├── .nvmrc ├── test-app ├── app │ ├── models │ │ └── .gitkeep │ ├── routes │ │ ├── .gitkeep │ │ └── application.ts │ ├── components │ │ ├── .gitkeep │ │ ├── playground.gts │ │ └── experiment.gts │ ├── controllers │ │ └── .gitkeep │ ├── helpers │ │ └── .gitkeep │ ├── styles │ │ └── app.css │ ├── config │ │ ├── environment.js │ │ └── environment.d.ts │ ├── mocks │ │ ├── browser.ts │ │ └── handlers.ts │ ├── router.ts │ ├── app.ts │ ├── instance-initializers │ │ └── apollo.ts │ └── templates │ │ └── application.gts ├── tests │ ├── unit │ │ ├── .gitkeep │ │ ├── client-test.ts │ │ ├── custom-query-test.ts │ │ ├── subscription-test.ts │ │ ├── mutation-test.ts │ │ └── query-test.ts │ ├── integration │ │ ├── .gitkeep │ │ └── components │ │ │ └── playground-test.gts │ ├── test-helper.ts │ ├── index.html │ └── helpers │ │ ├── index.ts │ │ └── mock-subscription-link.ts ├── .watchmanconfig ├── public │ ├── robots.txt │ └── mockServiceWorker.js ├── .template-lintrc.js ├── .stylelintrc.js ├── .stylelintignore ├── .prettierignore ├── config │ ├── ember-cli-update.json │ ├── targets.js │ ├── optional-features.json │ ├── ember-try.js │ └── environment.js ├── .ember-cli ├── ember-cli-build.js ├── vite.config.mjs ├── .editorconfig ├── .gitignore ├── testem.js ├── .prettierrc.cjs ├── index.html ├── tsconfig.json ├── babel.config.cjs ├── .github │ └── workflows │ │ └── ci.yml ├── README.md ├── package.json └── eslint.config.mjs ├── .prettierrc.js ├── glimmer-apollo ├── .template-lintrc.cjs ├── addon-main.cjs ├── .prettierignore ├── unpublished-development-types │ └── index.d.ts ├── .prettierrc.cjs ├── .gitignore ├── src │ ├── -private │ │ ├── types.ts │ │ ├── utils.ts │ │ ├── observable.ts │ │ ├── use-resource.ts │ │ ├── usables.ts │ │ ├── client.ts │ │ ├── mutation.ts │ │ ├── resource.ts │ │ ├── subscription.ts │ │ └── query.ts │ ├── template-registry.ts │ ├── index.ts │ └── environment.ts ├── babel.config.json ├── tsconfig.json ├── rollup.config.mjs ├── package.json └── eslint.config.mjs ├── pnpm-workspace.yaml ├── .prettierrc.cjs ├── netlify.toml ├── lerna.json ├── .prettierignore ├── .github ├── workflows │ ├── release-drafter.yml │ └── ci.yml └── release-drafter.yml ├── .gitignore ├── .editorconfig ├── config └── ember-cli-update.json ├── CONTRIBUTING.md ├── package.json ├── LICENSE.md ├── docs ├── README.md ├── getting-started.md └── fetching │ ├── subscriptions.md │ ├── mutations.md │ └── queries.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /test-app/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/tests/integration/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["dist"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@underline/eslint-config/.prettierrc.js'); 2 | -------------------------------------------------------------------------------- /test-app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /test-app/.template-lintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | }; 6 | -------------------------------------------------------------------------------- /glimmer-apollo/.template-lintrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | }; 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - glimmer-apollo 3 | - test-app 4 | - site 5 | # - site/lib/docfy-theme 6 | 7 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | plugins: ['prettier-plugin-ember-template-tag'], 5 | singleQuote: true, 6 | }; 7 | -------------------------------------------------------------------------------- /test-app/app/styles/app.css: -------------------------------------------------------------------------------- 1 | /* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */ 2 | -------------------------------------------------------------------------------- /test-app/.stylelintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: ['stylelint-config-standard', 'stylelint-prettier/recommended'], 5 | }; 6 | -------------------------------------------------------------------------------- /test-app/app/config/environment.js: -------------------------------------------------------------------------------- 1 | import loadConfigFromMeta from '@embroider/config-meta-loader'; 2 | 3 | export default loadConfigFromMeta('test-app'); 4 | -------------------------------------------------------------------------------- /glimmer-apollo/addon-main.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { addonV1Shim } = require('@embroider/addon-shim'); 4 | module.exports = addonV1Shim(__dirname); 5 | -------------------------------------------------------------------------------- /test-app/.stylelintignore: -------------------------------------------------------------------------------- 1 | # unconventional files 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # addons 8 | /.node_modules.ember-try/ 9 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "pnpm install && pnpm --filter site build" 3 | publish = "site/dist" 4 | 5 | [build.environment] 6 | NODE_VERSION = "20" 7 | -------------------------------------------------------------------------------- /glimmer-apollo/.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | /declarations/ 7 | 8 | # misc 9 | /coverage/ 10 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "glimmer-apollow", 4 | "test-app", 5 | "site" 6 | ], 7 | "version": "0.6.6", 8 | "npmClient": "pnpm" 9 | } 10 | -------------------------------------------------------------------------------- /test-app/app/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw/browser'; 2 | import { handlers } from './handlers'; 3 | 4 | export const worker = setupWorker(...handlers); 5 | -------------------------------------------------------------------------------- /test-app/.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # misc 8 | /coverage/ 9 | !.* 10 | .*/ 11 | 12 | # ember-try 13 | /.node_modules.ember-try/ 14 | -------------------------------------------------------------------------------- /test-app/config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | {"schemaVersion":"1.0.0","packages":[{"name":"@embroider/app-blueprint","version":"0.18.0","blueprints":[{"name":"@embroider/app-blueprint","isBaseBlueprint":true,"options":["--package-manager pnpm"]}]}]} -------------------------------------------------------------------------------- /test-app/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions', 7 | ]; 8 | 9 | module.exports = { 10 | browsers, 11 | }; 12 | -------------------------------------------------------------------------------- /test-app/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true, 6 | "no-implicit-route-model": true 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Prettier is also run from each package, so the ignores here 2 | # protect against files that may not be within a package 3 | 4 | # misc 5 | !.* 6 | .lint-todo/ 7 | 8 | # ember-try 9 | /.node_modules.ember-try/ 10 | /pnpm-lock.ember-try.yaml 11 | -------------------------------------------------------------------------------- /test-app/.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript 4 | rather than JavaScript by default, when a TypeScript version of a given blueprint is available. 5 | */ 6 | "isTypeScriptProject": true 7 | } 8 | -------------------------------------------------------------------------------- /glimmer-apollo/unpublished-development-types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Add any types here that you need for local development only. 2 | // These will *not* be published as part of your addon, so be careful that your published code does not rely on them! 3 | import 'ember-source/types'; 4 | import '@glint/ember-tsc/types'; 5 | -------------------------------------------------------------------------------- /test-app/app/router.ts: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from 'test-app/config/environment'; 3 | 4 | export default class Router extends EmberRouter { 5 | location = config.locationType; 6 | rootURL = config.rootURL; 7 | } 8 | 9 | Router.map(function () { 10 | // Add route declarations here 11 | }); 12 | -------------------------------------------------------------------------------- /glimmer-apollo/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | plugins: ['prettier-plugin-ember-template-tag'], 5 | overrides: [ 6 | { 7 | files: '*.{js,gjs,ts,gts,mjs,mts,cjs,cts}', 8 | options: { 9 | singleQuote: true, 10 | templateSingleQuote: false, 11 | }, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /test-app/ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberApp = require('ember-cli/lib/broccoli/ember-app'); 4 | const { Vite } = require('@embroider/vite'); 5 | const { compatBuild } = require('@embroider/compat'); 6 | 7 | module.exports = function (defaults) { 8 | let app = new EmberApp(defaults, {}); 9 | 10 | return compatBuild(app, Vite); 11 | }; 12 | -------------------------------------------------------------------------------- /test-app/app/config/environment.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type declarations for 3 | * import config from 'test-app/config/environment' 4 | */ 5 | declare const config: { 6 | environment: string; 7 | modulePrefix: string; 8 | podModulePrefix: string; 9 | locationType: 'history' | 'hash' | 'none'; 10 | rootURL: string; 11 | APP: Record; 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /test-app/vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { extensions, classicEmberSupport, ember } from '@embroider/vite'; 3 | import { babel } from '@rollup/plugin-babel'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | classicEmberSupport(), 8 | ember(), 9 | // extra plugins here 10 | babel({ 11 | babelHelpers: 'runtime', 12 | extensions, 13 | }), 14 | ], 15 | }); 16 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | draftRelease: 10 | runs-on: ubuntu-latest 11 | 12 | name: Draft Release 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Draft Release 16 | uses: toolmantim/release-drafter@v5.2.0 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules/ 6 | 7 | # misc 8 | .env* 9 | .pnp* 10 | .pnpm-debug.log 11 | .sass-cache 12 | .eslintcache 13 | coverage/ 14 | npm-debug.log* 15 | yarn-error.log 16 | 17 | # ember-try 18 | /.node_modules.ember-try/ 19 | /package.json.ember-try 20 | /package-lock.json.ember-try 21 | /yarn.lock.ember-try 22 | /pnpm-lock.ember-try.yaml 23 | 24 | -------------------------------------------------------------------------------- /glimmer-apollo/.gitignore: -------------------------------------------------------------------------------- 1 | # The authoritative copies of these live in the monorepo root (because they're 2 | # more useful on github that way), but the build copies them into here so they 3 | # will also appear in published NPM packages. 4 | /README.md 5 | /LICENSE.md 6 | 7 | # compiled output 8 | dist/ 9 | declarations/ 10 | 11 | # npm/pnpm/yarn pack output 12 | *.tgz 13 | 14 | # deps & caches 15 | node_modules/ 16 | .eslintcache 17 | .prettiercache 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /test-app/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/types.ts: -------------------------------------------------------------------------------- 1 | export interface TemplateArgs< 2 | T extends readonly unknown[] = readonly unknown[], 3 | > { 4 | positional: T; 5 | named: Record; 6 | } 7 | 8 | export interface Fastboot { 9 | isFastBoot: boolean; 10 | deferRendering(promise: Promise): unknown; 11 | } 12 | 13 | export declare function IWaitForPromise>( 14 | promise: KindOfPromise, 15 | label?: string, 16 | ): KindOfPromise; 17 | -------------------------------------------------------------------------------- /test-app/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /tmp/ 4 | /declarations/ 5 | 6 | # dependencies 7 | /node_modules/ 8 | 9 | # misc 10 | /.env* 11 | /.pnp* 12 | /.eslintcache 13 | /coverage/ 14 | /npm-debug.log* 15 | /testem.log 16 | /yarn-error.log 17 | 18 | # ember-try 19 | /.node_modules.ember-try/ 20 | /npm-shrinkwrap.json.ember-try 21 | /package.json.ember-try 22 | /package-lock.json.ember-try 23 | /yarn.lock.ember-try 24 | 25 | # broccoli-debug 26 | /DEBUG/ 27 | vite.config.mjs.timestamp* 28 | -------------------------------------------------------------------------------- /config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "projectName": "glimmer-apollo", 4 | "packages": [ 5 | { 6 | "name": "@embroider/addon-blueprint", 7 | "version": "3.2.0", 8 | "blueprints": [ 9 | { 10 | "name": "@embroider/addon-blueprint", 11 | "isBaseBlueprint": true, 12 | "options": [ 13 | "--ci-provider=github", 14 | "--typescript" 15 | ] 16 | } 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /test-app/tests/test-helper.ts: -------------------------------------------------------------------------------- 1 | import Application from 'test-app/app'; 2 | import config from 'test-app/config/environment'; 3 | import * as QUnit from 'qunit'; 4 | import { setApplication } from '@ember/test-helpers'; 5 | import { setup } from 'qunit-dom'; 6 | import { start as qunitStart } from 'ember-qunit'; 7 | import { worker } from 'test-app/mocks/browser'; 8 | 9 | export async function start() { 10 | setApplication(Application.create(config.APP)); 11 | setup(QUnit.assert); 12 | await worker.start(); 13 | 14 | qunitStart(); 15 | } 16 | -------------------------------------------------------------------------------- /test-app/app/app.ts: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import compatModules from '@embroider/virtual/compat-modules'; 3 | import Resolver from 'ember-resolver'; 4 | import loadInitializers from 'ember-load-initializers'; 5 | import config from './config/environment'; 6 | 7 | export default class App extends Application { 8 | modulePrefix = config.modulePrefix; 9 | podModulePrefix = config.podModulePrefix; 10 | Resolver = Resolver.withModules(compatModules); 11 | } 12 | 13 | loadInitializers(App, config.modulePrefix, compatModules); 14 | -------------------------------------------------------------------------------- /glimmer-apollo/src/template-registry.ts: -------------------------------------------------------------------------------- 1 | // Easily allow apps, which are not yet using strict mode templates, to consume your Glint types, by importing this file. 2 | // Add all your components, helpers and modifiers to the template registry here, so apps don't have to do this. 3 | // See https://typed-ember.gitbook.io/glint/environments/ember/authoring-addons 4 | 5 | // import type MyComponent from './components/my-component'; 6 | 7 | // Uncomment this once entries have been added! 👇 8 | // export default interface Registry { 9 | // MyComponent: typeof MyComponent 10 | // } 11 | -------------------------------------------------------------------------------- /test-app/app/routes/application.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access */ 2 | import Route from '@ember/routing/route'; 3 | import config from 'test-app/config/environment'; 4 | import { importSync } from '@embroider/macros'; 5 | 6 | export default class ApplicationRoute extends Route { 7 | async beforeModel(): Promise { 8 | if (config.environment === 'development') { 9 | // @ts-expect-error: cannot get type of that file 10 | await importSync('test-app/mocks/browser').worker.start(); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test-app/app/instance-initializers/apollo.ts: -------------------------------------------------------------------------------- 1 | import { setClient } from 'glimmer-apollo'; 2 | import { 3 | ApolloClient, 4 | InMemoryCache, 5 | createHttpLink, 6 | } from '@apollo/client/core'; 7 | import type ApplicationInstance from '@ember/application/instance'; 8 | 9 | export function initialize(appInstance: ApplicationInstance): void { 10 | setClient( 11 | appInstance, 12 | new ApolloClient({ 13 | cache: new InMemoryCache(), 14 | link: createHttpLink({ 15 | uri: '/graphql', 16 | }), 17 | }) 18 | ); 19 | } 20 | 21 | export default { 22 | initialize, 23 | }; 24 | -------------------------------------------------------------------------------- /test-app/app/templates/application.gts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import Route from 'ember-route-template'; 3 | import { pageTitle } from 'ember-page-title'; 4 | import Playground from 'test-app/components/playground'; 5 | import { getOwner } from '@ember/owner'; 6 | 7 | class Application extends Component { 8 | bla = () => { 9 | console.log('owner in application template', getOwner(this)); 10 | }; 11 | 12 | 19 | } 20 | 21 | export default Route(Application); 22 | -------------------------------------------------------------------------------- /glimmer-apollo/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "@babel/plugin-transform-typescript", 5 | { 6 | "allExtensions": true, 7 | "onlyRemoveTypeImports": true, 8 | "allowDeclareFields": true 9 | } 10 | ], 11 | "@embroider/addon-dev/template-colocation-plugin", 12 | [ 13 | "babel-plugin-ember-template-compilation", 14 | { 15 | "targetFormat": "hbs", 16 | "transforms": [] 17 | } 18 | ], 19 | [ 20 | "module:decorator-transforms", 21 | { "runtime": { "import": "decorator-transforms/runtime" } } 22 | ] 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /test-app/app/components/playground.gts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import { tracked } from '@glimmer/tracking'; 3 | import { on } from '@ember/modifier'; 4 | import Experiment from './experiment'; 5 | 6 | export default class Playground extends Component { 7 | @tracked isExperimenting = false; 8 | 9 | toggle = (): void => { 10 | this.isExperimenting = !this.isExperimenting; 11 | }; 12 | 13 | 26 | } 27 | -------------------------------------------------------------------------------- /test-app/tests/integration/components/playground-test.gts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { click, render, waitFor } from '@ember/test-helpers'; 4 | import Playground from 'test-app/components/playground'; 5 | 6 | module('Integration | Components | Playground', function (hooks) { 7 | setupRenderingTest(hooks); 8 | 9 | test('no waiters should be left behind when resource is teardown before resolving', async function (assert) { 10 | await render(); 11 | 12 | click('[data-test-id="toggle"]'); 13 | await waitFor('[data-test-id="experiment"]', { timeout: 100 }); 14 | 15 | await click('[data-test-id="toggle"]'); 16 | assert.ok(true); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test-app/testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (typeof module !== 'undefined') { 4 | module.exports = { 5 | test_page: 'tests/index.html?hidepassed', 6 | disable_watching: true, 7 | launch_in_ci: ['Chrome'], 8 | launch_in_dev: ['Chrome'], 9 | browser_start_timeout: 120, 10 | browser_args: { 11 | Chrome: { 12 | ci: [ 13 | // --no-sandbox is needed when running Chrome inside a container 14 | process.env.CI ? '--no-sandbox' : null, 15 | '--headless', 16 | '--disable-dev-shm-usage', 17 | '--disable-software-rasterizer', 18 | '--mute-audio', 19 | '--remote-debugging-port=0', 20 | '--window-size=1440,900', 21 | ].filter(Boolean), 22 | }, 23 | }, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | - `git clone ` 6 | - `cd glimmer-apollo` 7 | - `npm install` 8 | 9 | ## Linting 10 | 11 | - `npm run lint` 12 | - `npm run lint:fix` 13 | 14 | ## Building the addon 15 | 16 | - `cd glimmer-apollo` 17 | - `npm build` 18 | 19 | ## Running tests 20 | 21 | - `cd test-app` 22 | - `npm run test` – Runs the test suite on the current Ember version 23 | - `npm run test:watch` – Runs the test suite in "watch mode" 24 | 25 | ## Running the test application 26 | 27 | - `cd test-app` 28 | - `npm run start` 29 | - Visit the test application at [http://localhost:4200](http://localhost:4200). 30 | 31 | For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/). 32 | -------------------------------------------------------------------------------- /glimmer-apollo/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | getClient, 3 | setClient, 4 | clearClient, 5 | clearClients, 6 | } from './-private/client.ts'; 7 | export { gql } from '@apollo/client/core'; 8 | export { useQuery, useMutation, useSubscription } from './-private/usables.ts'; 9 | export type { 10 | UseQuery, 11 | UseMutation, 12 | UseSubscription, 13 | } from './-private/usables.ts'; 14 | export type { 15 | QueryOptions, 16 | QueryResource, 17 | QueryPositionalArgs, 18 | } from './-private/query.ts'; 19 | export type { 20 | MutationOptions, 21 | MutationResource, 22 | MutationPositionalArgs, 23 | } from './-private/mutation.ts'; 24 | export type { 25 | SubscriptionOptions, 26 | SubscriptionResource, 27 | SubscriptionPositionalArgs, 28 | } from './-private/subscription.ts'; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "license": "MIT", 4 | "scripts": { 5 | "build": "pnpm --filter glimmer-apollo build", 6 | "lint": "pnpm --filter '*' lint", 7 | "lint:fix": "pnpm --filter '*' lint:fix", 8 | "start": "concurrently 'pnpm:start:*' --restart-after 5000 --prefixColors auto", 9 | "start:addon": "pnpm --filter glimmer-apollo start --no-watch.clearScreen", 10 | "start:test-app": "pnpm --filter test-app start", 11 | "test": "pnpm --filter '*' test", 12 | "test:ember": "pnpm --filter '*' test:ember" 13 | }, 14 | "devDependencies": { 15 | "concurrently": "^9.2.1", 16 | "prettier": "^3.7.4", 17 | "prettier-plugin-ember-template-tag": "^2.1.2" 18 | }, 19 | "workspaces": [ 20 | "glimmer-apollo", 21 | "test-app", 22 | "site" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /test-app/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | plugins: ['prettier-plugin-ember-template-tag'], 5 | singleQuote: true, 6 | overrides: [ 7 | { 8 | files: ['*.js', '*.ts', '*.cjs', '.mjs', '.cts', '.mts', '.cts'], 9 | options: { 10 | trailingComma: 'es5', 11 | }, 12 | }, 13 | { 14 | files: ['*.html'], 15 | options: { 16 | singleQuote: false, 17 | }, 18 | }, 19 | { 20 | files: ['*.json'], 21 | options: { 22 | singleQuote: false, 23 | }, 24 | }, 25 | { 26 | files: ['*.hbs'], 27 | options: { 28 | singleQuote: false, 29 | }, 30 | }, 31 | { 32 | files: ['*.gjs', '*.gts'], 33 | options: { 34 | templateSingleQuote: false, 35 | trailingComma: 'es5', 36 | }, 37 | }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /test-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppTemplate 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | 11 | 12 | 13 | 14 | {{content-for "head-footer"}} 15 | 16 | 17 | {{content-for "body"}} 18 | 19 | 20 | 26 | 27 | {{content-for "body-footer"}} 28 | 29 | 30 | -------------------------------------------------------------------------------- /glimmer-apollo/src/environment.ts: -------------------------------------------------------------------------------- 1 | import ApplicationInstance from '@ember/application/instance'; 2 | import type Owner from '@ember/owner'; 3 | export { tracked } from '@glimmer/tracking'; 4 | import { getOwner as _getOwner } from '@ember/owner'; 5 | export { setOwner } from '@ember/owner'; 6 | export { getValue, createCache } from '@glimmer/tracking/primitives/cache'; 7 | export { 8 | isDestroying, 9 | isDestroyed, 10 | destroy, 11 | registerDestructor, 12 | associateDestroyableChild, 13 | } from '@ember/destroyable'; 14 | export { waitForPromise } from '@ember/test-waiters'; 15 | 16 | export { 17 | invokeHelper, 18 | setHelperManager, 19 | capabilities as helperCapabilities, 20 | } from '@ember/helper'; 21 | 22 | export function getOwner(obj: object): Owner | undefined { 23 | if ( 24 | obj instanceof ApplicationInstance || 25 | ('lookup' in obj && 'factoryFor' in obj) 26 | ) { 27 | return obj as Owner; 28 | } 29 | 30 | return _getOwner(obj); 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /test-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/ember/tsconfig.json", 3 | "include": [ 4 | "app", "tests", "types" 5 | ], 6 | "compilerOptions": { 7 | "allowJs": true, 8 | /** 9 | https://www.typescriptlang.org/tsconfig#noEmitOnError 10 | Do not block emit on TS errors. 11 | */ 12 | "noEmitOnError": false, 13 | 14 | "declaration": false, 15 | "declarationMap": false, 16 | 17 | /** 18 | https://www.typescriptlang.org/tsconfig#allowImportingTsExtensions 19 | 20 | We want our tooling to know how to resolve our custom files so the appropriate plugins 21 | can do the proper transformations on those files. 22 | */ 23 | "allowImportingTsExtensions": true, 24 | "paths": { 25 | "test-app/tests/*": [ 26 | "./tests/*" 27 | ], 28 | "test-app/*": [ 29 | "./app/*" 30 | ], 31 | "*": [ 32 | "./types/*" 33 | ] 34 | }, 35 | "types": [ 36 | "ember-source/types", 37 | "@glint/ember-tsc/types", 38 | "@embroider/core/virtual" 39 | ] 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Release Drafter - https://github.com/toolmantim/release-drafter 2 | 3 | name-template: v$NEXT_MINOR_VERSION 4 | tag-template: v$NEXT_MINOR_VERSION 5 | categories: 6 | - title: ':boom: Breaking Change' 7 | label: 'Type: Breaking Change' 8 | 9 | - title: ':rocket: Enhancement' 10 | label: 'Type: Enhancement' 11 | 12 | - title: ':bug: Bug Fix' 13 | label: 'Type: Bug' 14 | 15 | - title: ':nail_care: Refactor' 16 | label: 'Type: Refactor' 17 | 18 | - title: ':memo: Documentation' 19 | label: 'Type: Documentation' 20 | 21 | - title: ':house: Internal' 22 | label: 'Type: Internal' 23 | 24 | - title: ':wrench: Tooling' 25 | label: 'Type: Tooling' 26 | 27 | - title: ':package: Dependencies' 28 | label: 'Type: Dependencies' 29 | 30 | change-template: '- $TITLE (#$NUMBER) @$AUTHOR' 31 | no-changes-template: '- No changes' 32 | template: | 33 | $CHANGES 34 | 35 | *** 36 | 37 | ### Contributors 38 | 39 | $CONTRIBUTORS 40 | 41 | *** 42 | 43 | For full changes, see the [comparison between $PREVIOUS_TAG and v$NEXT_MINOR_VERSION](https://github.com/josemarluedke/glimmer-apollo/compare/$PREVIOUS_TAG...v$NEXT_MINOR_VERSION) 44 | -------------------------------------------------------------------------------- /test-app/config/ember-try.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getChannelURL = require('ember-source-channel-url'); 4 | 5 | module.exports = async function () { 6 | return { 7 | packageManager: 'pnpm', 8 | command: 'pnpm test:ember', 9 | scenarios: [ 10 | { 11 | name: 'ember-lts-4.12', 12 | npm: { 13 | devDependencies: { 14 | 'ember-source': '~4.12.0', 15 | }, 16 | }, 17 | }, 18 | { 19 | name: 'ember-lts-5.4', 20 | npm: { 21 | devDependencies: { 22 | 'ember-source': '~5.4.0', 23 | }, 24 | }, 25 | }, 26 | { 27 | name: 'ember-release', 28 | npm: { 29 | devDependencies: { 30 | 'ember-source': await getChannelURL('release'), 31 | }, 32 | }, 33 | }, 34 | { 35 | name: 'ember-beta', 36 | npm: { 37 | devDependencies: { 38 | 'ember-source': await getChannelURL('beta'), 39 | }, 40 | }, 41 | }, 42 | { 43 | name: 'ember-canary', 44 | npm: { 45 | devDependencies: { 46 | 'ember-source': await getChannelURL('canary'), 47 | }, 48 | }, 49 | }, 50 | ], 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /test-app/babel.config.cjs: -------------------------------------------------------------------------------- 1 | const { 2 | babelCompatSupport, 3 | templateCompatSupport, 4 | } = require('@embroider/compat/babel'); 5 | 6 | module.exports = { 7 | plugins: [ 8 | [ 9 | '@babel/plugin-transform-typescript', 10 | { 11 | allExtensions: true, 12 | onlyRemoveTypeImports: true, 13 | allowDeclareFields: true, 14 | }, 15 | ], 16 | [ 17 | 'babel-plugin-ember-template-compilation', 18 | { 19 | compilerPath: 'ember-source/dist/ember-template-compiler.js', 20 | enableLegacyModules: [ 21 | 'ember-cli-htmlbars', 22 | 'ember-cli-htmlbars-inline-precompile', 23 | 'htmlbars-inline-precompile', 24 | ], 25 | transforms: [...templateCompatSupport()], 26 | }, 27 | ], 28 | [ 29 | 'module:decorator-transforms', 30 | { 31 | runtime: { 32 | import: require.resolve('decorator-transforms/runtime-esm'), 33 | }, 34 | }, 35 | ], 36 | [ 37 | '@babel/plugin-transform-runtime', 38 | { 39 | absoluteRuntime: __dirname, 40 | useESModules: true, 41 | regenerator: false, 42 | }, 43 | ], 44 | ...babelCompatSupport(), 45 | ], 46 | 47 | generatorOpts: { 48 | compact: false, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /test-app/.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: {} 9 | 10 | concurrency: 11 | group: ci-${{ github.head_ref || github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | name: "Lint" 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 10 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: pnpm/action-setup@v2 23 | with: 24 | version: 8 25 | - name: Install Node 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 18 29 | cache: pnpm 30 | - name: Install Dependencies 31 | run: pnpm install --frozen-lockfile 32 | - name: Lint 33 | run: pnpm lint 34 | 35 | test: 36 | name: "Test" 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | 40 | steps: 41 | - uses: actions/checkout@v3 42 | - uses: pnpm/action-setup@v2 43 | with: 44 | version: 8 45 | - name: Install Node 46 | uses: actions/setup-node@v3 47 | with: 48 | node-version: 18 49 | cache: pnpm 50 | - name: Install Dependencies 51 | run: pnpm install --frozen-lockfile 52 | - name: Run Tests 53 | run: pnpm test 54 | -------------------------------------------------------------------------------- /test-app/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (environment) { 4 | const ENV = { 5 | modulePrefix: 'test-app', 6 | environment, 7 | rootURL: '/', 8 | locationType: 'history', 9 | EmberENV: { 10 | EXTEND_PROTOTYPES: false, 11 | FEATURES: { 12 | // Here you can enable experimental features on an ember canary build 13 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 14 | }, 15 | }, 16 | 17 | APP: { 18 | // Here you can pass flags/options to your application instance 19 | // when it is created 20 | }, 21 | }; 22 | 23 | if (environment === 'development') { 24 | // ENV.APP.LOG_RESOLVER = true; 25 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 26 | // ENV.APP.LOG_TRANSITIONS = true; 27 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 28 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 29 | } 30 | 31 | if (environment === 'test') { 32 | // Testem prefers this... 33 | ENV.locationType = 'none'; 34 | 35 | // keep test console output quieter 36 | ENV.APP.LOG_ACTIVE_GENERATION = false; 37 | ENV.APP.LOG_VIEW_LOOKUPS = false; 38 | 39 | ENV.APP.rootElement = '#ember-testing'; 40 | ENV.APP.autoboot = false; 41 | } 42 | 43 | if (environment === 'production') { 44 | // here you can enable a production-specific feature 45 | } 46 | 47 | return ENV; 48 | }; 49 | -------------------------------------------------------------------------------- /test-app/tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppTemplate Tests 6 | 7 | 8 | 9 | {{content-for "head"}} {{content-for "test-head"}} 10 | 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} {{content-for "test-head-footer"}} 16 | 17 | 18 | {{content-for "body"}} {{content-for "test-body"}} 19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | {{content-for "body-footer"}} 39 | 40 | 41 | -------------------------------------------------------------------------------- /test-app/tests/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | setupApplicationTest as upstreamSetupApplicationTest, 3 | setupRenderingTest as upstreamSetupRenderingTest, 4 | setupTest as upstreamSetupTest, 5 | type SetupTestOptions, 6 | } from 'ember-qunit'; 7 | 8 | // This file exists to provide wrappers around ember-qunit's 9 | // test setup functions. This way, you can easily extend the setup that is 10 | // needed per test type. 11 | 12 | function setupApplicationTest(hooks: NestedHooks, options?: SetupTestOptions) { 13 | upstreamSetupApplicationTest(hooks, options); 14 | 15 | // Additional setup for application tests can be done here. 16 | // 17 | // For example, if you need an authenticated session for each 18 | // application test, you could do: 19 | // 20 | // hooks.beforeEach(async function () { 21 | // await authenticateSession(); // ember-simple-auth 22 | // }); 23 | // 24 | // This is also a good place to call test setup functions coming 25 | // from other addons: 26 | // 27 | // setupIntl(hooks, 'en-us'); // ember-intl 28 | // setupMirage(hooks); // ember-cli-mirage 29 | } 30 | 31 | function setupRenderingTest(hooks: NestedHooks, options?: SetupTestOptions) { 32 | upstreamSetupRenderingTest(hooks, options); 33 | 34 | // Additional setup for rendering tests can be done here. 35 | } 36 | 37 | function setupTest(hooks: NestedHooks, options?: SetupTestOptions) { 38 | upstreamSetupTest(hooks, options); 39 | 40 | // Additional setup for unit tests can be done here. 41 | } 42 | 43 | export { setupApplicationTest, setupRenderingTest, setupTest }; 44 | -------------------------------------------------------------------------------- /test-app/README.md: -------------------------------------------------------------------------------- 1 | # test-app 2 | 3 | This README outlines the details of collaborating on this Ember application. 4 | A short introduction of this app could easily go here. 5 | 6 | ## Prerequisites 7 | 8 | You will need the following things properly installed on your computer. 9 | 10 | - [Git](https://git-scm.com/) 11 | - [Node.js](https://nodejs.org/) 12 | - [pnpm](https://pnpm.io/) 13 | - [Ember CLI](https://cli.emberjs.com/release/) 14 | - [Google Chrome](https://google.com/chrome/) 15 | 16 | ## Installation 17 | 18 | - `git clone ` this repository 19 | - `cd test-app` 20 | - `pnpm install` 21 | 22 | ## Running / Development 23 | 24 | - `pnpm start` 25 | - Visit your app at [http://localhost:4200](http://localhost:4200). 26 | - Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests). 27 | 28 | ### Code Generators 29 | 30 | Make use of the many generators for code, try `ember help generate` for more details 31 | 32 | ### Running Tests 33 | 34 | - `pnpm test` 35 | - `pnpm test:ember --server` 36 | 37 | ### Linting 38 | 39 | - `pnpm lint` 40 | - `pnpm lint:fix` 41 | 42 | ### Building 43 | 44 | - `pnpm ember build` (development) 45 | - `pnpm build` (production) 46 | 47 | ### Deploying 48 | 49 | Specify what it takes to deploy your app. 50 | 51 | ## Further Reading / Useful Links 52 | 53 | - [ember.js](https://emberjs.com/) 54 | - [ember-cli](https://cli.emberjs.com/release/) 55 | - Development Browser Extensions 56 | - [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi) 57 | - [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/) 58 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/utils.ts: -------------------------------------------------------------------------------- 1 | import { getOwner } from '../environment.ts'; 2 | import type { Fastboot } from './types'; 3 | 4 | function hasFastBoot(obj: unknown): obj is { FastBoot: unknown } { 5 | return Object.prototype.hasOwnProperty.call(obj, 'FastBoot'); 6 | } 7 | 8 | function ownerHasLookup( 9 | owner: object | undefined, 10 | ): owner is { lookup: unknown } { 11 | return !!(owner && 'lookup' in owner); 12 | } 13 | 14 | export function getFastboot(ctx: object): Fastboot | undefined { 15 | if ( 16 | typeof self != 'undefined' && 17 | hasFastBoot(self) && 18 | typeof self.FastBoot !== 'undefined' 19 | ) { 20 | const owner = getOwner(ctx) as object | undefined; 21 | 22 | if (ownerHasLookup(owner) && typeof owner.lookup === 'function') { 23 | return owner.lookup('service:fastboot') as Fastboot; // eslint-disable-line @typescript-eslint/no-unsafe-call 24 | } 25 | } 26 | 27 | return undefined; 28 | } 29 | 30 | export function createPromise(): [ 31 | Promise, 32 | (() => void) | undefined, 33 | (() => void) | undefined, 34 | ] { 35 | let resolvePromise: (val?: void | Promise) => unknown; 36 | let rejectPromise: (val?: undefined) => unknown; 37 | const promise = new Promise((resolve, reject) => { 38 | resolvePromise = resolve; 39 | rejectPromise = reject; 40 | }); 41 | 42 | return [promise, resolvePromise!, rejectPromise!]; 43 | } 44 | 45 | export function settled(promise?: Promise): Promise { 46 | return new Promise((resolve) => { 47 | if (promise) { 48 | promise.then(() => resolve()).catch(() => resolve()); 49 | } else { 50 | resolve(); 51 | } 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /glimmer-apollo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/ember/tsconfig.json", 3 | "include": ["src/**/*", "unpublished-development-types/**/*"], 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "declaration": true, 7 | "declarationDir": "declarations", 8 | /** 9 | https://www.typescriptlang.org/tsconfig#noEmit 10 | 11 | We want to emit declarations, so this option must be set to `false`. 12 | @tsconfig/ember sets this to `true`, which is incompatible with our need to set `emitDeclarationOnly`. 13 | @tsconfig/ember is more optimized for apps, which wouldn't emit anything, only type check. 14 | */ 15 | "noEmit": false, 16 | /** 17 | https://www.typescriptlang.org/tsconfig#emitDeclarationOnly 18 | We want to only emit declarations as we use Rollup to emit JavaScript. 19 | */ 20 | "emitDeclarationOnly": true, 21 | 22 | /** 23 | https://www.typescriptlang.org/tsconfig#noEmitOnError 24 | Do not block emit on TS errors. 25 | */ 26 | "noEmitOnError": false, 27 | 28 | /** 29 | https://www.typescriptlang.org/tsconfig#rootDir 30 | "Default: The longest common path of all non-declaration input files." 31 | 32 | Because we want our declarations' structure to match our rollup output, 33 | we need this "rootDir" to match the "srcDir" in the rollup.config.mjs. 34 | 35 | This way, we can have simpler `package.json#exports` that matches 36 | imports to files on disk 37 | */ 38 | "rootDir": "./src", 39 | 40 | /** 41 | https://www.typescriptlang.org/tsconfig#allowImportingTsExtensions 42 | 43 | We want our tooling to know how to resolve our custom files so the appropriate plugins 44 | can do the proper transformations on those files. 45 | */ 46 | "allowImportingTsExtensions": true, 47 | 48 | "types": ["ember-source/types"] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This library integrates Apollo Client in your Ember app with declarative API to query, mutate and access GraphQL data. 4 | 5 | ## What is GraphQL? 6 | 7 | [GraphQL](https://graphql.org/) is a query language for APIs and a runtime for fulfilling those queries 8 | with your existing data. GraphQL provides a complete and understandable description 9 | of the data in your API, gives clients the power to ask for exactly what they 10 | need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools. 11 | 12 | ## What is Apollo Client? 13 | 14 | [Apollo Client](https://www.apollographql.com/docs/react/) is a declarative data fetching library for GraphQL. 15 | It is one of the most popular options to execute GraphQL queries and mutations in JavaScript. 16 | 17 | ## Why Glimmer Apollo? 18 | 19 | Glimmer Apollo uses the concept of [Resources](https://www.pzuraq.com/introducing-use/) to allow easy integration of Glimmer's auto-tracking system with Apollo Client. It's built on top of public primitives available in Ember. 20 | 21 | The alternative in Ember, [ember-apollo-client](https://github.com/ember-graphql/ember-apollo-client/), is built using services and their own concept of query manager using decorators. While ember-apollo-client has been working for many applications in production just fine, it has a different take on how to fetch data. There is no reactive system to refetch queries when arguments change, for example. Glimmer Apollo takes full advantage of the auto-tracking system that Glimmer provides, allowing us to have a declarative API to query, mutate and access GraphQL data. 22 | 23 | Glimmer Apollo also provides [TypeScript](https://www.typescriptlang.org/) support by default, while ember-apollo-client does not have any types. 24 | 25 | > Disclosure: The author of glimmer-apollo is one of the active maintainers of ember-apollo-client. 26 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/observable.ts: -------------------------------------------------------------------------------- 1 | import { Resource } from './resource.ts'; 2 | import type { 3 | FetchMoreQueryOptions, 4 | ObservableQuery, 5 | OperationVariables, 6 | SubscribeToMoreOptions, 7 | Unmasked, 8 | WatchQueryOptions, 9 | } from '@apollo/client/core'; 10 | import type { TemplateArgs } from './types'; 11 | 12 | export default class ObservableResource< 13 | TData, 14 | TVariables extends OperationVariables, 15 | Args extends TemplateArgs, 16 | > extends Resource { 17 | private observable?: ObservableQuery; 18 | 19 | protected _setObservable(observable: ObservableQuery) { 20 | this.observable = observable; 21 | } 22 | 23 | refetch = (variables?: Partial | MouseEvent) => { 24 | if (variables instanceof MouseEvent) { 25 | return this.observable?.refetch(); 26 | } 27 | return this.observable?.refetch(variables); 28 | }; 29 | 30 | fetchMore = < 31 | TFetchData = TData, 32 | TFetchVars extends OperationVariables = TVariables, 33 | >( 34 | fetchMoreOptions: FetchMoreQueryOptions & { 35 | updateQuery?: ( 36 | previousQueryResult: Unmasked, 37 | options: { 38 | fetchMoreResult: Unmasked; 39 | variables: TFetchVars; 40 | }, 41 | ) => Unmasked; 42 | }, 43 | ) => this.observable?.fetchMore(fetchMoreOptions); 44 | 45 | updateQuery = ( 46 | mapFn: ( 47 | previousQueryResult: Unmasked, 48 | options: Pick, 'variables'>, 49 | ) => Unmasked, 50 | ) => this.observable?.updateQuery(mapFn); 51 | 52 | startPolling = (pollInterval: number) => { 53 | this.observable?.startPolling(pollInterval); 54 | }; 55 | 56 | stopPolling = () => { 57 | this.observable?.stopPolling(); 58 | }; 59 | 60 | subscribeToMore = < 61 | TSubscriptionData = TData, 62 | TSubscriptionVariables extends OperationVariables = TVariables, 63 | >( 64 | options: SubscribeToMoreOptions< 65 | TData, 66 | TSubscriptionVariables, 67 | TSubscriptionData, 68 | TVariables 69 | >, 70 | ) => this.observable?.subscribeToMore(options); 71 | } 72 | -------------------------------------------------------------------------------- /test-app/tests/helpers/mock-subscription-link.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */ 2 | // Origianl Implementation: https://github.com/apollographql/apollo-client/blob/main/src/utilities/testing/mocking/mockSubscriptionLink.ts 3 | 4 | import { Observable } from '@apollo/client/utilities'; 5 | import { 6 | ApolloLink, 7 | type FetchResult, 8 | type Operation, 9 | } from '@apollo/client/core'; 10 | 11 | export interface MockedSubscription { 12 | request: Operation; 13 | } 14 | 15 | export interface MockedSubscriptionResult { 16 | result?: FetchResult; 17 | error?: Error; 18 | delay?: number; 19 | } 20 | 21 | export class MockSubscriptionLink extends ApolloLink { 22 | unsubscribers: any[] = []; 23 | setups: any[] = []; 24 | operation?: Operation; 25 | private observers: any[] = []; 26 | 27 | request(operation: Operation) { 28 | this.operation = operation; 29 | return new Observable((observer) => { 30 | this.setups.forEach((x) => x()); 31 | this.observers.push(observer); 32 | return () => { 33 | this.unsubscribers.forEach((x) => x()); 34 | }; 35 | }); 36 | } 37 | 38 | simulateResult(result: MockedSubscriptionResult, complete = false) { 39 | setTimeout(() => { 40 | const { observers } = this; 41 | if (!observers.length) throw new Error('subscription torn down'); 42 | observers.forEach((observer) => { 43 | if (result.result && observer.next) observer.next(result.result); 44 | if (result.error && observer.error) observer.error(result.error); 45 | if (complete && observer.complete) observer.complete(); 46 | }); 47 | }, result.delay || 0); 48 | } 49 | 50 | simulateComplete() { 51 | const { observers } = this; 52 | if (!observers.length) throw new Error('subscription torn down'); 53 | observers.forEach((observer) => { 54 | if (observer.complete) observer.complete(); 55 | }); 56 | } 57 | 58 | onSetup(listener: any): void { 59 | this.setups = this.setups.concat([listener]); 60 | } 61 | 62 | onUnsubscribe(listener: any): void { 63 | this.unsubscribers = this.unsubscribers.concat([listener]); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: {} 9 | 10 | concurrency: 11 | group: ci-${{ github.head_ref || github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | name: "Tests" 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 10 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | with: 24 | version: 9 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: 22 28 | cache: pnpm 29 | - name: Install Dependencies 30 | run: pnpm install --frozen-lockfile 31 | - name: Build Addon 32 | run: pnpm build 33 | - name: Lint 34 | run: pnpm lint 35 | - name: Run Tests 36 | run: pnpm test 37 | 38 | floating: 39 | name: "Floating Dependencies" 40 | runs-on: ubuntu-latest 41 | timeout-minutes: 10 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: pnpm/action-setup@v4 46 | with: 47 | version: 9 48 | - uses: actions/setup-node@v4 49 | with: 50 | node-version: 22 51 | cache: pnpm 52 | - name: Install Dependencies 53 | run: pnpm install --no-lockfile 54 | - name: Build Addon 55 | run: pnpm build 56 | - name: Run Tests 57 | run: pnpm test 58 | 59 | try-scenarios: 60 | name: ${{ matrix.try-scenario }} 61 | runs-on: ubuntu-latest 62 | needs: 'test' 63 | timeout-minutes: 10 64 | 65 | strategy: 66 | fail-fast: false 67 | matrix: 68 | try-scenario: 69 | - ember-lts-4.12 70 | - ember-lts-5.4 71 | - ember-release 72 | - ember-beta 73 | - ember-canary 74 | 75 | steps: 76 | - uses: actions/checkout@v4 77 | - uses: pnpm/action-setup@v4 78 | with: 79 | version: 9 80 | - uses: actions/setup-node@v4 81 | with: 82 | node-version: 22 83 | cache: pnpm 84 | - name: Install Dependencies 85 | run: pnpm install --frozen-lockfile 86 | - name: Build Addon 87 | run: pnpm build 88 | - name: Run Tests 89 | run: pnpm exec ember try:one ${{ matrix.try-scenario }} --skip-cleanup 90 | working-directory: test-app 91 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/use-resource.ts: -------------------------------------------------------------------------------- 1 | import { getOwner } from '@ember/owner'; 2 | import { 3 | invokeHelper, 4 | getValue, 5 | setHelperManager, 6 | createCache, 7 | } from '../environment.ts'; 8 | import { ResourceManagerFactory, Resource } from './resource.ts'; 9 | import type { TemplateArgs } from './types'; 10 | type Cache = ReturnType>; 11 | 12 | type Args = TemplateArgs | TemplateArgs['positional'] | TemplateArgs['named']; 13 | 14 | function normalizeArgs(args: Args): TemplateArgs { 15 | if (Array.isArray(args)) { 16 | return { positional: args, named: {} }; 17 | } 18 | 19 | if ('positional' in args || 'named' in args) { 20 | return { 21 | positional: (args.positional as TemplateArgs['positional']) || [], 22 | named: (args.named as TemplateArgs['named']) || {}, 23 | }; 24 | } 25 | 26 | if (typeof args === 'object') { 27 | return { named: args as TemplateArgs['named'], positional: [] }; 28 | } 29 | 30 | return args; 31 | } 32 | 33 | export function useUnproxiedResource< 34 | TArgs = Args, 35 | T extends Resource = Resource, 36 | >(context: object, Class: object, args?: () => TArgs): { value: T } { 37 | let resource: Cache; 38 | 39 | return { 40 | get value(): T { 41 | if (!resource) { 42 | const owner = getOwner(context); 43 | const definition = { Class, owner }; 44 | setHelperManager(ResourceManagerFactory, definition); 45 | resource = invokeHelper(context, definition, () => { 46 | return normalizeArgs(args?.() || {}); 47 | }) as Cache; 48 | } 49 | 50 | return getValue(resource)!; 51 | }, 52 | }; 53 | } 54 | 55 | export function useResource< 56 | TArgs = Args, 57 | T extends Resource = Resource, 58 | >(context: object, definition: object, args?: () => TArgs): T { 59 | const target = useUnproxiedResource(context, definition, args); 60 | 61 | return new Proxy(target, { 62 | get(target, key): unknown { 63 | const instance = target.value; 64 | const value = Reflect.get(instance, key, instance); 65 | 66 | return typeof value === 'function' ? value.bind(instance) : value; 67 | }, 68 | ownKeys(target): (string | symbol)[] { 69 | return Reflect.ownKeys(target.value); 70 | }, 71 | getOwnPropertyDescriptor(target, key): PropertyDescriptor | undefined { 72 | return Reflect.getOwnPropertyDescriptor(target.value, key); 73 | }, 74 | }) as never as T; 75 | } 76 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/usables.ts: -------------------------------------------------------------------------------- 1 | import { useResource } from './use-resource.ts'; 2 | import { type MutationPositionalArgs, MutationResource } from './mutation.ts'; 3 | import { type QueryPositionalArgs, QueryResource } from './query.ts'; 4 | import { 5 | type SubscriptionPositionalArgs, 6 | SubscriptionResource, 7 | } from './subscription.ts'; 8 | import type { OperationVariables } from '@apollo/client/core'; 9 | 10 | export function useQuery< 11 | TData = unknown, 12 | TVariables extends OperationVariables = OperationVariables, 13 | >( 14 | parentDestroyable: object, 15 | args: () => QueryPositionalArgs, 16 | ): QueryResource { 17 | return useResource< 18 | QueryPositionalArgs, 19 | QueryResource 20 | >(parentDestroyable, QueryResource, args); 21 | } 22 | 23 | export function useMutation< 24 | TData = unknown, 25 | TVariables extends OperationVariables = OperationVariables, 26 | >( 27 | parentDestroyable: object, 28 | args: () => MutationPositionalArgs, 29 | ): MutationResource { 30 | return useResource< 31 | MutationPositionalArgs, 32 | MutationResource 33 | >(parentDestroyable, MutationResource, args); 34 | } 35 | 36 | export function useSubscription< 37 | TData = unknown, 38 | TVariables extends OperationVariables = OperationVariables, 39 | >( 40 | parentDestroyable: object, 41 | args: () => SubscriptionPositionalArgs, 42 | ): SubscriptionResource { 43 | return useResource< 44 | SubscriptionPositionalArgs, 45 | SubscriptionResource 46 | >(parentDestroyable, SubscriptionResource, args); 47 | } 48 | 49 | export type UseQuery< 50 | TData = unknown, 51 | TVariables extends OperationVariables = OperationVariables, 52 | > = { 53 | args: () => QueryPositionalArgs[1]; 54 | return: QueryResource; 55 | data: TData; 56 | variables: TVariables; 57 | }; 58 | 59 | export type UseMutation< 60 | TData = unknown, 61 | TVariables extends OperationVariables = OperationVariables, 62 | > = { 63 | args: () => MutationPositionalArgs[1]; 64 | return: MutationResource; 65 | data: TData; 66 | variables: TVariables; 67 | }; 68 | 69 | export type UseSubscription< 70 | TData = unknown, 71 | TVariables extends OperationVariables = OperationVariables, 72 | > = { 73 | args: () => SubscriptionPositionalArgs[1]; 74 | return: SubscriptionResource; 75 | data: TData; 76 | variables: TVariables; 77 | }; 78 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/client.ts: -------------------------------------------------------------------------------- 1 | import { getOwner, registerDestructor } from '../environment.ts'; 2 | import type { ApolloClient } from '@apollo/client/core'; 3 | 4 | type Owner = object; 5 | 6 | const CLIENTS: WeakMap< 7 | Owner, 8 | Map> 9 | > = new WeakMap(); 10 | const DEFAULT_CLIENT_ID = 'default'; 11 | 12 | export function setClient( 13 | context: object, 14 | client: ApolloClient, 15 | clientId: string = DEFAULT_CLIENT_ID, 16 | ): void { 17 | const owner = getOwner(context) as Owner | null; 18 | 19 | if (!owner) { 20 | throw new Error( 21 | 'Unable to find owner from the given context in glimmer-apollo setClient', 22 | ); 23 | } 24 | 25 | if (!CLIENTS.has(owner)) { 26 | CLIENTS.set(owner, new Map()); 27 | } 28 | 29 | CLIENTS.get(owner)?.set(clientId, client); 30 | 31 | registerDestructor(context, () => { 32 | clearClient(context, clientId); 33 | }); 34 | } 35 | 36 | export function getClient( 37 | context: object, 38 | clientId: string = DEFAULT_CLIENT_ID, 39 | ): ApolloClient { 40 | const owner = getOwner(context); 41 | 42 | if (!owner) { 43 | throw new Error( 44 | 'Unable to find owner from the given context in glimmer-apollo getClient', 45 | ); 46 | } 47 | 48 | const client = CLIENTS.get(owner)?.get(clientId); 49 | if (!client) { 50 | throw new Error( 51 | `Apollo client with id ${clientId} has not been set yet, use setClient(new ApolloClient({ ... }, '${clientId}')) to define it`, 52 | ); 53 | } 54 | 55 | return client as ApolloClient; 56 | } 57 | 58 | export function clearClients(context: object): void { 59 | const owner = getOwner(context) as Owner | null; 60 | if (!owner) { 61 | throw new Error( 62 | 'Unable to find owner from the given context in glimmer-apollo clearClients', 63 | ); 64 | } 65 | 66 | const bucket = CLIENTS.get(owner); 67 | bucket?.forEach((client) => { 68 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 69 | client.clearStore(); 70 | }); 71 | 72 | bucket?.clear(); 73 | } 74 | 75 | export function clearClient( 76 | context: object, 77 | clientId: string = DEFAULT_CLIENT_ID, 78 | ): void { 79 | const owner = getOwner(context) as Owner | null; 80 | if (!owner) { 81 | throw new Error( 82 | 'Unable to find owner from the given context in glimmer-apollo clearClient', 83 | ); 84 | } 85 | 86 | const bucket = CLIENTS.get(owner); 87 | 88 | const client = bucket?.get(clientId); 89 | if (client) { 90 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 91 | client.clearStore(); 92 | } 93 | bucket?.delete(clientId); 94 | } 95 | -------------------------------------------------------------------------------- /glimmer-apollo/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import copy from 'rollup-plugin-copy'; 3 | import { Addon } from '@embroider/addon-dev/rollup'; 4 | 5 | const addon = new Addon({ 6 | srcDir: 'src', 7 | destDir: 'dist', 8 | }); 9 | 10 | export default { 11 | // This provides defaults that work well alongside `publicEntrypoints` below. 12 | // You can augment this if you need to. 13 | output: addon.output(), 14 | 15 | plugins: [ 16 | // These are the modules that users should be able to import from your 17 | // addon. Anything not listed here may get optimized away. 18 | // By default all your JavaScript modules (**/*.js) will be importable. 19 | // But you are encouraged to tweak this to only cover the modules that make 20 | // up your addon's public API. Also make sure your package.json#exports 21 | // is aligned to the config here. 22 | // See https://github.com/embroider-build/embroider/blob/main/docs/v2-faq.md#how-can-i-define-the-public-exports-of-my-addon 23 | addon.publicEntrypoints(['**/*.js', 'index.js', 'template-registry.js']), 24 | 25 | // These are the modules that should get reexported into the traditional 26 | // "app" tree. Things in here should also be in publicEntrypoints above, but 27 | // not everything in publicEntrypoints necessarily needs to go here. 28 | addon.appReexports([ 29 | 'components/**/*.js', 30 | 'helpers/**/*.js', 31 | 'modifiers/**/*.js', 32 | 'services/**/*.js', 33 | ]), 34 | 35 | // Follow the V2 Addon rules about dependencies. Your code can import from 36 | // `dependencies` and `peerDependencies` as well as standard Ember-provided 37 | // package names. 38 | addon.dependencies(), 39 | 40 | // This babel config should *not* apply presets or compile away ES modules. 41 | // It exists only to provide development niceties for you, like automatic 42 | // template colocation. 43 | // 44 | // By default, this will load the actual babel config from the file 45 | // babel.config.json. 46 | babel({ 47 | extensions: ['.js', '.gjs', '.ts', '.gts'], 48 | babelHelpers: 'bundled', 49 | }), 50 | 51 | // Ensure that standalone .hbs files are properly integrated as Javascript. 52 | addon.hbs(), 53 | 54 | // Ensure that .gjs files are properly integrated as Javascript 55 | addon.gjs(), 56 | 57 | // Emit .d.ts declaration files using Glint v2's ember-tsc 58 | addon.declarations('declarations', 'ember-tsc --declaration'), 59 | 60 | // addons are allowed to contain imports of .css files, which we want rollup 61 | // to leave alone and keep in the published output. 62 | addon.keepAssets(['**/*.css']), 63 | 64 | // Remove leftover build artifacts when starting a new build. 65 | addon.clean(), 66 | 67 | // Copy Readme and License into published package 68 | copy({ 69 | targets: [ 70 | { src: '../README.md', dest: '.' }, 71 | { src: '../LICENSE.md', dest: '.' }, 72 | ], 73 | }), 74 | ], 75 | }; 76 | -------------------------------------------------------------------------------- /test-app/app/components/experiment.gts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import { useQuery, useMutation, gql } from 'glimmer-apollo'; 3 | import { on } from '@ember/modifier'; 4 | import type { 5 | UserInfoQuery, 6 | UserInfoQueryVariables, 7 | LoginMutation, 8 | LoginMutationVariables, 9 | } from 'test-app/mocks/handlers'; 10 | 11 | const USER_INFO = gql` 12 | query GetUserInfo { 13 | user { 14 | username 15 | firstName 16 | } 17 | } 18 | `; 19 | 20 | const LOGIN = gql` 21 | mutation Login($username: String!) { 22 | login(username: $username) { 23 | id 24 | firstName 25 | } 26 | } 27 | `; 28 | 29 | export default class PlaygroundExperiment extends Component { 30 | userInfo = useQuery(this, () => [ 31 | USER_INFO, 32 | { 33 | variables: { id: '1-with-delay' }, 34 | errorPolicy: 'all', 35 | notifyOnNetworkStatusChange: true, 36 | }, 37 | ]); 38 | 39 | userInfoWithSkip = useQuery( 40 | this, 41 | () => [ 42 | USER_INFO, 43 | { 44 | variables: { id: '1-with-delay' }, 45 | errorPolicy: 'all', 46 | notifyOnNetworkStatusChange: true, 47 | skip: true, 48 | }, 49 | ] 50 | ); 51 | 52 | login = useMutation(this, () => [ 53 | LOGIN, 54 | { 55 | variables: { 56 | username: undefined as never, 57 | }, 58 | errorPolicy: 'all', 59 | }, 60 | ]); 61 | 62 | bla = (): void => { 63 | this.login.mutate(); 64 | }; 65 | 66 | 125 | } 126 | -------------------------------------------------------------------------------- /glimmer-apollo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glimmer-apollo", 3 | "version": "0.6.6", 4 | "description": "Ember and Glimmer integration for Apollo Client", 5 | "keywords": [ 6 | "ember-addon" 7 | ], 8 | "repository": "http://github.com/josemarluedke/glimmer-apollo", 9 | "license": "MIT", 10 | "author": "Josemar Luedke ", 11 | "exports": { 12 | ".": { 13 | "types": "./declarations/index.d.ts", 14 | "default": "./dist/index.js" 15 | }, 16 | "./*": { 17 | "types": "./declarations/*.d.ts", 18 | "default": "./dist/*.js" 19 | }, 20 | "./addon-main.js": "./addon-main.cjs" 21 | }, 22 | "typesVersions": { 23 | "*": { 24 | "*": [ 25 | "declarations/*" 26 | ] 27 | } 28 | }, 29 | "files": [ 30 | "addon-main.cjs", 31 | "declarations", 32 | "dist" 33 | ], 34 | "scripts": { 35 | "build": "rollup --config", 36 | "format": "prettier . --cache --write", 37 | "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto", 38 | "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm run format", 39 | "lint:hbs": "ember-template-lint . --no-error-on-unmatched-pattern", 40 | "lint:hbs:fix": "ember-template-lint . --fix --no-error-on-unmatched-pattern", 41 | "lint:js": "eslint . --cache", 42 | "lint:js:fix": "eslint . --fix", 43 | "prepack": "rollup --config", 44 | "start": "rollup --config --watch", 45 | "test": "echo 'A v2 addon does not have tests, run tests in test-app'" 46 | }, 47 | "dependencies": { 48 | "@ember/test-waiters": "^4.1.1", 49 | "@embroider/addon-shim": "^1.10.2", 50 | "@wry/equality": "^0.5.2", 51 | "decorator-transforms": "^2.2.2" 52 | }, 53 | "devDependencies": { 54 | "@apollo/client": "^3.14.0", 55 | "@babel/core": "^7.28.5", 56 | "@babel/eslint-parser": "^7.28.5", 57 | "@babel/plugin-transform-typescript": "^7.28.5", 58 | "@babel/runtime": "^7.28.4", 59 | "@embroider/addon-dev": "^8.1.2", 60 | "@eslint/js": "^9.39.2", 61 | "@glint/ember-tsc": "^1.0.8", 62 | "@glint/template": "^1.7.3", 63 | "@rollup/plugin-babel": "^6.1.0", 64 | "@tsconfig/ember": "^3.0.12", 65 | "babel-plugin-ember-template-compilation": "^3.0.1", 66 | "concurrently": "^9.2.1", 67 | "ember-source": "^6.9.0", 68 | "ember-template-lint": "^7.9.3", 69 | "eslint": "^9.39.2", 70 | "eslint-config-prettier": "^10.1.8", 71 | "eslint-plugin-ember": "^12.7.5", 72 | "eslint-plugin-import": "^2.32.0", 73 | "eslint-plugin-n": "^17.23.1", 74 | "globals": "^16.5.0", 75 | "prettier": "^3.7.4", 76 | "prettier-plugin-ember-template-tag": "^2.1.2", 77 | "rollup": "^4.53.5", 78 | "rollup-plugin-copy": "^3.5.0", 79 | "typescript": "^5.9.3", 80 | "typescript-eslint": "^8.50.0" 81 | }, 82 | "peerDependencies": { 83 | "@apollo/client": "^3.0.0", 84 | "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" 85 | }, 86 | "ember": { 87 | "edition": "octane" 88 | }, 89 | "ember-addon": { 90 | "version": 2, 91 | "type": "addon", 92 | "main": "addon-main.cjs", 93 | "app-js": {} 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /test-app/tests/unit/client-test.ts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { 3 | setClient, 4 | getClient, 5 | clearClient, 6 | clearClients, 7 | } from 'glimmer-apollo'; 8 | import { ApolloClient, InMemoryCache } from '@apollo/client/core'; 9 | import { destroy } from '@ember/destroyable'; 10 | import { setOwner } from '@ember/owner'; 11 | import type Owner from '@ember/owner'; 12 | 13 | module('setClient & getClient', function (hooks) { 14 | let ctx = {}; 15 | const owner: Owner = {} as Owner; 16 | 17 | const cache = new InMemoryCache(); 18 | const client = new ApolloClient({ cache }); 19 | 20 | hooks.beforeEach(() => { 21 | ctx = {}; 22 | setOwner(ctx, owner); 23 | }); 24 | 25 | hooks.afterEach(() => { 26 | destroy(ctx); 27 | }); 28 | 29 | test('setting and getting a client without passing name', function (assert) { 30 | setClient(ctx, client); 31 | assert.equal(getClient(ctx), client); 32 | }); 33 | 34 | test('setting and getting client with custom name', function (assert) { 35 | setClient(ctx, client, 'custom'); 36 | assert.equal(getClient(ctx, 'custom'), client); 37 | }); 38 | 39 | test('getting a client without setting before throws error', function (assert) { 40 | assert.throws( 41 | () => getClient(ctx), 42 | /Apollo client with id default has not been set yet, use setClient/ 43 | ); 44 | 45 | assert.throws( 46 | () => getClient(ctx, 'customClient'), 47 | /Apollo client with id customClient has not been set yet, use setClient/ 48 | ); 49 | }); 50 | 51 | test('geClient/setClient with context withou owner', function (assert) { 52 | assert.throws(() => { 53 | setClient({}, client); 54 | }, / Unable to find owner from the given context in glimmer-apollo setClient/); 55 | assert.throws( 56 | () => getClient({}), 57 | / Unable to find owner from the given context in glimmer-apollo getClient/ 58 | ); 59 | }); 60 | 61 | test('clearClient removes the client from context', function (assert) { 62 | setClient(ctx, client); 63 | setClient(ctx, client, 'customClient'); 64 | assert.equal(getClient(ctx), client); 65 | assert.equal(getClient(ctx, 'customClient'), client); 66 | 67 | clearClient(ctx); 68 | 69 | assert.equal( 70 | getClient(ctx, 'customClient'), 71 | client, 72 | 'should not have removed customClient' 73 | ); 74 | 75 | assert.throws( 76 | () => getClient(ctx), 77 | /Apollo client with id default has not been set yet, use setClient/ 78 | ); 79 | }); 80 | 81 | test('clearClients removes all clients from context', function (assert) { 82 | setClient(ctx, client); 83 | setClient(ctx, client, 'customClient'); 84 | assert.equal(getClient(ctx), client); 85 | assert.equal(getClient(ctx, 'customClient'), client); 86 | clearClients(ctx); 87 | 88 | assert.throws( 89 | () => getClient(ctx), 90 | /Apollo client with id default has not been set yet, use setClient/ 91 | ); 92 | 93 | assert.throws( 94 | () => getClient(ctx, 'customClient'), 95 | /Apollo client with id customClient has not been set yet, use setClient/ 96 | ); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | Getting up and running with Glimmer Apollo. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | yarn add -D glimmer-apollo @apollo/client graphql 9 | ``` 10 | 11 | ```sh 12 | npm install --save-dev glimmer-apollo @apollo/client graphql 13 | ``` 14 | 15 | ## Compatibility 16 | 17 | Here is what Glimmer Apollo is compatible with: 18 | 19 | - Apollo Client v3.0 or above 20 | - Ember.js v3.27 or above 21 | - Ember CLI v2.13 or above 22 | - Embroider or ember-auto-import v2 23 | - Node.js v12 or above 24 | - FastBoot 1.0+ 25 | 26 | Glimmer Apollo uses [proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy), so it does NOT support IE 11 or older browsers. 27 | 28 | ## Setup the client 29 | 30 | The first step to get going is to create and set an `ApolloClient` instance. 31 | For this we will create a file and export a function to set it up. The location 32 | of this file can be something like `app/apollo.js`. It's totally up to you. 33 | 34 | ```ts:app/apollo.ts 35 | import { setClient } from 'glimmer-apollo'; 36 | import { 37 | ApolloClient, 38 | InMemoryCache, 39 | createHttpLink 40 | } from '@apollo/client/core'; 41 | 42 | export default function setupApolloClient(context: object): void { 43 | // HTTP connection to the API 44 | const httpLink = createHttpLink({ 45 | uri: 'http://localhost:3000/graphql' 46 | }); 47 | 48 | // Cache implementation 49 | const cache = new InMemoryCache(); 50 | 51 | // Create the apollo client 52 | const apolloClient = new ApolloClient({ 53 | link: httpLink, 54 | cache 55 | }); 56 | 57 | // Set default apollo client for Glimmer Apollo 58 | setClient(context, apolloClient); 59 | } 60 | ``` 61 | 62 | The `context` argument for `setClient` can be any object with an Owner. 63 | For Ember apps specifically, you can pass an `ApplicationInstance`, which itself 64 | is the Owner. 65 | 66 | 67 | > Important: When the context object is torn down, the `ApolloClient` instance 68 | > will be cleared out, removing all its cache, and it will unregister the client 69 | > from Glimmer Apollo. 70 | 71 | ## Ember Setup 72 | 73 | Now that we have defined the Apollo Client let's call the function we created 74 | above in an Instance Initializer. 75 | 76 | ```ts:app/instance-initializers/apollo.js 77 | import setupApolloClient from '../apollo'; 78 | 79 | export default { 80 | initialize: setupApolloClient 81 | }; 82 | ``` 83 | 84 | We recommend using an instance initializer as they get executed in component 85 | integration tests, allowing you to run GraphQL queries in your component tests. 86 | 87 | ### Setup the client for Ember Engines 88 | 89 | For Engine we should pass `context.ownerInjection()` instead of `context` 90 | because `EngineInstance` has no `owner` concept and if you try to use `context` it will crash you app. Using `context.ownerInjection()` it will register a new client per engine. 91 | 92 | ```ts:app/apollo.ts 93 | import { setClient } from 'glimmer-apollo'; 94 | 95 | export default function setupApolloClient(context: object): void { 96 | ... 97 | 98 | // Set default apollo client for Glimmer Apollo 99 | setClient(context.ownerInjection(), apolloClient); 100 | } 101 | ``` 102 | 103 | -------------------------------------------------------------------------------- /glimmer-apollo/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Debugging: 3 | * https://eslint.org/docs/latest/use/configure/debug 4 | * ---------------------------------------------------- 5 | * 6 | * Print a file's calculated configuration 7 | * 8 | * npx eslint --print-config path/to/file.js 9 | * 10 | * Inspecting the config 11 | * 12 | * npx eslint --inspect-config 13 | * 14 | */ 15 | import babelParser from '@babel/eslint-parser'; 16 | import js from '@eslint/js'; 17 | import prettier from 'eslint-config-prettier'; 18 | import ember from 'eslint-plugin-ember/recommended'; 19 | import importPlugin from 'eslint-plugin-import'; 20 | import n from 'eslint-plugin-n'; 21 | import globals from 'globals'; 22 | import ts from 'typescript-eslint'; 23 | 24 | const parserOptions = { 25 | esm: { 26 | js: { 27 | ecmaFeatures: { modules: true }, 28 | ecmaVersion: 'latest', 29 | }, 30 | ts: { 31 | projectService: true, 32 | tsconfigRootDir: import.meta.dirname, 33 | }, 34 | }, 35 | }; 36 | 37 | export default ts.config( 38 | js.configs.recommended, 39 | ember.configs.base, 40 | ember.configs.gjs, 41 | ember.configs.gts, 42 | prettier, 43 | /** 44 | * Ignores must be in their own object 45 | * https://eslint.org/docs/latest/use/configure/ignore 46 | */ 47 | { 48 | ignores: ['dist/', 'declarations/', 'node_modules/', 'coverage/', '!**/.*'], 49 | }, 50 | /** 51 | * https://eslint.org/docs/latest/use/configure/configuration-files#configuring-linter-options 52 | */ 53 | { 54 | linterOptions: { 55 | reportUnusedDisableDirectives: 'error', 56 | }, 57 | }, 58 | { 59 | files: ['**/*.js'], 60 | languageOptions: { 61 | parser: babelParser, 62 | }, 63 | }, 64 | { 65 | files: ['**/*.{js,gjs}'], 66 | languageOptions: { 67 | parserOptions: parserOptions.esm.js, 68 | globals: { 69 | ...globals.browser, 70 | }, 71 | }, 72 | }, 73 | { 74 | files: ['**/*.{ts,gts}'], 75 | languageOptions: { 76 | parser: ember.parser, 77 | parserOptions: parserOptions.esm.ts, 78 | }, 79 | extends: [...ts.configs.recommendedTypeChecked, ember.configs.gts], 80 | }, 81 | { 82 | files: ['src/**/*'], 83 | plugins: { 84 | import: importPlugin, 85 | }, 86 | rules: { 87 | // require relative imports use full extensions 88 | 'import/extensions': ['error', 'always', { ignorePackages: true }], 89 | }, 90 | }, 91 | /** 92 | * CJS node files 93 | */ 94 | { 95 | files: [ 96 | '**/*.cjs', 97 | '.prettierrc.js', 98 | '.stylelintrc.js', 99 | '.template-lintrc.js', 100 | 'addon-main.cjs', 101 | ], 102 | plugins: { 103 | n, 104 | }, 105 | 106 | languageOptions: { 107 | sourceType: 'script', 108 | ecmaVersion: 'latest', 109 | globals: { 110 | ...globals.node, 111 | }, 112 | }, 113 | }, 114 | /** 115 | * ESM node files 116 | */ 117 | { 118 | files: ['**/*.mjs'], 119 | plugins: { 120 | n, 121 | }, 122 | 123 | languageOptions: { 124 | sourceType: 'module', 125 | ecmaVersion: 'latest', 126 | parserOptions: parserOptions.esm.js, 127 | globals: { 128 | ...globals.node, 129 | }, 130 | }, 131 | }, 132 | ); 133 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/mutation.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from '@apollo/client/core'; 2 | 3 | import { 4 | isDestroyed, 5 | isDestroying, 6 | tracked, 7 | waitForPromise, 8 | } from '../environment.ts'; 9 | import { getClient } from './client.ts'; 10 | import { Resource } from './resource.ts'; 11 | import { settled } from './utils.ts'; 12 | 13 | import type { 14 | DocumentNode, 15 | FetchResult, 16 | MutationOptions as ApolloMutationOptions, 17 | OperationVariables, 18 | MaybeMasked, 19 | } from '@apollo/client/core'; 20 | import type { TemplateArgs } from './types'; 21 | 22 | type Maybe = T | undefined | null; 23 | 24 | export interface MutationOptions extends Omit< 25 | ApolloMutationOptions, 26 | 'mutation' 27 | > { 28 | clientId?: string; 29 | onComplete?: (data: Maybe>) => void; 30 | onError?: (error: ApolloError) => void; 31 | } 32 | 33 | export type MutationPositionalArgs< 34 | TData, 35 | TVariables extends OperationVariables = OperationVariables, 36 | > = [DocumentNode, MutationOptions?]; 37 | 38 | export class MutationResource< 39 | TData, 40 | TVariables extends OperationVariables = OperationVariables, 41 | > extends Resource>> { 42 | @tracked loading = false; 43 | @tracked called = false; 44 | @tracked error?: ApolloError; 45 | @tracked data: Maybe>; 46 | @tracked promise!: Promise>>; 47 | 48 | async mutate( 49 | variables?: TVariables, 50 | overrideOptions: Omit< 51 | MutationOptions, 52 | 'variables' | 'mutation' 53 | > = {}, 54 | ): Promise>> { 55 | this.loading = true; 56 | const [mutation, originalOptions] = this.args.positional; 57 | const options = { ...originalOptions, ...overrideOptions }; 58 | const client = getClient(this, options.clientId); 59 | 60 | if (!variables) { 61 | variables = originalOptions?.variables; 62 | } else if (variables && originalOptions?.variables) { 63 | variables = { 64 | ...originalOptions.variables, 65 | ...variables, 66 | }; 67 | } 68 | 69 | this.promise = waitForPromise( 70 | client.mutate({ 71 | mutation, 72 | ...options, 73 | variables, 74 | }), 75 | ) 76 | .then((result) => { 77 | this.#onComplete(result); 78 | return this.data; 79 | }) 80 | .catch((error: ApolloError) => { 81 | this.#onError(error); 82 | return this.data; 83 | }); 84 | 85 | return this.promise; 86 | } 87 | 88 | settled(): Promise { 89 | return settled(this.promise); 90 | } 91 | 92 | #onComplete(result: FetchResult>): void { 93 | const { data, errors } = result; 94 | const error = 95 | errors && errors.length > 0 96 | ? new ApolloError({ graphQLErrors: errors }) 97 | : undefined; 98 | 99 | this.data = data; 100 | this.error = error; 101 | 102 | this.#handleOnCompleteOrOnError(); 103 | } 104 | 105 | #onError(error: ApolloError): void { 106 | this.error = error; 107 | this.data = undefined; 108 | 109 | this.#handleOnCompleteOrOnError(); 110 | } 111 | 112 | #handleOnCompleteOrOnError(): void { 113 | this.loading = false; 114 | this.called = true; 115 | 116 | // We want to avoid calling the callbacks when this is destroyed. 117 | // If the resource is destroyed, the callback context might not be defined anymore. 118 | if (isDestroyed(this) || isDestroying(this)) { 119 | return; 120 | } 121 | 122 | const [, options] = this.args.positional; 123 | const { onComplete, onError } = options || {}; 124 | const { data, error } = this; 125 | 126 | if (onComplete && !error) { 127 | onComplete(data); 128 | } else if (onError && error) { 129 | onError(error); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /test-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-app", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Small description for test-app goes here", 6 | "repository": "", 7 | "license": "MIT", 8 | "author": "", 9 | "directories": { 10 | "doc": "doc", 11 | "test": "tests" 12 | }, 13 | "scripts": { 14 | "build": "vite build", 15 | "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto", 16 | "lint:css": "stylelint \"**/*.css\"", 17 | "lint:css:fix": "concurrently \"pnpm:lint:css -- --fix\"", 18 | "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto", 19 | "lint:hbs": "ember-template-lint .", 20 | "lint:hbs:fix": "ember-template-lint . --fix", 21 | "lint:js": "eslint .", 22 | "lint:js:fix": "eslint . --fix", 23 | "lint:types": "ember-tsc --noEmit", 24 | "start": "vite", 25 | "test": "concurrently \"pnpm:test:*\" --names \"test:\" --prefixColors auto", 26 | "test:ember": "vite build --mode test && ember test --path dist" 27 | }, 28 | "devDependencies": { 29 | "@apollo/client": "^3.14.0", 30 | "@babel/core": "^7.28.5", 31 | "@babel/eslint-parser": "^7.28.5", 32 | "@babel/plugin-transform-runtime": "^7.28.5", 33 | "@babel/plugin-transform-typescript": "^7.28.5", 34 | "@babel/runtime": "^7.28.4", 35 | "@ember/optional-features": "^2.3.0", 36 | "@ember/test-helpers": "^5.4.1", 37 | "@embroider/compat": "4.1.11", 38 | "@embroider/config-meta-loader": "1.0.0", 39 | "@embroider/core": "4.4.1", 40 | "@embroider/macros": "^1.19.5", 41 | "@embroider/vite": "1.4.4", 42 | "@eslint/js": "^9.39.2", 43 | "@glimmer/component": "^2.0.0", 44 | "@glint/ember-tsc": "^1.0.8", 45 | "@glint/template": "1.7.3", 46 | "@rollup/plugin-babel": "^6.1.0", 47 | "@tsconfig/ember": "^3.0.12", 48 | "@types/eslint__js": "^9.14.0", 49 | "@types/qunit": "^2.19.13", 50 | "@types/rsvp": "^4.0.9", 51 | "@types/sinon": "^21.0.0", 52 | "@typescript-eslint/eslint-plugin": "^8.50.0", 53 | "@typescript-eslint/parser": "^8.50.0", 54 | "@warp-drive/core-types": "~5.8.0", 55 | "babel-plugin-ember-template-compilation": "^3.0.1", 56 | "concurrently": "^9.2.1", 57 | "decorator-transforms": "^2.2.2", 58 | "ember-auto-import": "^2.12.0", 59 | "ember-cli": "~6.9.1", 60 | "ember-cli-babel": "^8.2.0", 61 | "ember-cli-htmlbars": "^7.0.0", 62 | "ember-load-initializers": "^3.0.1", 63 | "ember-modifier": "^4.2.2", 64 | "ember-page-title": "^9.0.3", 65 | "ember-qunit": "^9.0.4", 66 | "ember-resolver": "^13.1.1", 67 | "ember-route-template": "^1.0.3", 68 | "ember-source": "^6.9.0", 69 | "ember-template-imports": "^4.3.0", 70 | "ember-template-lint": "^7.9.3", 71 | "ember-try": "^4.0.0", 72 | "eslint": "^9.39.2", 73 | "eslint-config-prettier": "^10.1.8", 74 | "eslint-plugin-ember": "^12.7.5", 75 | "eslint-plugin-n": "^17.23.1", 76 | "eslint-plugin-prettier": "^5.5.4", 77 | "eslint-plugin-qunit": "^8.2.5", 78 | "glimmer-apollo": "workspace:*", 79 | "globals": "^16.5.0", 80 | "loader.js": "^4.7.0", 81 | "msw": "^2.12.4", 82 | "prettier": "^3.7.4", 83 | "prettier-plugin-ember-template-tag": "^2.1.2", 84 | "qunit": "^2.24.3", 85 | "qunit-dom": "^3.5.0", 86 | "sinon": "^21.0.0", 87 | "stylelint": "^16.26.1", 88 | "stylelint-config-standard": "^39.0.1", 89 | "stylelint-prettier": "^5.0.3", 90 | "tracked-built-ins": "^4.0.0", 91 | "typescript": "^5.9.3", 92 | "typescript-eslint": "^8.50.0", 93 | "vite": "^7.3.0" 94 | }, 95 | "engines": { 96 | "node": ">= 18" 97 | }, 98 | "ember": { 99 | "edition": "octane" 100 | }, 101 | "ember-addon": { 102 | "type": "app", 103 | "version": 2 104 | }, 105 | "exports": { 106 | "./tests/*": "./tests/*", 107 | "./*": "./app/*" 108 | }, 109 | "msw": { 110 | "workerDirectory": "public" 111 | }, 112 | "pnpm": { 113 | "overrides": { 114 | "ember-source": "$ember-source" 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /test-app/app/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { graphql, delay, HttpResponse } from 'msw'; 2 | 3 | type Maybe = T | null; 4 | type Exact = { 5 | [K in keyof T]: T[K]; 6 | }; 7 | 8 | type User = { 9 | id: string; 10 | firstName: string; 11 | lastName: string; 12 | }; 13 | 14 | type Message = { 15 | id: string; 16 | message: string; 17 | }; 18 | 19 | export type UserInfoQueryVariables = Exact<{ 20 | id: string; 21 | }>; 22 | 23 | export type UserInfoQuery = { 24 | __typename?: 'Query'; 25 | user: Maybe< 26 | { __typename?: 'User' } & Pick 27 | >; 28 | }; 29 | 30 | export type LoginMutationVariables = Exact<{ 31 | username: string; 32 | }>; 33 | 34 | export type LoginMutation = { 35 | __typename?: 'Mutation'; 36 | login: Maybe< 37 | { __typename?: 'User' } & Pick 38 | >; 39 | }; 40 | 41 | export type OnMessageAddedSubscriptionVariables = Exact<{ 42 | channel: string; 43 | }>; 44 | 45 | export type OnMessageAddedSubscription = { 46 | __typename?: 'Subscription'; 47 | messageAdded: Maybe< 48 | { __typename?: 'Message' } & Pick 49 | >; 50 | }; 51 | 52 | const USERS: User[] = [ 53 | { 54 | id: '1', 55 | firstName: 'Cathaline', 56 | lastName: 'McCoy', 57 | }, 58 | { 59 | id: '2', 60 | firstName: 'Joth', 61 | lastName: 'Maverick', 62 | }, 63 | ]; 64 | 65 | export const handlers = [ 66 | graphql.query( 67 | 'UserInfo', 68 | async (req) => { 69 | const user = USERS[Number(req.variables.id.charAt(0)) - 1]; 70 | 71 | if (user && req.variables.id.includes('with-error')) { 72 | return HttpResponse.json({ 73 | errors: [ 74 | { 75 | message: 'Data With Error', 76 | }, 77 | ], 78 | data: { 79 | user: { 80 | __typename: 'User', 81 | ...user, 82 | }, 83 | }, 84 | }); 85 | } else if (user && req.variables.id.includes('with-delay')) { 86 | await delay(300); 87 | return HttpResponse.json({ 88 | data: { 89 | user: { 90 | __typename: 'User', 91 | ...user, 92 | }, 93 | }, 94 | }); 95 | } else if (user) { 96 | return HttpResponse.json({ 97 | data: { 98 | user: { 99 | __typename: 'User', 100 | ...user, 101 | }, 102 | }, 103 | }); 104 | } else { 105 | return HttpResponse.json({ 106 | errors: [ 107 | { 108 | message: 'User not found', 109 | }, 110 | ], 111 | }); 112 | } 113 | } 114 | ), 115 | 116 | // Capture a "Login" mutation 117 | graphql.mutation('Login', (req) => { 118 | const { username } = req.variables; 119 | 120 | if (username === 'non-existing') { 121 | return HttpResponse.json({ 122 | errors: [ 123 | { 124 | message: 'User not found with given username', 125 | }, 126 | ], 127 | }); 128 | } 129 | 130 | if (username === 'with-error') { 131 | return HttpResponse.json({ 132 | errors: [ 133 | { 134 | message: 'Error with Data', 135 | }, 136 | ], 137 | data: { 138 | login: { 139 | __typename: 'User', 140 | ...USERS[1], 141 | }, 142 | }, 143 | }); 144 | } 145 | 146 | return HttpResponse.json({ 147 | data: { 148 | login: { 149 | __typename: 'User', 150 | ...USERS[1], 151 | }, 152 | }, 153 | }); 154 | }), 155 | 156 | // Handles a "GetUserInfo" query 157 | graphql.query('GetUserInfo', async () => { 158 | await delay(300); 159 | return HttpResponse.json({ 160 | data: { 161 | user: { 162 | __typename: 'User', 163 | 164 | id: '1', 165 | firstName: 'John', 166 | lastName: 'Maverick', 167 | username: 'joth', 168 | }, 169 | }, 170 | }); 171 | }), 172 | ]; 173 | -------------------------------------------------------------------------------- /test-app/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import js from '@eslint/js'; 3 | 4 | import ts from 'typescript-eslint'; 5 | 6 | import ember from 'eslint-plugin-ember'; 7 | import emberRecommended from 'eslint-plugin-ember/configs/recommended'; 8 | import gjsRecommended from 'eslint-plugin-ember/configs/recommended-gjs'; 9 | import gtsRecommended from 'eslint-plugin-ember/configs/recommended-gts'; 10 | 11 | import prettier from 'eslint-plugin-prettier/recommended'; 12 | import qunit from 'eslint-plugin-qunit'; 13 | import n from 'eslint-plugin-n'; 14 | 15 | import emberParser from 'ember-eslint-parser'; 16 | import babelParser from '@babel/eslint-parser'; 17 | 18 | const parserOptions = { 19 | esm: { 20 | js: { 21 | ecmaFeatures: { modules: true }, 22 | ecmaVersion: 'latest', 23 | }, 24 | ts: { 25 | projectService: true, 26 | tsconfigRootDir: import.meta.dirname, 27 | }, 28 | }, 29 | }; 30 | 31 | export default [ 32 | ...ts.config( 33 | js.configs.recommended, 34 | prettier, 35 | { 36 | files: ['**/*.js'], 37 | ignores: [ 38 | 'config/**/*.js', 39 | 'app/config/**/*.js', 40 | 'testem.js', 41 | 'testem*.js', 42 | 'ember-cli-build.js', 43 | '.prettierrc.js', 44 | '.stylelintrc.js', 45 | '.template-lintrc.js', 46 | ], 47 | languageOptions: { 48 | parser: babelParser, 49 | parserOptions: parserOptions.esm.js, 50 | globals: { 51 | ...globals.browser, 52 | }, 53 | }, 54 | plugins: { 55 | ember, 56 | }, 57 | rules: { 58 | ...emberRecommended.rules, 59 | }, 60 | }, 61 | { 62 | files: ['**/*.ts'], 63 | plugins: { ember }, 64 | languageOptions: { 65 | parserOptions: parserOptions.esm.ts, 66 | }, 67 | extends: [...ts.configs.strictTypeChecked, ...emberRecommended], 68 | rules: { 69 | '@typescript-eslint/no-floating-promises': 'off', 70 | }, 71 | }, 72 | { 73 | files: ['**/*.gjs'], 74 | languageOptions: { 75 | parser: emberParser, 76 | parserOptions: parserOptions.esm.js, 77 | globals: { 78 | ...globals.browser, 79 | }, 80 | }, 81 | plugins: { 82 | ember, 83 | }, 84 | rules: { 85 | ...emberRecommended.rules, 86 | ...gjsRecommended.rules, 87 | }, 88 | }, 89 | { 90 | files: ['**/*.gts'], 91 | plugins: { ember }, 92 | languageOptions: { 93 | parserOptions: parserOptions.esm.ts, 94 | }, 95 | extends: [ 96 | ...ts.configs.strictTypeChecked, 97 | ...emberRecommended, 98 | ...gtsRecommended, 99 | ], 100 | rules: { 101 | '@typescript-eslint/no-floating-promises': 'off', 102 | }, 103 | }, 104 | { 105 | files: ['tests/**/*-test.{js,gjs}'], 106 | plugins: { 107 | qunit, 108 | }, 109 | }, 110 | /** 111 | * CJS node files 112 | */ 113 | { 114 | files: [ 115 | '**/*.cjs', 116 | 'config/**/*.js', 117 | 'testem.js', 118 | 'testem*.js', 119 | '.prettierrc.js', 120 | '.stylelintrc.js', 121 | '.template-lintrc.js', 122 | 'ember-cli-build.js', 123 | ], 124 | plugins: { 125 | n, 126 | }, 127 | 128 | languageOptions: { 129 | sourceType: 'script', 130 | ecmaVersion: 'latest', 131 | globals: { 132 | ...globals.node, 133 | }, 134 | }, 135 | }, 136 | /** 137 | * ESM node files 138 | */ 139 | { 140 | files: ['*.mjs'], 141 | plugins: { 142 | n, 143 | }, 144 | 145 | languageOptions: { 146 | sourceType: 'module', 147 | ecmaVersion: 'latest', 148 | parserOptions: parserOptions.esm.js, 149 | globals: { 150 | ...globals.node, 151 | }, 152 | }, 153 | }, 154 | ), 155 | /** 156 | * Global ignores - must be in their own object 157 | */ 158 | { 159 | ignores: [ 160 | 'dist/', 161 | 'node_modules/', 162 | 'coverage/', 163 | 'public/', 164 | '!**/.*', 165 | 'vite.config.mjs.*', 166 | ], 167 | }, 168 | /** 169 | * Linter options 170 | */ 171 | { 172 | linterOptions: { 173 | reportUnusedDisableDirectives: 'error', 174 | }, 175 | }, 176 | ]; 177 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/resource.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | import { 3 | setHelperManager, 4 | helperCapabilities, 5 | createCache, 6 | getValue, 7 | setOwner, 8 | destroy, 9 | registerDestructor, 10 | associateDestroyableChild, 11 | } from '../environment.ts'; 12 | import type { TemplateArgs } from './types'; 13 | type Cache = ReturnType>; 14 | import type Owner from '@ember/owner'; 15 | 16 | type HelperDefinition< 17 | Args extends TemplateArgs = TemplateArgs< 18 | readonly unknown[] 19 | >, 20 | > = new (owner: Owner, args: Args) => Resource & {}; 21 | 22 | type Thunk = (...args: any[]) => void; // eslint-disable-line 23 | 24 | export abstract class Resource< 25 | Args extends TemplateArgs = TemplateArgs< 26 | readonly unknown[] 27 | >, 28 | > { 29 | protected readonly args!: Args; 30 | 31 | constructor(ownerOrThunk: Owner | Thunk, args: Args) { 32 | if (typeof ownerOrThunk === 'function') { 33 | // @ts-expect-error This is naughty. 34 | return { definition: this.constructor, args: ownerOrThunk }; 35 | } 36 | 37 | setOwner(this, ownerOrThunk); 38 | this.args = args; 39 | } 40 | 41 | setup(): void {} 42 | 43 | update?(): void; 44 | teardown?(): void; 45 | } 46 | 47 | class ResourceManager { 48 | readonly capabilities = helperCapabilities('3.23', { 49 | hasValue: true, 50 | hasDestroyable: true, 51 | }) as never; 52 | 53 | private readonly owner?: Owner; 54 | 55 | constructor(owner?: Owner) { 56 | this.owner = owner; 57 | } 58 | 59 | createHelper< 60 | Args extends TemplateArgs = TemplateArgs< 61 | readonly unknown[] 62 | >, 63 | >( 64 | definition: 65 | | { 66 | Class: HelperDefinition; 67 | owner: Owner; 68 | } 69 | | HelperDefinition, 70 | args: Args, 71 | ): Cache { 72 | let owner = this.owner; 73 | let Class: HelperDefinition; 74 | 75 | if ('Class' in definition) { 76 | Class = definition.Class; 77 | if (definition.owner) { 78 | owner = definition.owner; 79 | } 80 | } else { 81 | Class = definition; 82 | } 83 | 84 | assert('Cannot create resource without an owner', owner); 85 | 86 | // eslint-disable-next-line @typescript-eslint/unbound-method 87 | const { update, teardown } = Class.prototype as Resource; 88 | 89 | const hasUpdate = typeof update === 'function'; 90 | const hasTeardown = typeof teardown === 'function'; 91 | 92 | let instance: Resource | undefined; 93 | let cache: Cache; 94 | 95 | if (hasUpdate) { 96 | cache = createCache(() => { 97 | if (instance === undefined) { 98 | instance = setupInstance(cache, Class, owner, args, hasTeardown); 99 | } else { 100 | instance.update!(); 101 | } 102 | 103 | return instance; 104 | }); 105 | } else { 106 | cache = createCache(() => { 107 | if (instance !== undefined) { 108 | destroy(instance); 109 | } 110 | 111 | instance = setupInstance(cache, Class, owner, args, hasTeardown); 112 | 113 | return instance; 114 | }); 115 | } 116 | 117 | return cache; 118 | } 119 | 120 | getValue(cache: Cache): Resource | undefined { 121 | const instance = getValue(cache); 122 | 123 | return instance; 124 | } 125 | 126 | getDestroyable(cache: Cache): Cache { 127 | return cache; 128 | } 129 | 130 | //eslint-disable-next-line 131 | getDebugName(fn: (...args: any[]) => void): string { 132 | return fn.name || '(anonymous function)'; 133 | } 134 | } 135 | 136 | function setupInstance>>( 137 | cache: Cache, 138 | Class: HelperDefinition, 139 | owner: Owner, 140 | args: TemplateArgs, 141 | hasTeardown: boolean, 142 | ): T { 143 | const instance = new Class(owner, args); 144 | associateDestroyableChild(cache, instance); 145 | instance.setup(); 146 | 147 | if (hasTeardown) { 148 | registerDestructor(instance, () => instance.teardown!()); 149 | } 150 | 151 | return instance as T; 152 | } 153 | 154 | setHelperManager((owner: Owner | undefined) => { 155 | return new ResourceManager(owner); 156 | }, Resource); 157 | 158 | export const ResourceManagerFactory = (owner: Owner | undefined) => { 159 | return new ResourceManager(owner); 160 | }; 161 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/subscription.ts: -------------------------------------------------------------------------------- 1 | import { getClient } from './client.ts'; 2 | import { 3 | isDestroyed, 4 | isDestroying, 5 | tracked, 6 | waitForPromise, 7 | } from '../environment.ts'; 8 | import { Resource } from './resource.ts'; 9 | import { ApolloError } from '@apollo/client/core'; 10 | import type { 11 | DocumentNode, 12 | OperationVariables, 13 | FetchResult, 14 | SubscriptionOptions as ApolloSubscriptionOptions, 15 | ObservableSubscription, 16 | } from '@apollo/client/core'; 17 | import { equal } from '@wry/equality'; 18 | import { getFastboot, createPromise, settled } from './utils.ts'; 19 | import type { TemplateArgs } from './types'; 20 | 21 | export interface SubscriptionOptions extends Omit< 22 | ApolloSubscriptionOptions, 23 | 'query' 24 | > { 25 | ssr?: boolean; 26 | clientId?: string; 27 | onData?: (data: TData | undefined) => void; 28 | onError?: (error: ApolloError) => void; 29 | onComplete?: () => void; 30 | } 31 | 32 | export type SubscriptionPositionalArgs< 33 | TData, 34 | TVariables = OperationVariables, 35 | > = [DocumentNode, SubscriptionOptions?]; 36 | 37 | export class SubscriptionResource< 38 | TData, 39 | TVariables extends OperationVariables = OperationVariables, 40 | > extends Resource< 41 | TemplateArgs> 42 | > { 43 | @tracked loading = true; 44 | @tracked error?: ApolloError; 45 | @tracked data: TData | undefined; 46 | @tracked promise!: Promise; 47 | 48 | #subscription?: ObservableSubscription; 49 | #previousPositionalArgs: typeof this.args.positional | undefined; 50 | 51 | /** @internal */ 52 | setup(): void { 53 | this.#previousPositionalArgs = this.args.positional; 54 | const [query, options] = this.args.positional; 55 | const client = getClient(this, options?.clientId); 56 | 57 | this.loading = true; 58 | const fastboot = getFastboot(this); 59 | 60 | if (fastboot && fastboot.isFastBoot && options && options.ssr === false) { 61 | return; 62 | } 63 | 64 | let [promise, firstResolve, firstReject] = createPromise(); // eslint-disable-line prefer-const 65 | this.promise = promise; 66 | const observable = client.subscribe({ 67 | query, 68 | ...(options || {}), 69 | }); 70 | 71 | this.#subscription = observable.subscribe({ 72 | next: (result) => { 73 | if (isDestroyed(this) || isDestroying(this)) { 74 | return; 75 | } 76 | this.#onNextResult(result); 77 | if (firstResolve) { 78 | firstResolve(); 79 | firstResolve = undefined; 80 | } 81 | }, 82 | error: (error: ApolloError) => { 83 | if (isDestroyed(this) || isDestroying(this)) { 84 | return; 85 | } 86 | this.#onError(error); 87 | if (firstReject) { 88 | firstReject(); 89 | firstReject = undefined; 90 | } 91 | }, 92 | complete: () => { 93 | if (isDestroyed(this) || isDestroying(this)) { 94 | return; 95 | } 96 | this.#onComplete(); 97 | }, 98 | }); 99 | 100 | waitForPromise(promise).catch(() => { 101 | // We catch by default as the promise is only meant to be used 102 | // as an indicator if the query is being initially fetched. 103 | }); 104 | 105 | if (fastboot && fastboot.isFastBoot && options && options.ssr !== false) { 106 | fastboot.deferRendering(promise); 107 | } 108 | } 109 | 110 | /** @internal */ 111 | update(): void { 112 | if ( 113 | !equal(this.#previousPositionalArgs, this.args.positional) || 114 | !this.#subscription 115 | ) { 116 | this.teardown(); 117 | this.setup(); 118 | } 119 | } 120 | 121 | /** @internal */ 122 | teardown(): void { 123 | if (this.#subscription) { 124 | this.#subscription.unsubscribe(); 125 | } 126 | } 127 | 128 | settled(): Promise { 129 | return settled(this.promise); 130 | } 131 | 132 | #onNextResult(result: FetchResult): void { 133 | this.loading = false; 134 | this.error = undefined; 135 | 136 | const { data } = result; 137 | if (data == null) { 138 | this.data = undefined; 139 | } else { 140 | this.data = data; 141 | } 142 | 143 | const [, options] = this.args.positional; 144 | const { onData } = options || {}; 145 | if (onData) { 146 | onData(this.data); 147 | } 148 | } 149 | 150 | #onError(error: ApolloError): void { 151 | if (!Object.prototype.hasOwnProperty.call(error, 'graphQLErrors')) { 152 | error = new ApolloError({ networkError: error }); 153 | } 154 | 155 | this.loading = false; 156 | this.data = undefined; 157 | this.error = error; 158 | 159 | const [, options] = this.args.positional; 160 | const { onError } = options || {}; 161 | if (onError) { 162 | onError(this.error); 163 | } 164 | } 165 | 166 | #onComplete(): void { 167 | this.loading = false; 168 | 169 | const [, options] = this.args.positional; 170 | const { onComplete } = options || {}; 171 | if (onComplete) { 172 | onComplete(); 173 | } 174 | 175 | if (this.#subscription) { 176 | this.#subscription.unsubscribe(); 177 | this.#subscription = undefined; 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | github-readme 2 | 3 |

4 | Build Status 5 | GitHub license 6 |

7 | 8 | Glimmer Apollo: Ember integration for Apollo Client. 9 | 10 | ## Documentation 11 | 12 | Visit [glimmer-apollo.com](https://glimmer-apollo.com/) to read the docs. 13 | 14 | ## Compatibility 15 | 16 | - Apollo Client v3.0 or above 17 | - Ember.js v3.27 or above 18 | - Ember CLI v2.13 or above 19 | - Embroider or ember-auto-import v2 20 | - Node.js v12 or above 21 | - FastBoot 1.0+ 22 | 23 | ## API 24 | 25 | ### useQuery(ctx, args) 26 | 27 | ```gts 28 | import Component from '@glimmer/component'; 29 | import { tracked } from '@glimmer/tracking'; 30 | import { on } from '@ember/modifier'; 31 | import { action } from '@ember/object'; 32 | import { useQuery, gql } from 'glimmer-apollo'; 33 | import Todo from './todo'; 34 | 35 | export default class Todos extends Component { 36 | @tracked isDone = false; 37 | 38 | todos = useQuery(this, () => [ 39 | gql` 40 | query($isDone: Boolean) { 41 | todos(isDone: $isDone) { 42 | id 43 | description 44 | } 45 | } 46 | `, 47 | { 48 | variables: { isDone: this.isDone } 49 | } 50 | ]); 51 | 52 | @action toggleDone() { 53 | this.isDone = !this.isDone; 54 | } 55 | 56 | 67 | } 68 | ``` 69 | 70 | ### useMutation(ctx, args) 71 | 72 | ```gts 73 | import Component from '@glimmer/component'; 74 | import { on } from '@ember/modifier'; 75 | import { useMutation, gql } from 'glimmer-apollo'; 76 | 77 | interface Signature { 78 | Args: { 79 | todo: { 80 | id: string; 81 | description: string; 82 | }; 83 | }; 84 | } 85 | 86 | export default class Todo extends Component { 87 | deleteTodo = useMutation(this, () => [ 88 | gql` 89 | mutation($id: ID!) { 90 | deleteTodo(id: $id) { 91 | id 92 | } 93 | } 94 | `, 95 | { variables: { id: this.args.todo.id } } 96 | ]); 97 | 98 | 113 | } 114 | ``` 115 | 116 | ### useSubscription(ctx, args) 117 | 118 | ```gts 119 | import Component from '@glimmer/component'; 120 | import { tracked } from '@glimmer/tracking'; 121 | import { useSubscription, gql } from 'glimmer-apollo'; 122 | 123 | interface Signature { 124 | Args: { 125 | channel: string; 126 | }; 127 | } 128 | 129 | interface Message { 130 | id: string; 131 | message: string; 132 | } 133 | 134 | export default class Messages extends Component { 135 | @tracked receivedMessages: Message[] = []; 136 | 137 | messageAdded = useSubscription(this, () => [ 138 | gql` 139 | subscription ($channel: String!) { 140 | messageAdded(channel: $channel) { 141 | id 142 | message 143 | } 144 | } 145 | `, 146 | { 147 | variables: { channel: this.args.channel }, 148 | onData: (data) => { 149 | this.receivedMessages = [ 150 | ...this.receivedMessages, 151 | data.messageAdded 152 | ] 153 | } 154 | } 155 | ]); 156 | 157 | 168 | } 169 | ``` 170 | 171 | ### setClient(ctx, client[, clientId]) 172 | 173 | Where `ctx` is an object with owner. 174 | 175 | ```gts 176 | import Component from '@glimmer/component'; 177 | import { setClient } from 'glimmer-apollo'; 178 | import { ApolloClient, InMemoryCache } from '@apollo/client/core'; 179 | 180 | export default class App extends Component { 181 | constructor(owner: unknown, args: object) { 182 | super(owner, args); 183 | 184 | setClient( 185 | this, 186 | new ApolloClient({ 187 | uri: 'https://api.example.com/graphql', 188 | cache: new InMemoryCache() 189 | }) 190 | ); 191 | } 192 | 193 | 196 | } 197 | ``` 198 | 199 | ### getClient(ctx[,clientId]) 200 | 201 | Where `ctx` is an object with owner. 202 | 203 | ```gts 204 | import Component from '@glimmer/component'; 205 | import { getClient } from 'glimmer-apollo'; 206 | import type { ApolloClient } from '@apollo/client/core'; 207 | 208 | export default class MyComponent extends Component { 209 | client: ApolloClient; 210 | 211 | constructor(owner: unknown, args: object) { 212 | super(owner, args); 213 | 214 | this.client = getClient(this); 215 | } 216 | 217 | 220 | } 221 | ``` 222 | 223 | ## License 224 | 225 | This project is licensed under the MIT License. 226 | -------------------------------------------------------------------------------- /glimmer-apollo/src/-private/query.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError, NetworkStatus } from '@apollo/client/core'; 2 | import { equal } from '@wry/equality'; 3 | 4 | import { 5 | isDestroyed, 6 | isDestroying, 7 | tracked, 8 | waitForPromise, 9 | } from '../environment.ts'; 10 | import { getClient } from './client.ts'; 11 | import ObservableResource from './observable.ts'; 12 | import { createPromise, getFastboot, settled } from './utils.ts'; 13 | 14 | import type { 15 | ApolloQueryResult, 16 | DocumentNode, 17 | OperationVariables, 18 | WatchQueryOptions, 19 | ObservableSubscription, 20 | ObservableQuery, 21 | } from '@apollo/client/core'; 22 | import type { TemplateArgs } from './types'; 23 | 24 | export interface QueryOptions< 25 | TData, 26 | TVariables extends OperationVariables, 27 | > extends Omit, 'query'> { 28 | skip?: boolean; 29 | ssr?: boolean; 30 | clientId?: string; 31 | onComplete?: (data: TData | undefined) => void; 32 | onError?: (error: ApolloError) => void; 33 | } 34 | 35 | export type QueryPositionalArgs< 36 | TData, 37 | TVariables extends OperationVariables = OperationVariables, 38 | > = [DocumentNode, QueryOptions?]; 39 | 40 | export class QueryResource< 41 | TData, 42 | TVariables extends OperationVariables = OperationVariables, 43 | > extends ObservableResource< 44 | TData, 45 | TVariables, 46 | TemplateArgs> 47 | > { 48 | @tracked loading = false; 49 | @tracked error?: ApolloError; 50 | @tracked data: TData | undefined; 51 | @tracked networkStatus: NetworkStatus = NetworkStatus.loading; 52 | @tracked promise!: Promise; 53 | 54 | #subscription?: ObservableSubscription; 55 | #previousPositionalArgs: typeof this.args.positional | undefined; 56 | 57 | #firstPromiseReject: (() => unknown) | undefined; 58 | 59 | /** @internal */ 60 | setup(): void { 61 | this.#previousPositionalArgs = this.args.positional; 62 | const [query, options = {}] = this.args.positional; 63 | const client = getClient(this, options.clientId); 64 | 65 | const fastboot = getFastboot(this); 66 | 67 | if ( 68 | fastboot && 69 | fastboot.isFastBoot && 70 | (options.ssr === false || options.skip === true) 71 | ) { 72 | return; 73 | } 74 | 75 | let [promise, firstResolve, firstReject] = createPromise(); // eslint-disable-line prefer-const 76 | this.#firstPromiseReject = firstReject; 77 | this.promise = promise; 78 | 79 | if (options.skip) { 80 | options.fetchPolicy = 'standby'; 81 | } 82 | 83 | if (options.fetchPolicy === 'standby') { 84 | if (firstResolve) { 85 | firstResolve(); 86 | firstResolve = undefined; 87 | } 88 | } else { 89 | this.loading = true; 90 | } 91 | 92 | const observable = client.watchQuery({ 93 | query, 94 | ...options, 95 | }); 96 | 97 | this._setObservable( 98 | observable as ObservableQuery, 99 | ); 100 | 101 | this.#subscription = observable.subscribe( 102 | (result: ApolloQueryResult) => { 103 | this.#onComplete(result); 104 | if (firstResolve) { 105 | firstResolve(); 106 | firstResolve = undefined; 107 | } 108 | }, 109 | (error: ApolloError) => { 110 | this.#onError(error); 111 | if (typeof this.#firstPromiseReject === 'function') { 112 | this.#firstPromiseReject(); 113 | this.#firstPromiseReject = undefined; 114 | } 115 | }, 116 | ); 117 | 118 | waitForPromise(promise).catch(() => { 119 | // We catch by default as the promise is only meant to be used 120 | // as an indicator if the query is being initially fetched. 121 | }); 122 | 123 | if (fastboot && fastboot.isFastBoot && options && options.ssr !== false) { 124 | fastboot.deferRendering(promise); 125 | } 126 | } 127 | 128 | /** @internal */ 129 | update(): void { 130 | if (!equal(this.#previousPositionalArgs, this.args.positional)) { 131 | this.teardown(); 132 | this.setup(); 133 | } 134 | } 135 | 136 | /** @internal */ 137 | teardown(): void { 138 | if (this.#subscription) { 139 | this.#subscription.unsubscribe(); 140 | } 141 | if (typeof this.#firstPromiseReject === 'function') { 142 | this.#firstPromiseReject(); 143 | this.#firstPromiseReject = undefined; 144 | } 145 | } 146 | 147 | settled(): Promise { 148 | return settled(this.promise); 149 | } 150 | 151 | #onComplete(result: ApolloQueryResult): void { 152 | const { loading, errors, data, networkStatus } = result; 153 | let { error } = result; 154 | 155 | error = 156 | errors && errors.length > 0 157 | ? new ApolloError({ graphQLErrors: errors }) 158 | : undefined; 159 | 160 | this.loading = loading; 161 | this.data = data; 162 | this.networkStatus = networkStatus; 163 | this.error = error; 164 | this.#handleOnCompleteOrOnError(); 165 | } 166 | 167 | #onError(error: ApolloError): void { 168 | if (!Object.prototype.hasOwnProperty.call(error, 'graphQLErrors')) { 169 | error = new ApolloError({ networkError: error }); 170 | } 171 | 172 | this.loading = false; 173 | this.data = undefined; 174 | this.networkStatus = NetworkStatus.error; 175 | this.error = error; 176 | this.#handleOnCompleteOrOnError(); 177 | } 178 | 179 | #handleOnCompleteOrOnError(): void { 180 | // We want to avoid calling the callbacks when this is destroyed. 181 | // If the resource is destroyed, the callback context might not be defined anymore. 182 | if (isDestroyed(this) || isDestroying(this)) { 183 | return; 184 | } 185 | 186 | const [, options] = this.args.positional; 187 | const { onComplete, onError } = options || {}; 188 | const { data, error } = this; 189 | 190 | if (onComplete && !error) { 191 | onComplete(data); 192 | } else if (onError && error) { 193 | onError(error); 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /test-app/tests/unit/custom-query-test.ts: -------------------------------------------------------------------------------- 1 | import { setOwner } from '@ember/owner'; 2 | import type Owner from '@ember/owner'; 3 | import { destroy } from '@ember/destroyable'; 4 | import { tracked } from '@glimmer/tracking'; 5 | 6 | import { 7 | getClient, 8 | gql, 9 | type QueryPositionalArgs, 10 | type QueryResource, 11 | setClient, 12 | useQuery, 13 | } from 'glimmer-apollo'; 14 | 15 | import { module, test } from 'qunit'; 16 | import sinon from 'sinon'; 17 | 18 | import { 19 | ApolloClient, 20 | ApolloError, 21 | createHttpLink, 22 | InMemoryCache, 23 | type OperationVariables, 24 | } from '@apollo/client/core'; 25 | 26 | import { 27 | type UserInfoQuery, 28 | type UserInfoQueryVariables, 29 | } from '../../app/mocks/handlers'; 30 | 31 | function useCustomQuery< 32 | TData = unknown, 33 | TVariables extends OperationVariables = OperationVariables, 34 | >( 35 | parentDestroyable: object, 36 | args: () => QueryPositionalArgs 37 | ): QueryResource { 38 | const customArgs: () => QueryPositionalArgs = function () { 39 | const passedArgs = args(); 40 | const options = Object.assign( 41 | {} as Partial, 42 | { 43 | variables: { id: '2' }, 44 | }, 45 | passedArgs[1] 46 | ); 47 | return [passedArgs[0], options]; 48 | }; 49 | 50 | return useQuery(parentDestroyable, customArgs); 51 | } 52 | 53 | const USER_INFO = gql` 54 | query UserInfo($id: ID!) { 55 | user(id: $id) { 56 | id 57 | firstName 58 | lastName 59 | } 60 | } 61 | `; 62 | 63 | module('useCustomQuery', function (hooks) { 64 | let ctx = {}; 65 | const owner: Owner = {} as Owner; 66 | 67 | const client = new ApolloClient({ 68 | cache: new InMemoryCache(), 69 | link: createHttpLink({ 70 | uri: '/graphql', 71 | }), 72 | }); 73 | 74 | hooks.beforeEach(() => { 75 | ctx = {}; 76 | setOwner(ctx, owner); 77 | setClient(ctx, client); 78 | }); 79 | 80 | hooks.afterEach(() => { 81 | destroy(ctx); 82 | }); 83 | 84 | test('it fetches the default query', async function (assert) { 85 | const query = useCustomQuery( 86 | ctx, 87 | () => [USER_INFO] 88 | ); 89 | 90 | assert.equal(query.loading, true); 91 | assert.equal(query.data, undefined); 92 | await query.settled(); 93 | assert.equal(query.loading, false); 94 | assert.equal(query.error, undefined); 95 | assert.deepEqual(query.data, { 96 | user: { 97 | __typename: 'User', 98 | firstName: 'Joth', 99 | id: '2', 100 | lastName: 'Maverick', 101 | }, 102 | }); 103 | }); 104 | 105 | test('it fetches the query', async function (assert) { 106 | const query = useCustomQuery( 107 | ctx, 108 | () => [ 109 | USER_INFO, 110 | { 111 | variables: { id: '1' }, 112 | }, 113 | ] 114 | ); 115 | 116 | assert.equal(query.loading, true); 117 | assert.equal(query.data, undefined); 118 | await query.settled(); 119 | assert.equal(query.loading, false); 120 | assert.equal(query.error, undefined); 121 | assert.deepEqual(query.data, { 122 | user: { 123 | __typename: 'User', 124 | firstName: 'Cathaline', 125 | id: '1', 126 | lastName: 'McCoy', 127 | }, 128 | }); 129 | }); 130 | 131 | test('it refetches the query when args change', async function (assert) { 132 | class Obj { 133 | @tracked id = '1'; 134 | } 135 | const vars = new Obj(); 136 | 137 | const query = useCustomQuery( 138 | ctx, 139 | () => [ 140 | USER_INFO, 141 | { 142 | variables: { id: vars.id }, 143 | }, 144 | ] 145 | ); 146 | 147 | assert.equal(query.loading, true); 148 | assert.equal(query.data, undefined); 149 | await query.promise; 150 | assert.equal(query.loading, false); 151 | assert.equal(query.data?.user?.id, '1'); 152 | 153 | vars.id = '2'; 154 | assert.equal(query.loading, true); 155 | assert.equal(query.data?.user?.id, '1'); 156 | await query.promise; 157 | assert.equal(query.loading, false); 158 | assert.equal(query.data?.user?.id, '2'); 159 | }); 160 | 161 | test('it returns error', async function (assert) { 162 | const query = useCustomQuery( 163 | ctx, 164 | () => [ 165 | USER_INFO, 166 | { 167 | variables: { id: 'NOT_FOUND' }, 168 | }, 169 | ] 170 | ); 171 | 172 | assert.equal(query.loading, true); 173 | assert.equal(query.data, undefined); 174 | assert.equal(query.error, undefined); 175 | await query.settled(); 176 | assert.equal(query.loading, false); 177 | assert.equal(query.error?.message, 'User not found'); 178 | assert.equal(query.data, undefined); 179 | }); 180 | 181 | test('it calls onComplete', async function (assert) { 182 | let onCompleteCalled: unknown; 183 | const query = useCustomQuery( 184 | ctx, 185 | () => [ 186 | USER_INFO, 187 | { 188 | variables: { id: '2' }, 189 | onComplete: (data) => { 190 | onCompleteCalled = data; 191 | }, 192 | }, 193 | ] 194 | ); 195 | 196 | assert.equal(query.data, undefined); 197 | await query.settled(); 198 | 199 | const expectedData = { 200 | user: { 201 | __typename: 'User', 202 | firstName: 'Joth', 203 | id: '2', 204 | lastName: 'Maverick', 205 | }, 206 | }; 207 | 208 | assert.deepEqual(query.data as unknown, expectedData); 209 | assert.deepEqual(onCompleteCalled, expectedData); 210 | }); 211 | 212 | test('it calls onError', async function (assert) { 213 | let onErrorCalled: ApolloError | undefined; 214 | const query = useCustomQuery( 215 | ctx, 216 | () => [ 217 | USER_INFO, 218 | { 219 | variables: { id: 'NOT_FOUND' }, 220 | onError: (error) => { 221 | onErrorCalled = error; 222 | }, 223 | }, 224 | ] 225 | ); 226 | 227 | assert.equal(query.error, undefined); 228 | await query.settled(); 229 | 230 | const expectedError = 'User not found'; 231 | assert.equal(query.error?.message, expectedError); 232 | assert.equal(onErrorCalled?.message, expectedError); 233 | }); 234 | 235 | test('it returns error with data', async function (assert) { 236 | let onCompleteCalled: unknown; 237 | let onErrorCalled: ApolloError | undefined; 238 | const query = useCustomQuery( 239 | ctx, 240 | () => [ 241 | USER_INFO, 242 | { 243 | variables: { id: '2-with-error' }, 244 | errorPolicy: 'all', 245 | onComplete: (data) => { 246 | onCompleteCalled = data; 247 | }, 248 | onError: (error) => { 249 | onErrorCalled = error; 250 | }, 251 | }, 252 | ] 253 | ); 254 | 255 | assert.equal(query.data, undefined); 256 | await query.settled(); 257 | 258 | const expectedData = { 259 | user: { 260 | __typename: 'User', 261 | firstName: 'Joth', 262 | id: '2', 263 | lastName: 'Maverick', 264 | }, 265 | }; 266 | 267 | assert.deepEqual(query.data as unknown, expectedData); 268 | assert.equal( 269 | onCompleteCalled, 270 | undefined, 271 | 'onComplete should not be called when there are errors' 272 | ); 273 | 274 | const expectedError = 'Data With Error'; 275 | assert.equal(query.error?.message, expectedError); 276 | assert.equal(onErrorCalled?.message, expectedError); 277 | }); 278 | 279 | test('it does not trigger query update if args references changes but values are the same', async function (assert) { 280 | class Obj { 281 | @tracked id = '1'; 282 | } 283 | const vars = new Obj(); 284 | const sandbox = sinon.createSandbox(); 285 | const client = getClient(ctx); 286 | 287 | const watchQuery = sandbox.spy(client, 'watchQuery'); 288 | const query = useCustomQuery( 289 | ctx, 290 | () => [ 291 | USER_INFO, 292 | { 293 | variables: { id: vars.id }, 294 | }, 295 | ] 296 | ); 297 | 298 | assert.equal(query.data, undefined); 299 | await query.settled(); 300 | 301 | vars.id = '1'; 302 | await query.settled(); 303 | 304 | assert.ok(watchQuery.calledOnce); 305 | 306 | sandbox.restore(); 307 | }); 308 | 309 | test('it uses correct client based on clientId option', async function (assert) { 310 | class Obj { 311 | @tracked id = '1'; 312 | } 313 | const vars = new Obj(); 314 | const sandbox = sinon.createSandbox(); 315 | const defaultClient = getClient(ctx); 316 | const customClient = new ApolloClient({ 317 | cache: new InMemoryCache(), 318 | link: createHttpLink({ 319 | uri: '/graphql', 320 | }), 321 | }); 322 | setClient(ctx, customClient, 'custom-client'); 323 | 324 | const defaultClientWatchQuery = sandbox.spy(defaultClient, 'watchQuery'); 325 | const customClientWatchQuery = sandbox.spy(customClient, 'watchQuery'); 326 | 327 | const query = useCustomQuery( 328 | ctx, 329 | () => [ 330 | USER_INFO, 331 | { 332 | variables: { id: vars.id }, 333 | clientId: 'custom-client', 334 | }, 335 | ] 336 | ); 337 | 338 | await query.settled(); 339 | assert.ok( 340 | customClientWatchQuery.calledOnce, 341 | 'custom client should be used' 342 | ); 343 | assert.ok( 344 | defaultClientWatchQuery.notCalled, 345 | 'default client should not be used' 346 | ); 347 | 348 | sandbox.restore(); 349 | }); 350 | }); 351 | -------------------------------------------------------------------------------- /test-app/tests/unit/subscription-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { module, test } from 'qunit'; 3 | import { waitUntil } from '@ember/test-helpers'; 4 | import { destroy } from '@ember/destroyable'; 5 | import { tracked } from '@glimmer/tracking'; 6 | import { setClient, getClient, useSubscription, gql } from 'glimmer-apollo'; 7 | import { setOwner } from '@ember/owner'; 8 | import type Owner from '@ember/owner'; 9 | import { ApolloClient, ApolloError, InMemoryCache } from '@apollo/client/core'; 10 | import { 11 | type OnMessageAddedSubscription, 12 | type OnMessageAddedSubscriptionVariables, 13 | } from '../../app/mocks/handlers'; 14 | import sinon from 'sinon'; 15 | import { MockSubscriptionLink } from 'test-app/tests/helpers/mock-subscription-link'; 16 | 17 | const SUBSCRIPTION = gql` 18 | subscription OnMessageAdded($channel: String!) { 19 | messageAdded(channel: $channel) { 20 | id 21 | message 22 | } 23 | } 24 | `; 25 | 26 | module('useSubscription', function (hooks) { 27 | const results = ['Hey There!', 'Hello', 'How are you?'].map( 28 | (message, id) => ({ 29 | result: { 30 | data: { 31 | messageAdded: { __typename: 'Message', id: id.toString(), message }, 32 | }, 33 | }, 34 | }) 35 | ); 36 | 37 | let ctx = {}; 38 | const owner: Owner = {} as Owner; 39 | const link = new MockSubscriptionLink(); 40 | 41 | const client = new ApolloClient({ 42 | cache: new InMemoryCache(), 43 | link, 44 | }); 45 | 46 | hooks.beforeEach(() => { 47 | ctx = {}; 48 | setOwner(ctx, owner); 49 | setClient(ctx, client); 50 | }); 51 | 52 | hooks.afterEach(() => { 53 | destroy(ctx); 54 | }); 55 | 56 | test('it fetches the subscription', async function (assert) { 57 | link.simulateResult(results[0]!); 58 | 59 | const sub = useSubscription< 60 | OnMessageAddedSubscription, 61 | OnMessageAddedSubscriptionVariables 62 | >(ctx, () => [ 63 | SUBSCRIPTION, 64 | { 65 | variables: { channel: '1' }, 66 | }, 67 | ]); 68 | 69 | assert.equal(sub.loading, true); 70 | assert.equal(sub.data, undefined); 71 | await sub.settled(); 72 | assert.equal(sub.loading, false); 73 | assert.equal(sub.error, undefined); 74 | assert.deepEqual(sub.data, { 75 | messageAdded: { 76 | __typename: 'Message', 77 | id: '0', 78 | message: 'Hey There!', 79 | }, 80 | }); 81 | 82 | link.simulateResult(results[1]!); 83 | 84 | await waitUntil( 85 | function () { 86 | return sub.data?.messageAdded?.id == '1'; 87 | }, 88 | { timeout: 200 } 89 | ); 90 | 91 | assert.deepEqual(sub.data, { 92 | messageAdded: { 93 | __typename: 'Message', 94 | id: '1', 95 | message: 'Hello', 96 | }, 97 | }); 98 | 99 | // link.simulateComplete(); 100 | }); 101 | 102 | test('it refetches the subscription when args change', async function (assert) { 103 | link.simulateResult(results[0]!); 104 | 105 | class Obj { 106 | @tracked id = '1'; 107 | } 108 | const vars = new Obj(); 109 | 110 | const sub = useSubscription< 111 | OnMessageAddedSubscription, 112 | OnMessageAddedSubscriptionVariables 113 | >(ctx, () => [ 114 | SUBSCRIPTION, 115 | { 116 | variables: { channel: vars.id }, 117 | }, 118 | ]); 119 | 120 | assert.equal(sub.loading, true); 121 | assert.equal(sub.data, undefined); 122 | await sub.promise; 123 | assert.equal(sub.loading, false); 124 | assert.equal(sub.data?.messageAdded?.id, '0'); 125 | 126 | vars.id = '2'; 127 | assert.equal(sub.loading, true); 128 | assert.equal(sub.data?.messageAdded?.id, '0'); 129 | 130 | link.simulateResult(results[1]!); 131 | 132 | await sub.promise; 133 | assert.equal(sub.loading, false); 134 | assert.equal(sub.data?.messageAdded?.id, '1'); 135 | }); 136 | 137 | test('it returns error', async function (assert) { 138 | const subscriptionError = { 139 | error: new Error('error occurred'), 140 | }; 141 | 142 | link.simulateResult(subscriptionError); 143 | 144 | const sub = useSubscription< 145 | OnMessageAddedSubscription, 146 | OnMessageAddedSubscriptionVariables 147 | >(ctx, () => [ 148 | SUBSCRIPTION, 149 | { 150 | variables: { channel: 'NOT_FOUND' }, 151 | }, 152 | ]); 153 | 154 | assert.equal(sub.loading, true); 155 | assert.equal(sub.data, undefined); 156 | assert.equal(sub.error, undefined); 157 | await sub.settled(); 158 | assert.equal(sub.loading, false); 159 | assert.equal(sub.error?.message, 'error occurred'); 160 | assert.equal(sub.data, undefined); 161 | }); 162 | 163 | test('it calls onData', async function (assert) { 164 | link.simulateResult(results[0]!); 165 | 166 | let onDataCalled: unknown; 167 | const sub = useSubscription< 168 | OnMessageAddedSubscription, 169 | OnMessageAddedSubscriptionVariables 170 | >(ctx, () => [ 171 | SUBSCRIPTION, 172 | { 173 | variables: { channel: '2' }, 174 | onData: (data) => { 175 | onDataCalled = data; 176 | }, 177 | }, 178 | ]); 179 | 180 | assert.equal(sub.data, undefined); 181 | await sub.settled(); 182 | 183 | let expectedData = { 184 | messageAdded: { 185 | __typename: 'Message', 186 | id: '0', 187 | message: 'Hey There!', 188 | }, 189 | }; 190 | 191 | assert.deepEqual(sub.data as unknown, expectedData); 192 | assert.deepEqual(onDataCalled, expectedData); 193 | 194 | link.simulateResult(results[1]!); 195 | 196 | await waitUntil( 197 | function () { 198 | return sub.data?.messageAdded?.id == '1'; 199 | }, 200 | { timeout: 200 } 201 | ); 202 | 203 | expectedData = { 204 | messageAdded: { 205 | __typename: 'Message', 206 | id: '1', 207 | message: 'Hello', 208 | }, 209 | }; 210 | 211 | assert.deepEqual(sub.data as unknown, expectedData); 212 | assert.deepEqual(onDataCalled, expectedData); 213 | 214 | link.simulateResult(results[2]!); 215 | 216 | await waitUntil( 217 | function () { 218 | return sub.data?.messageAdded?.id == '2'; 219 | }, 220 | { timeout: 200 } 221 | ); 222 | 223 | expectedData = { 224 | messageAdded: { 225 | __typename: 'Message', 226 | id: '2', 227 | message: 'How are you?', 228 | }, 229 | }; 230 | 231 | assert.deepEqual(sub.data as unknown, expectedData); 232 | assert.deepEqual(onDataCalled, expectedData); 233 | }); 234 | 235 | test('it calls onError', async function (assert) { 236 | const subscriptionError = { 237 | error: new Error('error occurred'), 238 | }; 239 | 240 | link.simulateResult(subscriptionError); 241 | 242 | let onErrorCalled: ApolloError; 243 | const sub = useSubscription< 244 | OnMessageAddedSubscription, 245 | OnMessageAddedSubscriptionVariables 246 | >(ctx, () => [ 247 | SUBSCRIPTION, 248 | { 249 | variables: { channel: 'NOT_FOUND' }, 250 | onError: (error) => { 251 | onErrorCalled = error; 252 | }, 253 | }, 254 | ]); 255 | 256 | assert.equal(sub.error, undefined); 257 | await sub.settled(); 258 | 259 | const expectedError = 'error occurred'; 260 | assert.equal(sub.error?.message, expectedError); 261 | assert.equal(onErrorCalled!.message, expectedError); 262 | }); 263 | 264 | test('it calls onComplete', async function (assert) { 265 | link.simulateResult(results[0]!); 266 | 267 | let onCompleteCalled = false; 268 | const sub = useSubscription< 269 | OnMessageAddedSubscription, 270 | OnMessageAddedSubscriptionVariables 271 | >(ctx, () => [ 272 | SUBSCRIPTION, 273 | { 274 | variables: { channel: '2' }, 275 | onComplete: () => { 276 | onCompleteCalled = true; 277 | }, 278 | }, 279 | ]); 280 | 281 | assert.equal(sub.data, undefined); 282 | await sub.settled(); 283 | 284 | link.simulateComplete(); 285 | 286 | assert.deepEqual( 287 | onCompleteCalled, 288 | true, 289 | 'onComplete should have been called' 290 | ); 291 | }); 292 | 293 | test('it does not trigger subscription update if args references changes but values are the same', async function (assert) { 294 | link.simulateResult(results[0]!); 295 | class Obj { 296 | @tracked id = '1'; 297 | } 298 | const vars = new Obj(); 299 | const sandbox = sinon.createSandbox(); 300 | const client = getClient(ctx); 301 | 302 | const subscribe = sandbox.spy(client, 'subscribe'); 303 | const sub = useSubscription< 304 | OnMessageAddedSubscription, 305 | OnMessageAddedSubscriptionVariables 306 | >(ctx, () => [ 307 | SUBSCRIPTION, 308 | { 309 | variables: { channel: vars.id }, 310 | }, 311 | ]); 312 | 313 | assert.equal(sub.data, undefined); 314 | await sub.settled(); 315 | 316 | vars.id = '1'; 317 | await sub.settled(); 318 | 319 | assert.ok(subscribe.calledOnce); 320 | 321 | sandbox.restore(); 322 | }); 323 | 324 | test('it uses correct client based on clientId option', async function (assert) { 325 | link.simulateResult(results[0]!); 326 | 327 | class Obj { 328 | @tracked id = '1'; 329 | } 330 | const vars = new Obj(); 331 | const sandbox = sinon.createSandbox(); 332 | const defaultClient = getClient(ctx); 333 | const customClient = new ApolloClient({ 334 | cache: new InMemoryCache(), 335 | link, 336 | }); 337 | setClient(ctx, customClient, 'custom-client'); 338 | 339 | const defaultClientWatchsub = sandbox.spy(defaultClient, 'subscribe'); 340 | const customClientWatchsub = sandbox.spy(customClient, 'subscribe'); 341 | 342 | const sub = useSubscription< 343 | OnMessageAddedSubscription, 344 | OnMessageAddedSubscriptionVariables 345 | >(ctx, () => [ 346 | SUBSCRIPTION, 347 | { 348 | variables: { channel: vars.id }, 349 | clientId: 'custom-client', 350 | }, 351 | ]); 352 | 353 | await sub.settled(); 354 | assert.ok(customClientWatchsub.calledOnce, 'custom client should be used'); 355 | assert.ok( 356 | defaultClientWatchsub.notCalled, 357 | 'default client should not be used' 358 | ); 359 | 360 | sandbox.restore(); 361 | }); 362 | }); 363 | -------------------------------------------------------------------------------- /test-app/tests/unit/mutation-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { module, test } from 'qunit'; 3 | import { destroy } from '@ember/destroyable'; 4 | import { 5 | setClient, 6 | clearClients, 7 | useMutation, 8 | getClient, 9 | gql, 10 | } from 'glimmer-apollo'; 11 | import { setOwner } from '@ember/owner'; 12 | import type Owner from '@ember/owner'; 13 | import { tracked } from '@glimmer/tracking'; 14 | import { 15 | ApolloClient, 16 | ApolloError, 17 | InMemoryCache, 18 | createHttpLink, 19 | } from '@apollo/client/core'; 20 | import { 21 | type LoginMutationVariables, 22 | type LoginMutation, 23 | } from '../../app/mocks/handlers'; 24 | import sinon from 'sinon'; 25 | 26 | const LOGIN = gql` 27 | mutation Login($username: String!) { 28 | login(username: $username) { 29 | id 30 | firstName 31 | lastName 32 | } 33 | } 34 | `; 35 | 36 | module('useMutation', function (hooks) { 37 | let ctx = {}; 38 | const owner: Owner = {} as Owner; 39 | 40 | const client = new ApolloClient({ 41 | cache: new InMemoryCache(), 42 | link: createHttpLink({ 43 | uri: '/graphql', 44 | }), 45 | }); 46 | 47 | hooks.beforeEach(() => { 48 | ctx = {}; 49 | setOwner(ctx, owner); 50 | 51 | clearClients(ctx); 52 | setClient(ctx, client); 53 | }); 54 | 55 | hooks.afterEach(() => { 56 | destroy(ctx); 57 | }); 58 | 59 | test('it executes the mutation', async function (assert) { 60 | const mutation = useMutation( 61 | ctx, 62 | () => [ 63 | LOGIN, 64 | { 65 | variables: { username: 'john' }, 66 | }, 67 | ] 68 | ); 69 | 70 | assert.equal(mutation.loading, false); 71 | assert.equal(mutation.called, false); 72 | assert.equal(mutation.data, undefined); 73 | 74 | mutation.mutate(); 75 | assert.equal(mutation.loading, true); 76 | await mutation.settled(); 77 | 78 | assert.equal(mutation.loading, false); 79 | assert.equal(mutation.called, true); 80 | assert.equal(mutation.error, undefined); 81 | assert.deepEqual(mutation.data, { 82 | login: { 83 | __typename: 'User', 84 | firstName: 'Joth', 85 | id: '2', 86 | lastName: 'Maverick', 87 | }, 88 | }); 89 | }); 90 | 91 | test('it returns error', async function (assert) { 92 | const mutation = useMutation( 93 | ctx, 94 | () => [ 95 | LOGIN, 96 | { 97 | variables: { username: 'non-existing' }, 98 | }, 99 | ] 100 | ); 101 | 102 | assert.equal(mutation.loading, false); 103 | assert.equal(mutation.called, false); 104 | assert.equal(mutation.data, undefined); 105 | 106 | mutation.mutate(); 107 | assert.equal(mutation.loading, true); 108 | await mutation.settled(); 109 | 110 | assert.equal(mutation.loading, false); 111 | assert.equal(mutation.called, true); 112 | assert.equal(mutation.data, undefined); 113 | assert.equal(mutation.error?.message, 'User not found with given username'); 114 | }); 115 | 116 | test('it uses variables passed into mutate', function (assert) { 117 | const sandbox = sinon.createSandbox(); 118 | const client = getClient(ctx); 119 | 120 | const mutate = sandbox.spy(client, 'mutate'); 121 | const mutation = useMutation( 122 | ctx, 123 | () => [ 124 | LOGIN, 125 | { 126 | variables: { username: 'non-existing' }, 127 | }, 128 | ] 129 | ); 130 | 131 | mutation.mutate({ username: 'john' }); 132 | 133 | assert.ok(mutate.called); 134 | assert.deepEqual(mutate.args[0]![0].variables, { username: 'john' }); 135 | 136 | sandbox.restore(); 137 | }); 138 | 139 | test('it merges variables passed into mutate', function (assert) { 140 | const sandbox = sinon.createSandbox(); 141 | const client = getClient(ctx); 142 | 143 | const mutate = sandbox.spy(client, 'mutate'); 144 | const mutation = useMutation( 145 | ctx, 146 | () => [ 147 | LOGIN, 148 | { 149 | variables: { username: 'non-existing' }, 150 | }, 151 | ] 152 | ); 153 | 154 | mutation.mutate({ username: 'john', isCool: true } as never); 155 | 156 | assert.ok(mutate.called); 157 | assert.deepEqual(mutate.args[0]![0].variables, { 158 | isCool: true, 159 | username: 'john', 160 | }); 161 | 162 | sandbox.restore(); 163 | }); 164 | 165 | test('it merges options passed into mutate', function (assert) { 166 | const sandbox = sinon.createSandbox(); 167 | const client = getClient(ctx); 168 | 169 | const mutate = sandbox.spy(client, 'mutate'); 170 | const mutation = useMutation( 171 | ctx, 172 | () => [ 173 | LOGIN, 174 | { 175 | variables: { username: 'non-existing' }, 176 | awaitRefetchQueries: true, 177 | }, 178 | ] 179 | ); 180 | 181 | mutation.mutate(undefined, { refetchQueries: ['CollQuery'] }); 182 | 183 | assert.ok(mutate.called); 184 | assert.equal(mutate.args[0]![0].awaitRefetchQueries, true); 185 | assert.deepEqual(mutate.args[0]![0].refetchQueries, ['CollQuery']); 186 | 187 | sandbox.restore(); 188 | }); 189 | 190 | test('it calls onComplete', async function (assert) { 191 | let onCompleteCalled: unknown; 192 | const mutation = useMutation( 193 | ctx, 194 | () => [ 195 | LOGIN, 196 | { 197 | variables: { username: 'john' }, 198 | onComplete: (data) => { 199 | onCompleteCalled = data; 200 | }, 201 | }, 202 | ] 203 | ); 204 | 205 | assert.equal(mutation.data, undefined); 206 | mutation.mutate(); 207 | await mutation.settled(); 208 | 209 | const expectedData = { 210 | login: { 211 | __typename: 'User', 212 | firstName: 'Joth', 213 | id: '2', 214 | lastName: 'Maverick', 215 | }, 216 | }; 217 | 218 | assert.deepEqual(mutation.data as unknown, expectedData); 219 | assert.deepEqual(onCompleteCalled, expectedData); 220 | }); 221 | 222 | test('it calls onError', async function (assert) { 223 | let onErrorCalled: ApolloError; 224 | const mutation = useMutation( 225 | ctx, 226 | () => [ 227 | LOGIN, 228 | { 229 | variables: { username: 'non-existing' }, 230 | onError: (error) => { 231 | onErrorCalled = error; 232 | }, 233 | }, 234 | ] 235 | ); 236 | 237 | assert.equal(mutation.error, undefined); 238 | mutation.mutate(); 239 | await mutation.settled(); 240 | 241 | assert.equal(mutation.error?.message, 'User not found with given username'); 242 | assert.equal(onErrorCalled!.message, 'User not found with given username'); 243 | }); 244 | 245 | test('it returns error with data', async function (assert) { 246 | let onCompleteCalled: unknown; 247 | let onErrorCalled: ApolloError; 248 | const mutation = useMutation( 249 | ctx, 250 | () => [ 251 | LOGIN, 252 | { 253 | variables: { username: 'with-error' }, 254 | errorPolicy: 'all', 255 | onComplete: (data) => { 256 | onCompleteCalled = data; 257 | }, 258 | onError: (error) => { 259 | onErrorCalled = error; 260 | }, 261 | }, 262 | ] 263 | ); 264 | 265 | assert.equal(mutation.error, undefined); 266 | mutation.mutate(); 267 | await mutation.settled(); 268 | 269 | const expectedData = { 270 | login: { 271 | __typename: 'User', 272 | firstName: 'Joth', 273 | id: '2', 274 | lastName: 'Maverick', 275 | }, 276 | }; 277 | 278 | assert.deepEqual(mutation.data as unknown, expectedData); 279 | assert.equal( 280 | onCompleteCalled, 281 | undefined, 282 | 'onComplete should not be called when there are errors' 283 | ); 284 | assert.equal(mutation.error?.message, 'Error with Data'); 285 | assert.equal(onErrorCalled!.message, 'Error with Data'); 286 | }); 287 | 288 | test('it updates when args changes', function (assert) { 289 | class Obj { 290 | @tracked username = 'non-existing'; 291 | } 292 | const vars = new Obj(); 293 | const sandbox = sinon.createSandbox(); 294 | const client = getClient(ctx); 295 | 296 | const mutate = sandbox.spy(client, 'mutate'); 297 | const mutation = useMutation( 298 | ctx, 299 | () => [ 300 | LOGIN, 301 | { 302 | variables: { username: vars.username }, 303 | }, 304 | ] 305 | ); 306 | 307 | mutation.mutate(); 308 | 309 | assert.ok(mutate.called); 310 | assert.deepEqual(mutate.args[0]![0].variables, { 311 | username: 'non-existing', 312 | }); 313 | 314 | vars.username = 'john'; 315 | 316 | mutation.mutate(); 317 | 318 | assert.deepEqual(mutate.args[1]![0].variables, { 319 | username: 'john', 320 | }); 321 | 322 | sandbox.restore(); 323 | }); 324 | 325 | test('it uses correct client based on clientId option', function (assert) { 326 | class Obj { 327 | @tracked username = 'non-existing'; 328 | } 329 | const vars = new Obj(); 330 | const sandbox = sinon.createSandbox(); 331 | const defaultClient = getClient(ctx); 332 | const customClient = new ApolloClient({ 333 | cache: new InMemoryCache(), 334 | link: createHttpLink({ 335 | uri: '/graphql', 336 | }), 337 | }); 338 | setClient(ctx, customClient, 'custom-client'); 339 | 340 | const defaultMutate = sandbox.spy(defaultClient, 'mutate'); 341 | const customMutate = sandbox.spy(customClient, 'mutate'); 342 | 343 | const mutation = useMutation( 344 | ctx, 345 | () => [ 346 | LOGIN, 347 | { 348 | variables: { username: vars.username }, 349 | clientId: 'custom-client', 350 | }, 351 | ] 352 | ); 353 | 354 | mutation.mutate(); 355 | 356 | assert.ok(customMutate.calledOnce, 'custom client should be used'); 357 | assert.ok(defaultMutate.notCalled, 'default client should not be used'); 358 | 359 | sandbox.restore(); 360 | }); 361 | }); 362 | -------------------------------------------------------------------------------- /test-app/public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker. 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | */ 9 | 10 | const PACKAGE_VERSION = '2.12.4' 11 | const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' 12 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') 13 | const activeClientIds = new Set() 14 | 15 | addEventListener('install', function () { 16 | self.skipWaiting() 17 | }) 18 | 19 | addEventListener('activate', function (event) { 20 | event.waitUntil(self.clients.claim()) 21 | }) 22 | 23 | addEventListener('message', async function (event) { 24 | const clientId = Reflect.get(event.source || {}, 'id') 25 | 26 | if (!clientId || !self.clients) { 27 | return 28 | } 29 | 30 | const client = await self.clients.get(clientId) 31 | 32 | if (!client) { 33 | return 34 | } 35 | 36 | const allClients = await self.clients.matchAll({ 37 | type: 'window', 38 | }) 39 | 40 | switch (event.data) { 41 | case 'KEEPALIVE_REQUEST': { 42 | sendToClient(client, { 43 | type: 'KEEPALIVE_RESPONSE', 44 | }) 45 | break 46 | } 47 | 48 | case 'INTEGRITY_CHECK_REQUEST': { 49 | sendToClient(client, { 50 | type: 'INTEGRITY_CHECK_RESPONSE', 51 | payload: { 52 | packageVersion: PACKAGE_VERSION, 53 | checksum: INTEGRITY_CHECKSUM, 54 | }, 55 | }) 56 | break 57 | } 58 | 59 | case 'MOCK_ACTIVATE': { 60 | activeClientIds.add(clientId) 61 | 62 | sendToClient(client, { 63 | type: 'MOCKING_ENABLED', 64 | payload: { 65 | client: { 66 | id: client.id, 67 | frameType: client.frameType, 68 | }, 69 | }, 70 | }) 71 | break 72 | } 73 | 74 | case 'CLIENT_CLOSED': { 75 | activeClientIds.delete(clientId) 76 | 77 | const remainingClients = allClients.filter((client) => { 78 | return client.id !== clientId 79 | }) 80 | 81 | // Unregister itself when there are no more clients 82 | if (remainingClients.length === 0) { 83 | self.registration.unregister() 84 | } 85 | 86 | break 87 | } 88 | } 89 | }) 90 | 91 | addEventListener('fetch', function (event) { 92 | const requestInterceptedAt = Date.now() 93 | 94 | // Bypass navigation requests. 95 | if (event.request.mode === 'navigate') { 96 | return 97 | } 98 | 99 | // Opening the DevTools triggers the "only-if-cached" request 100 | // that cannot be handled by the worker. Bypass such requests. 101 | if ( 102 | event.request.cache === 'only-if-cached' && 103 | event.request.mode !== 'same-origin' 104 | ) { 105 | return 106 | } 107 | 108 | // Bypass all requests when there are no active clients. 109 | // Prevents the self-unregistered worked from handling requests 110 | // after it's been terminated (still remains active until the next reload). 111 | if (activeClientIds.size === 0) { 112 | return 113 | } 114 | 115 | const requestId = crypto.randomUUID() 116 | event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) 117 | }) 118 | 119 | /** 120 | * @param {FetchEvent} event 121 | * @param {string} requestId 122 | * @param {number} requestInterceptedAt 123 | */ 124 | async function handleRequest(event, requestId, requestInterceptedAt) { 125 | const client = await resolveMainClient(event) 126 | const requestCloneForEvents = event.request.clone() 127 | const response = await getResponse( 128 | event, 129 | client, 130 | requestId, 131 | requestInterceptedAt, 132 | ) 133 | 134 | // Send back the response clone for the "response:*" life-cycle events. 135 | // Ensure MSW is active and ready to handle the message, otherwise 136 | // this message will pend indefinitely. 137 | if (client && activeClientIds.has(client.id)) { 138 | const serializedRequest = await serializeRequest(requestCloneForEvents) 139 | 140 | // Clone the response so both the client and the library could consume it. 141 | const responseClone = response.clone() 142 | 143 | sendToClient( 144 | client, 145 | { 146 | type: 'RESPONSE', 147 | payload: { 148 | isMockedResponse: IS_MOCKED_RESPONSE in response, 149 | request: { 150 | id: requestId, 151 | ...serializedRequest, 152 | }, 153 | response: { 154 | type: responseClone.type, 155 | status: responseClone.status, 156 | statusText: responseClone.statusText, 157 | headers: Object.fromEntries(responseClone.headers.entries()), 158 | body: responseClone.body, 159 | }, 160 | }, 161 | }, 162 | responseClone.body ? [serializedRequest.body, responseClone.body] : [], 163 | ) 164 | } 165 | 166 | return response 167 | } 168 | 169 | /** 170 | * Resolve the main client for the given event. 171 | * Client that issues a request doesn't necessarily equal the client 172 | * that registered the worker. It's with the latter the worker should 173 | * communicate with during the response resolving phase. 174 | * @param {FetchEvent} event 175 | * @returns {Promise} 176 | */ 177 | async function resolveMainClient(event) { 178 | const client = await self.clients.get(event.clientId) 179 | 180 | if (activeClientIds.has(event.clientId)) { 181 | return client 182 | } 183 | 184 | if (client?.frameType === 'top-level') { 185 | return client 186 | } 187 | 188 | const allClients = await self.clients.matchAll({ 189 | type: 'window', 190 | }) 191 | 192 | return allClients 193 | .filter((client) => { 194 | // Get only those clients that are currently visible. 195 | return client.visibilityState === 'visible' 196 | }) 197 | .find((client) => { 198 | // Find the client ID that's recorded in the 199 | // set of clients that have registered the worker. 200 | return activeClientIds.has(client.id) 201 | }) 202 | } 203 | 204 | /** 205 | * @param {FetchEvent} event 206 | * @param {Client | undefined} client 207 | * @param {string} requestId 208 | * @param {number} requestInterceptedAt 209 | * @returns {Promise} 210 | */ 211 | async function getResponse(event, client, requestId, requestInterceptedAt) { 212 | // Clone the request because it might've been already used 213 | // (i.e. its body has been read and sent to the client). 214 | const requestClone = event.request.clone() 215 | 216 | function passthrough() { 217 | // Cast the request headers to a new Headers instance 218 | // so the headers can be manipulated with. 219 | const headers = new Headers(requestClone.headers) 220 | 221 | // Remove the "accept" header value that marked this request as passthrough. 222 | // This prevents request alteration and also keeps it compliant with the 223 | // user-defined CORS policies. 224 | const acceptHeader = headers.get('accept') 225 | if (acceptHeader) { 226 | const values = acceptHeader.split(',').map((value) => value.trim()) 227 | const filteredValues = values.filter( 228 | (value) => value !== 'msw/passthrough', 229 | ) 230 | 231 | if (filteredValues.length > 0) { 232 | headers.set('accept', filteredValues.join(', ')) 233 | } else { 234 | headers.delete('accept') 235 | } 236 | } 237 | 238 | return fetch(requestClone, { headers }) 239 | } 240 | 241 | // Bypass mocking when the client is not active. 242 | if (!client) { 243 | return passthrough() 244 | } 245 | 246 | // Bypass initial page load requests (i.e. static assets). 247 | // The absence of the immediate/parent client in the map of the active clients 248 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 249 | // and is not ready to handle requests. 250 | if (!activeClientIds.has(client.id)) { 251 | return passthrough() 252 | } 253 | 254 | // Notify the client that a request has been intercepted. 255 | const serializedRequest = await serializeRequest(event.request) 256 | const clientMessage = await sendToClient( 257 | client, 258 | { 259 | type: 'REQUEST', 260 | payload: { 261 | id: requestId, 262 | interceptedAt: requestInterceptedAt, 263 | ...serializedRequest, 264 | }, 265 | }, 266 | [serializedRequest.body], 267 | ) 268 | 269 | switch (clientMessage.type) { 270 | case 'MOCK_RESPONSE': { 271 | return respondWithMock(clientMessage.data) 272 | } 273 | 274 | case 'PASSTHROUGH': { 275 | return passthrough() 276 | } 277 | } 278 | 279 | return passthrough() 280 | } 281 | 282 | /** 283 | * @param {Client} client 284 | * @param {any} message 285 | * @param {Array} transferrables 286 | * @returns {Promise} 287 | */ 288 | function sendToClient(client, message, transferrables = []) { 289 | return new Promise((resolve, reject) => { 290 | const channel = new MessageChannel() 291 | 292 | channel.port1.onmessage = (event) => { 293 | if (event.data && event.data.error) { 294 | return reject(event.data.error) 295 | } 296 | 297 | resolve(event.data) 298 | } 299 | 300 | client.postMessage(message, [ 301 | channel.port2, 302 | ...transferrables.filter(Boolean), 303 | ]) 304 | }) 305 | } 306 | 307 | /** 308 | * @param {Response} response 309 | * @returns {Response} 310 | */ 311 | function respondWithMock(response) { 312 | // Setting response status code to 0 is a no-op. 313 | // However, when responding with a "Response.error()", the produced Response 314 | // instance will have status code set to 0. Since it's not possible to create 315 | // a Response instance with status code 0, handle that use-case separately. 316 | if (response.status === 0) { 317 | return Response.error() 318 | } 319 | 320 | const mockedResponse = new Response(response.body, response) 321 | 322 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { 323 | value: true, 324 | enumerable: true, 325 | }) 326 | 327 | return mockedResponse 328 | } 329 | 330 | /** 331 | * @param {Request} request 332 | */ 333 | async function serializeRequest(request) { 334 | return { 335 | url: request.url, 336 | mode: request.mode, 337 | method: request.method, 338 | headers: Object.fromEntries(request.headers.entries()), 339 | cache: request.cache, 340 | credentials: request.credentials, 341 | destination: request.destination, 342 | integrity: request.integrity, 343 | redirect: request.redirect, 344 | referrer: request.referrer, 345 | referrerPolicy: request.referrerPolicy, 346 | body: await request.arrayBuffer(), 347 | keepalive: request.keepalive, 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /docs/fetching/subscriptions.md: -------------------------------------------------------------------------------- 1 | --- 2 | order: 3 3 | --- 4 | 5 | # Subscriptions 6 | 7 | In addition to queries and mutations, GraphQL supports a third operation type: `subscriptions`. 8 | 9 | Subscriptions enable you to fetch data for long-lasting operations that can change their result over time. They maintain an active connection to your GraphQL server via WebSocket, allowing the server to push updates to the subscription's result. 10 | 11 | Subscriptions help notify your client in real-time about changes to back-end data, such as adding new objects, updated fields, and so on. 12 | 13 | ## Client Setup 14 | 15 | As subscriptions usually maintain a persistent connection, they shouldn't use 16 | the default HTTP transport that Apollo Client uses for queries 17 | and mutations. Instead, Apollo Client subscriptions most commonly 18 | communicate over WebSocket, via the community-maintained 19 | [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws) library. 20 | 21 | 22 | ```sh 23 | yarn add -D subscriptions-transport-ws 24 | ``` 25 | 26 | ```sh 27 | npm install --save-dev subscriptions-transport-ws 28 | ``` 29 | 30 | ```ts:app/apollo.ts 31 | import { setClient } from 'glimmer-apollo'; 32 | import { 33 | ApolloClient, 34 | InMemoryCache, 35 | createHttpLink, 36 | split 37 | } from '@apollo/client/core'; 38 | import { getMainDefinition } from '@apollo/client/utilities'; 39 | import { WebSocketLink } from '@apollo/client/link/ws'; 40 | 41 | export default function setupApolloClient(context: object): void { 42 | // WebSocket connection to the API 43 | const wsLink = new WebSocketLink({ 44 | uri: 'ws://localhost:3000/subscriptions', 45 | options: { 46 | reconnect: true 47 | } 48 | }); 49 | 50 | // HTTP connection to the API 51 | const httpLink = createHttpLink({ 52 | uri: 'http://localhost:3000/graphql' 53 | }); 54 | 55 | // Cache implementation 56 | const cache = new InMemoryCache(); 57 | 58 | // Split HTTP link and WebSockete link 59 | const splitLink = split( 60 | ({ query }) => { 61 | const definition = getMainDefinition(query); 62 | return ( 63 | definition.kind === 'OperationDefinition' && 64 | definition.operation === 'subscription' 65 | ); 66 | }, 67 | wsLink, 68 | httpLink 69 | ); 70 | 71 | // Create the apollo client 72 | const apolloClient = new ApolloClient({ 73 | link: splitLink, 74 | cache 75 | }); 76 | 77 | // Set default apollo client for Glimmer Apollo 78 | setClient(context, apolloClient); 79 | } 80 | ``` 81 | 82 | ## Executing a Subscription 83 | 84 | Let's define our GraphQL Subscription document. 85 | 86 | 87 | ```ts:subscriptions.ts 88 | import { gql } from 'glimmer-apollo'; 89 | 90 | export const ON_MESSAGED_ADDED = gql` 91 | subscription OnMessageAdded($channel: String!) { 92 | messageAdded(channel: $channel) { 93 | id 94 | message 95 | } 96 | } 97 | `; 98 | 99 | export type OnMessageAddedSubscriptionVariables = Exact<{ 100 | channel: string; 101 | }>; 102 | 103 | export type OnMessageAddedSubscription = { 104 | __typename?: 'Subscription'; 105 | 106 | messageAdded?: { 107 | __typename?: 'Message'; 108 | id: string; 109 | message: string; 110 | } | null; 111 | }; 112 | ``` 113 | 114 | ## useSubscription 115 | 116 | Similar to `useQuery` and `useMutation`, `useSubscription` is a utility function to create a Subscription Resource. 117 | 118 | ```ts:latest-message.ts 119 | import { useSubscription } from 'glimmer-apollo'; 120 | import { 121 | ON_MESSAGED_ADDED, 122 | OnMessageAddedSubscription, 123 | OnMessageAddedSubscriptionVariables 124 | } from './subscriptions'; 125 | 126 | export default class LatestMessage extends Component { 127 | latestMessage = useSubscription< 128 | OnMessageAddedSubscription, 129 | OnMessageAddedSubscriptionVariables 130 | >( 131 | this, 132 | () => [ 133 | ON_MESSAGED_ADDED, 134 | { 135 | /* options */ 136 | } 137 | ] 138 | ); 139 | } 140 | ``` 141 | 142 | - The `this` is to keep track of destruction. When the context object (`this`) is destroyed, all the subscriptions resources attached to it can be destroyed and the connection closed. 143 | - The second argument to `useSubscription` should always be a function that returns an array. 144 | - The subscription will not be executed until any property of the resource is accessed. 145 | 146 | ```gts:latest-message.gts 147 | import Component from '@glimmer/component'; 148 | import { useSubscription } from 'glimmer-apollo'; 149 | import { 150 | ON_MESSAGED_ADDED, 151 | OnMessageAddedSubscription, 152 | OnMessageAddedSubscriptionVariables 153 | } from './subscriptions'; 154 | 155 | export default class LatestMessage extends Component { 156 | latestMessage = useSubscription< 157 | OnMessageAddedSubscription, 158 | OnMessageAddedSubscriptionVariables 159 | >( 160 | this, 161 | () => [ 162 | ON_MESSAGED_ADDED, 163 | { 164 | variables: { 165 | channel: 'glimmer-apollo' 166 | } 167 | } 168 | ] 169 | ); 170 | 171 | 182 | } 183 | ``` 184 | 185 | ### Variables 186 | 187 | You can pass a variables object as part of the options argument for `useSubscription` 188 | args thunk. 189 | 190 | ```ts 191 | latestMessage = useSubscription(this, () => [ 192 | ON_MESSAGED_ADDED, 193 | { variables: { channel: this.args.channel } } 194 | ]); 195 | ``` 196 | 197 | If your variables are `tracked`, Glimmer Apollo will re-execute your subscription. 198 | 199 | ## Options 200 | 201 | Alongside variables, you can pass additional options to `useSubscription`. These options vary from fetch policies, error policies, and more. 202 | 203 | ```ts 204 | latestMessage = useSubscription(this, () => [ 205 | ON_MESSAGED_ADDED, 206 | { 207 | variables: { channel: this.args.channel }, 208 | errorPolicy: 'all', 209 | fetchPolicy: 'network-only', 210 | ssr: false 211 | } 212 | ]); 213 | ``` 214 | 215 | ### `ssr` 216 | 217 | Glimmer Apollo supports SSR with [FastBoot](http://ember-fastboot.com/) by default. Any subscriptions that are triggered while rendering in FastBoot are automatically awaited for the first message to respond. 218 | 219 | The `ssr` option allows disabling execution of subscriptions when running in SSR with FastBoot. It will skip the execution entirely in FastBoot but will execute when running in the Browser. This feature is useful if you are fetching secondary data to the page and can wait to be fetched. 220 | 221 | ### `clientId` 222 | 223 | This option specifies which Apollo Client should be used for the given subscription. Glimmer Apollo supports defining multiple Apollo Clients that are distinguished by a custom identifier while setting the client to Glimmer Apollo. 224 | 225 | ```ts 226 | // .... 227 | setClient( 228 | this, 229 | new ApolloClient({ 230 | /* ... */ 231 | }), 232 | 'my-custom-client' 233 | ); 234 | // .... 235 | latestMessages = useSubscription(this, () => [ON_MESSAGED_ADDED, { clientId: 'my-custom-client' }]); 236 | ``` 237 | 238 | ## Query Status 239 | 240 | ### `loading` 241 | 242 | This is a handy property that allows us to inform our interface that we are loading data. 243 | 244 | ```gts 245 | import Component from '@glimmer/component'; 246 | import { useSubscription } from 'glimmer-apollo'; 247 | import { 248 | ON_MESSAGED_ADDED, 249 | OnMessageAddedSubscription, 250 | OnMessageAddedSubscriptionVariables 251 | } from './subscriptions'; 252 | 253 | export default class LatestMessage extends Component { 254 | latestMessage = useSubscription< 255 | OnMessageAddedSubscription, 256 | OnMessageAddedSubscriptionVariables 257 | >( 258 | this, 259 | () => [ 260 | ON_MESSAGED_ADDED, 261 | { 262 | // ... 263 | } 264 | ] 265 | ); 266 | 267 | 274 | } 275 | ``` 276 | 277 | ### `error` 278 | 279 | This property that can be `undefined` or an `ApolloError` object, holds the information about any errors that occurred while executing your query. The reported errors are directly reflected from the `errorPolicy` option available from Apollo Client. 280 | 281 | ```gts 282 | import Component from '@glimmer/component'; 283 | import { useSubscription } from 'glimmer-apollo'; 284 | import { 285 | ON_MESSAGED_ADDED, 286 | OnMessageAddedSubscription, 287 | OnMessageAddedSubscriptionVariables 288 | } from './subscriptions'; 289 | 290 | export default class LatestMessage extends Component { 291 | latestMessage = useSubscription< 292 | OnMessageAddedSubscription, 293 | OnMessageAddedSubscriptionVariables 294 | >( 295 | this, 296 | () => [ 297 | ON_MESSAGED_ADDED, 298 | { 299 | // ... 300 | errorPolicy: 'all' 301 | } 302 | ] 303 | ); 304 | 305 | 314 | } 315 | ``` 316 | 317 | For most cases, it's usually sufficient to check for the `loading` state, then the `error` state, then finally, assume that the data is available and render it. 318 | 319 | ### `promise` 320 | 321 | This property holds a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves when the subscription receives the first data from the network. 322 | The Promise will only be updated for the first execution of the Resource, meaning that it won't become an unresolved promise when Apollo Cache is updating or when receiving new events. 323 | 324 | ## Event Callbacks 325 | 326 | As part of the options argument to `useSubscription`, you can pass callback functions 327 | allowing you to execute code when a specific event occurs. 328 | 329 | ### `onData` 330 | 331 | This callback gets called when the subscription receives data. 332 | 333 | ```js 334 | latestMessages = useSubscription(this, () => [ 335 | ON_MESSAGED_ADDED, 336 | { 337 | variables: { channel: this.args.channel }, 338 | onData: (data): void => { 339 | console.log('Received data:', data); 340 | } 341 | } 342 | ]); 343 | ``` 344 | 345 | ### `onError` 346 | 347 | This callback gets called when we have an error. 348 | 349 | ```ts 350 | latestMessages = useSubscription(this, () => [ 351 | ON_MESSAGED_ADDED, 352 | { 353 | variables: { channel: this.args.channel }, 354 | onData: (data): void => { 355 | console.log('Received data:', data); 356 | }, 357 | onError: (error): void => { 358 | console.error('Received an error:', error.message); 359 | } 360 | } 361 | ]); 362 | ``` 363 | 364 | ### `onComplete` 365 | 366 | This callback gets called when the subscription completes its execution. This 367 | happens when the server closes the connection for example. 368 | 369 | ```js 370 | latestMessages = useSubscription(this, () => [ 371 | ON_MESSAGED_ADDED, 372 | { 373 | variables: { channel: this.args.channel }, 374 | onData: (data): void => { 375 | console.log('Received data:', data); 376 | }, 377 | onError: (error): void => { 378 | console.error('Received an error:', error.message); 379 | }, 380 | onComplete: (): void => { 381 | console.log('Subscription completed'); 382 | } 383 | } 384 | ]); 385 | ``` 386 | 387 | ## Authenticate over WebSocket 388 | 389 | It is often necessary to authenticate a client before allowing it to receive 390 | subscription results. To do this, you can provide a `connectionParams` option 391 | to the `WebSocketLink` constructor in the Apollo Client setup. 392 | 393 | 394 | ```ts 395 | import { WebSocketLink } from '@apollo/client/link/ws'; 396 | 397 | const wsLink = new WebSocketLink({ 398 | uri: 'ws://localhost:3000/subscriptions', 399 | options: { 400 | reconnect: true, 401 | connectionParams: { 402 | authorization: 'Bearer My_TOKEN_HERE' 403 | } 404 | } 405 | }); 406 | ``` 407 | -------------------------------------------------------------------------------- /test-app/tests/unit/query-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/unbound-method */ 2 | import { module, test } from 'qunit'; 3 | import { destroy } from '@ember/destroyable'; 4 | import { tracked } from '@glimmer/tracking'; 5 | import { setClient, getClient, useQuery, gql } from 'glimmer-apollo'; 6 | import { setOwner } from '@ember/owner'; 7 | import type Owner from '@ember/owner'; 8 | import { 9 | ApolloClient, 10 | ApolloError, 11 | InMemoryCache, 12 | type WatchQueryFetchPolicy, 13 | createHttpLink, 14 | } from '@apollo/client/core'; 15 | import { 16 | type UserInfoQuery, 17 | type UserInfoQueryVariables, 18 | } from '../../app/mocks/handlers'; 19 | import sinon from 'sinon'; 20 | import { waitUntil } from '@ember/test-helpers'; 21 | 22 | const USER_INFO = gql` 23 | query UserInfo($id: ID!) { 24 | user(id: $id) { 25 | id 26 | firstName 27 | lastName 28 | } 29 | } 30 | `; 31 | 32 | module('useQuery', function (hooks) { 33 | let ctx = {}; 34 | const owner: Owner = {} as Owner; 35 | 36 | const link = createHttpLink({ 37 | uri: '/graphql', 38 | }); 39 | 40 | const client = new ApolloClient({ 41 | cache: new InMemoryCache(), 42 | link, 43 | }); 44 | 45 | hooks.beforeEach(() => { 46 | ctx = {}; 47 | setOwner(ctx, owner); 48 | setClient(ctx, client); 49 | }); 50 | 51 | hooks.afterEach(() => { 52 | destroy(ctx); 53 | }); 54 | 55 | test('it fetches the query', async function (assert) { 56 | const query = useQuery(ctx, () => [ 57 | USER_INFO, 58 | { 59 | variables: { id: '1' }, 60 | }, 61 | ]); 62 | 63 | assert.equal(query.loading, true); 64 | assert.equal(query.data, undefined); 65 | await query.settled(); 66 | assert.equal(query.loading, false); 67 | assert.equal(query.error, undefined); 68 | assert.deepEqual(query.data, { 69 | user: { 70 | __typename: 'User', 71 | firstName: 'Cathaline', 72 | id: '1', 73 | lastName: 'McCoy', 74 | }, 75 | }); 76 | }); 77 | 78 | test('it refetches the query when args change', async function (assert) { 79 | class Obj { 80 | @tracked id = '1'; 81 | } 82 | const vars = new Obj(); 83 | 84 | const query = useQuery(ctx, () => [ 85 | USER_INFO, 86 | { 87 | variables: { id: vars.id }, 88 | }, 89 | ]); 90 | 91 | assert.equal(query.loading, true); 92 | assert.equal(query.data, undefined); 93 | await query.promise; 94 | assert.equal(query.loading, false); 95 | assert.equal(query.data?.user?.id, '1'); 96 | 97 | vars.id = '2'; 98 | assert.equal(query.loading, true); 99 | assert.equal(query.data?.user?.id, '1'); 100 | await query.promise; 101 | assert.equal(query.loading, false); 102 | assert.equal(query.data?.user?.id, '2'); 103 | }); 104 | 105 | test('it returns error', async function (assert) { 106 | const query = useQuery(ctx, () => [ 107 | USER_INFO, 108 | { 109 | variables: { id: 'NOT_FOUND' }, 110 | }, 111 | ]); 112 | 113 | assert.equal(query.loading, true); 114 | assert.equal(query.data, undefined); 115 | assert.equal(query.error, undefined); 116 | await query.settled(); 117 | assert.equal(query.loading, false); 118 | assert.equal(query.error?.message, 'User not found'); 119 | assert.equal(query.data, undefined); 120 | }); 121 | 122 | test('it calls onComplete', async function (assert) { 123 | let onCompleteCalled: unknown; 124 | const query = useQuery(ctx, () => [ 125 | USER_INFO, 126 | { 127 | variables: { id: '2' }, 128 | onComplete: (data) => { 129 | onCompleteCalled = data; 130 | }, 131 | }, 132 | ]); 133 | 134 | assert.equal(query.data, undefined); 135 | await query.settled(); 136 | 137 | const expectedData = { 138 | user: { 139 | __typename: 'User', 140 | firstName: 'Joth', 141 | id: '2', 142 | lastName: 'Maverick', 143 | }, 144 | }; 145 | 146 | assert.deepEqual(query.data as unknown, expectedData); 147 | assert.deepEqual(onCompleteCalled, expectedData); 148 | }); 149 | 150 | test('it does not call onCompleted if skip is true', async function (assert) { 151 | const sandbox = sinon.createSandbox(); 152 | const onCompleteCallback = sandbox.fake(); 153 | const query = useQuery(ctx, () => [ 154 | USER_INFO, 155 | { 156 | variables: { id: '2' }, 157 | skip: true, 158 | onComplete: onCompleteCallback, 159 | }, 160 | ]); 161 | 162 | await query.settled(); 163 | assert.equal(query.loading, false); 164 | assert.equal(query.data, undefined); 165 | assert.equal(onCompleteCallback.callCount, 0); 166 | 167 | sandbox.restore(); 168 | }); 169 | 170 | test('it calls onError', async function (assert) { 171 | let onErrorCalled: ApolloError; 172 | const query = useQuery(ctx, () => [ 173 | USER_INFO, 174 | { 175 | variables: { id: 'NOT_FOUND' }, 176 | onError: (error) => { 177 | onErrorCalled = error; 178 | }, 179 | }, 180 | ]); 181 | 182 | assert.equal(query.error, undefined); 183 | await query.settled(); 184 | 185 | const expectedError = 'User not found'; 186 | assert.equal(query.error?.message, expectedError); 187 | assert.equal(onErrorCalled!.message, expectedError); 188 | }); 189 | 190 | test('it returns error with data', async function (assert) { 191 | let onCompleteCalled: unknown; 192 | let onErrorCalled: ApolloError; 193 | const query = useQuery(ctx, () => [ 194 | USER_INFO, 195 | { 196 | variables: { id: '2-with-error' }, 197 | errorPolicy: 'all', 198 | onComplete: (data) => { 199 | onCompleteCalled = data; 200 | }, 201 | onError: (error) => { 202 | onErrorCalled = error; 203 | }, 204 | }, 205 | ]); 206 | 207 | assert.equal(query.data, undefined); 208 | await query.settled(); 209 | 210 | const expectedData = { 211 | user: { 212 | __typename: 'User', 213 | firstName: 'Joth', 214 | id: '2', 215 | lastName: 'Maverick', 216 | }, 217 | }; 218 | 219 | assert.deepEqual(query.data as unknown, expectedData); 220 | assert.equal( 221 | onCompleteCalled, 222 | undefined, 223 | 'onComplete should not be called when there are errors' 224 | ); 225 | 226 | const expectedError = 'Data With Error'; 227 | assert.equal(query.error?.message, expectedError); 228 | assert.equal(onErrorCalled!.message, expectedError); 229 | }); 230 | 231 | test('it does not trigger query update if args references changes but values are the same', async function (assert) { 232 | class Obj { 233 | @tracked id = '1'; 234 | } 235 | const vars = new Obj(); 236 | const sandbox = sinon.createSandbox(); 237 | const client = getClient(ctx); 238 | 239 | const watchQuery = sandbox.spy(client, 'watchQuery'); 240 | const query = useQuery(ctx, () => [ 241 | USER_INFO, 242 | { 243 | variables: { id: vars.id }, 244 | }, 245 | ]); 246 | 247 | assert.equal(query.data, undefined); 248 | await query.settled(); 249 | 250 | vars.id = '1'; 251 | await query.settled(); 252 | 253 | assert.ok(watchQuery.calledOnce); 254 | 255 | sandbox.restore(); 256 | }); 257 | 258 | test('it uses correct client based on clientId option', async function (assert) { 259 | class Obj { 260 | @tracked id = '1'; 261 | } 262 | const vars = new Obj(); 263 | const sandbox = sinon.createSandbox(); 264 | const defaultClient = getClient(ctx); 265 | const customClient = new ApolloClient({ 266 | cache: new InMemoryCache(), 267 | link: createHttpLink({ 268 | uri: '/graphql', 269 | }), 270 | }); 271 | setClient(ctx, customClient, 'custom-client'); 272 | 273 | const defaultClientWatchQuery = sandbox.spy(defaultClient, 'watchQuery'); 274 | const customClientWatchQuery = sandbox.spy(customClient, 'watchQuery'); 275 | 276 | const query = useQuery(ctx, () => [ 277 | USER_INFO, 278 | { 279 | variables: { id: vars.id }, 280 | clientId: 'custom-client', 281 | }, 282 | ]); 283 | 284 | await query.settled(); 285 | assert.ok( 286 | customClientWatchQuery.calledOnce, 287 | 'custom client should be used' 288 | ); 289 | assert.ok( 290 | defaultClientWatchQuery.notCalled, 291 | 'default client should not be used' 292 | ); 293 | 294 | sandbox.restore(); 295 | }); 296 | 297 | test('it skips running a query when skip is true', async function (assert) { 298 | class Obj { 299 | @tracked skip = true; 300 | } 301 | const options = new Obj(); 302 | 303 | const query = useQuery(ctx, () => [ 304 | USER_INFO, 305 | { 306 | variables: { id: '1' }, 307 | skip: options.skip, 308 | }, 309 | ]); 310 | 311 | assert.equal(query.loading, false); 312 | assert.equal(query.data, undefined); 313 | 314 | options.skip = false; 315 | 316 | assert.equal(query.loading, true); 317 | assert.equal(query.data, undefined); 318 | await query.settled(); 319 | assert.equal(query.loading, false); 320 | assert.deepEqual(query.data, { 321 | user: { 322 | __typename: 'User', 323 | firstName: 'Cathaline', 324 | id: '1', 325 | lastName: 'McCoy', 326 | }, 327 | }); 328 | }); 329 | 330 | test('it does not make network requests when skip is true', async function (assert) { 331 | const sandbox = sinon.createSandbox(); 332 | 333 | const requestSpy = sandbox.fake(link.request); 334 | sandbox.replace(link, 'request', requestSpy); 335 | 336 | class Obj { 337 | @tracked skip = false; 338 | @tracked id = '1'; 339 | } 340 | const options = new Obj(); 341 | 342 | const query = useQuery(ctx, () => [ 343 | USER_INFO, 344 | { 345 | variables: { id: options.id }, 346 | skip: options.skip, 347 | }, 348 | ]); 349 | 350 | assert.equal(query.loading, true); 351 | assert.equal(query.data, undefined); 352 | 353 | await query.settled(); 354 | assert.equal(query.loading, false); 355 | assert.deepEqual(query.data, { 356 | user: { 357 | __typename: 'User', 358 | firstName: 'Cathaline', 359 | id: '1', 360 | lastName: 'McCoy', 361 | }, 362 | }); 363 | 364 | options.skip = true; 365 | options.id = '2'; 366 | 367 | await query.settled(); 368 | assert.equal(query.loading, false); 369 | assert.deepEqual(query.data, { 370 | user: { 371 | __typename: 'User', 372 | firstName: 'Cathaline', 373 | id: '1', 374 | lastName: 'McCoy', 375 | }, 376 | }); 377 | assert.equal(requestSpy.callCount, 1); 378 | 379 | sandbox.restore(); 380 | }); 381 | 382 | test('it treats fetchPolicy standby like skip', async function (assert) { 383 | class Obj { 384 | @tracked fetchPolicy: WatchQueryFetchPolicy = 'standby'; 385 | } 386 | const options = new Obj(); 387 | 388 | const query = useQuery(ctx, () => [ 389 | USER_INFO, 390 | { 391 | variables: { id: '1' }, 392 | fetchPolicy: options.fetchPolicy, 393 | }, 394 | ]); 395 | 396 | assert.equal(query.loading, false); 397 | assert.equal(query.data, undefined); 398 | 399 | await query.settled(); 400 | 401 | options.fetchPolicy = 'cache-first'; 402 | assert.equal(query.data, undefined); 403 | 404 | await query.settled(); 405 | assert.deepEqual(query.data, { 406 | user: { 407 | __typename: 'User', 408 | firstName: 'Cathaline', 409 | id: '1', 410 | lastName: 'McCoy', 411 | }, 412 | }); 413 | }); 414 | 415 | test('it refetches the query when skip is true', async function (assert) { 416 | const sandbox = sinon.createSandbox(); 417 | 418 | const requestSpy = sandbox.fake(link.request); 419 | sandbox.replace(link, 'request', requestSpy); 420 | 421 | const query = useQuery(ctx, () => [ 422 | USER_INFO, 423 | { 424 | variables: { id: '1' }, 425 | skip: true, 426 | }, 427 | ]); 428 | 429 | query.refetch(); 430 | 431 | assert.equal(query.loading, false); 432 | await waitUntil(() => query.data !== undefined); 433 | assert.ok(requestSpy.calledOnce); 434 | assert.equal(query.loading, false); 435 | assert.equal(query.networkStatus, 7); 436 | assert.deepEqual(query.data, { 437 | user: { 438 | __typename: 'User', 439 | firstName: 'Cathaline', 440 | id: '1', 441 | lastName: 'McCoy', 442 | }, 443 | }); 444 | 445 | sandbox.restore(); 446 | }); 447 | }); 448 | -------------------------------------------------------------------------------- /docs/fetching/mutations.md: -------------------------------------------------------------------------------- 1 | --- 2 | order: 2 3 | --- 4 | 5 | # Mutations 6 | 7 | Now that we've learned how to [fetch data](./queries.md), the next step is to learn how to update that data with mutations. 8 | 9 | ## Executing a Mutation 10 | 11 | Let's define our GraphQL Mutation document. 12 | 13 | ```ts:mutations.ts 14 | import { gql } from 'glimmer-apollo'; 15 | 16 | export const CREATE_NOTE = gql` 17 | mutation CreateNote($input: NoteInput!) { 18 | createNote(input: $input) { 19 | id 20 | title 21 | description 22 | } 23 | } 24 | `; 25 | 26 | export type CreateNoteMutation = { 27 | __typename?: 'Mutation'; 28 | 29 | createNote?: { 30 | __typename?: 'Note'; 31 | id: string; 32 | title: string; 33 | description: string; 34 | } | null; 35 | }; 36 | 37 | export type CreateNoteMutationVariables = { 38 | input: { 39 | title: string; 40 | description: string; 41 | isArchived?: boolean | null; 42 | }; 43 | }; 44 | ``` 45 | 46 | ### useMutation 47 | 48 | Similar to `useQuery`, `useMutation` is a utility function to create a Mutation Resource. 49 | 50 | ```ts:notes.ts 51 | import { useMutation } from 'glimmer-apollo'; 52 | import { 53 | CREATE_NOTE, 54 | CreateNoteMutation, 55 | CreateNoteMutationVariables 56 | } from './mutations'; 57 | 58 | export default class CreateNote extends Component { 59 | createNote = useMutation( 60 | this, 61 | () => [ 62 | CREATE_NOTE, 63 | { 64 | /* options */ 65 | } 66 | ] 67 | ); 68 | } 69 | ``` 70 | 71 | - The `this` is to keep track of destruction. When the context object (`this`) is destroyed, all the mutations resources attached to it can be destroyed. 72 | - The mutation will not be executed until `mutate` function is called. 73 | - The second argument to `useMutation` should always be a function that returns an array. 74 | 75 | ```gts:create-note.gts 76 | import Component from '@glimmer/component'; 77 | import { on } from '@ember/modifier'; 78 | import { action } from '@ember/object'; 79 | import { useMutation } from 'glimmer-apollo'; 80 | import { 81 | CREATE_NOTE, 82 | CreateNoteMutation, 83 | CreateNoteMutationVariables 84 | } from './mutations'; 85 | 86 | export default class CreateNote extends Component { 87 | createNote = useMutation( 88 | this, 89 | () => [ 90 | CREATE_NOTE, 91 | { 92 | /* options */ 93 | } 94 | ] 95 | ); 96 | 97 | @action 98 | async submit(): Promise { 99 | await this.createNote.mutate({ 100 | input: { 101 | title: 'Title', 102 | description: 'Description', 103 | isArchived: false 104 | } 105 | }); 106 | } 107 | 108 | 125 | } 126 | ``` 127 | 128 | In the example above, we call the `mutate` function to execute the GraphQL mutation in the backend API. 129 | 130 | The `mutate` function returns a Promise that resolves with the received data. It's important to note that it will not throw if an error occurs, making the use of `try catch` not possible with the `mutate` function. 131 | 132 | ### Variables 133 | 134 | There are two ways you can pass variables to mutations. 135 | 136 | 1. Pass alongside the options in `useMutation`. 137 | 2. Pass when calling `mutate`. 138 | 139 | Which one should you use? It depends on how you are getting the data for your mutation. 140 | It probably makes sense to pass in the `mutate` call if it comes from a form. 141 | However, some data might be present early on, so you might also want to pass these variables in the `useMutation`. Glimmer Apollo does a shallow merge on variables provided earlier, with these provided at `mutate` time. 142 | 143 | ```ts:create-note.ts 144 | import { useMutation } from 'glimmer-apollo'; 145 | import { 146 | CREATE_NOTE, 147 | CreateNoteMutation, 148 | CreateNoteMutationVariables 149 | } from './mutations'; 150 | 151 | export default class CreateNote extends Component { 152 | createNote = useMutation( 153 | this, 154 | () => [ 155 | CREATE_NOTE, 156 | { 157 | variables: { 158 | /* default variables here */ 159 | } 160 | } 161 | ] 162 | ); 163 | 164 | submit = async (): Promise => { 165 | await this.createNote.mutate({ 166 | /* overwrite default variables here */ 167 | }); 168 | }; 169 | } 170 | ``` 171 | 172 | ## Options 173 | 174 | Similar to variables, you can pass options to mutations on `useMutation` and `mutate` function call. 175 | 176 | ```ts:create-note.ts 177 | import { useMutation } from 'glimmer-apollo'; 178 | import { 179 | CREATE_NOTE, 180 | CreateNoteMutation, 181 | CreateNoteMutationVariables 182 | } from './mutations'; 183 | 184 | export default class CreateNote extends Component { 185 | createNote = useMutation( 186 | this, 187 | () => [ 188 | CREATE_NOTE, 189 | { 190 | errorPolicy: 'all' 191 | } 192 | ] 193 | ); 194 | 195 | submit = async (): Promise => { 196 | await this.createNote.mutate( 197 | { 198 | /* variables */ 199 | }, 200 | /* additional options */ 201 | { refetchQueries: ['GetNotes'] } 202 | ); 203 | }; 204 | } 205 | ``` 206 | 207 | ### `clientId` 208 | 209 | This option specifies which Apollo Client should be used for the given mutation. Glimmer Apollo supports defining multiple Apollo Clients that are distinguished by a custom identifier while setting the client to Glimmer Apollo. 210 | 211 | ```ts 212 | // .... 213 | setClient( 214 | this, 215 | new ApolloClient({ 216 | /* ... */ 217 | }), 218 | 'my-custom-client' 219 | ); 220 | // .... 221 | notes = useMutation(this, () => [ 222 | CREATE_NOTE, 223 | { clientId: 'my-custom-client' } 224 | ]); 225 | ``` 226 | 227 | ## Mutation Status 228 | 229 | ### `called` 230 | 231 | This boolean property informs if the `mutate` function gets called. 232 | 233 | ```gts:create-note.gts 234 | import Component from '@glimmer/component'; 235 | import { on } from '@ember/modifier'; 236 | import { action } from '@ember/object'; 237 | import { useMutation } from 'glimmer-apollo'; 238 | import { 239 | CREATE_NOTE, 240 | CreateNoteMutation, 241 | CreateNoteMutationVariables 242 | } from './mutations'; 243 | 244 | export default class CreateNote extends Component { 245 | createNote = useMutation( 246 | this, 247 | () => [CREATE_NOTE] 248 | ); 249 | 250 | @action 251 | async submit(): Promise { 252 | await this.createNote.mutate(/* variables */); 253 | } 254 | 255 | 264 | } 265 | ``` 266 | 267 | ### `loading` 268 | 269 | This is a handy property that allows us to inform our interface that we are saving data. 270 | 271 | ```gts:create-note.gts 272 | import Component from '@glimmer/component'; 273 | import { on } from '@ember/modifier'; 274 | import { action } from '@ember/object'; 275 | import { useMutation } from 'glimmer-apollo'; 276 | import { 277 | CREATE_NOTE, 278 | CreateNoteMutation, 279 | CreateNoteMutationVariables 280 | } from './mutations'; 281 | 282 | export default class CreateNote extends Component { 283 | createNote = useMutation( 284 | this, 285 | () => [CREATE_NOTE] 286 | ); 287 | 288 | @action 289 | async submit(): Promise { 290 | await this.createNote.mutate(/* variables */); 291 | } 292 | 293 | 302 | } 303 | ``` 304 | 305 | ### `error` 306 | 307 | This property that can be `undefined` or an `ApolloError` object, holds the information about any errors that occurred while executing your mutation. The reported errors are directly reflected from the `errorPolicy` option available from Apollo Client. 308 | 309 | ```gts:create-note.gts 310 | import Component from '@glimmer/component'; 311 | import { on } from '@ember/modifier'; 312 | import { action } from '@ember/object'; 313 | import { useMutation } from 'glimmer-apollo'; 314 | import { 315 | CREATE_NOTE, 316 | CreateNoteMutation, 317 | CreateNoteMutationVariables 318 | } from './mutations'; 319 | 320 | export default class CreateNote extends Component { 321 | createNote = useMutation( 322 | this, 323 | () => [CREATE_NOTE] 324 | ); 325 | 326 | @action 327 | async submit(): Promise { 328 | await this.createNote.mutate(/* variables */); 329 | } 330 | 331 | 340 | } 341 | ``` 342 | 343 | ## Event Callbacks 344 | 345 | As part of the options argument to `useMutation`, you can pass callback functions 346 | allowing you to execute code when a specific event occurs. 347 | 348 | ### `onComplete` 349 | 350 | This callback gets called when the mutation successfully completes execution. 351 | 352 | ```ts 353 | createNote = useMutation(this, () => [ 354 | CREATE_NOTE, 355 | onComplete: (data): void => { 356 | console.log('Received data:', data); 357 | } 358 | } 359 | ]); 360 | ``` 361 | 362 | ### `onError` 363 | 364 | This callback gets called when we have an error. 365 | 366 | ```ts 367 | createNote = useMutation(this, () => [ 368 | CREATE_NOTE, 369 | { 370 | onComplete: (data): void => { 371 | console.log('Received data:', data); 372 | }, 373 | onError: (error): void => { 374 | console.error('Received an error:', error.message); 375 | } 376 | } 377 | ]); 378 | ``` 379 | 380 | ## Updating the Cache 381 | 382 | When you execute a mutation, you modify backend data. If that data is also present in your [Apollo Client cache](https://www.apollographql.com/docs/react/caching/cache-configuration/), you might need to update your cache to reflect the result of the mutation. It depends on whether the mutation updates a single existing record. 383 | 384 | ### Updating a single existing record 385 | 386 | Apollo will automatically update the record if a mutation updates a single record present in the Apollo Client cache. For this behavior to work, the mutation result must include an `id` field. 387 | 388 | ### Making all other cache updates 389 | 390 | If a mutation modifies multiple entities or creates, deletes records, the Apollo Client cache is **not** automatically updated to reflect the result of the mutation. To manually update the cache, you can pass an `update` function to `useMutation` options. 391 | 392 | The purpose of an update function is to modify the cached data to match the modifications that a mutation makes to your backend data without having to refetch the data from your server. 393 | 394 | In the example below, we use `GetNotes` query from the previous section to demonstrate how to update the cache when creating a new note. 395 | 396 | ```gts:create-notes.gts 397 | import Component from '@glimmer/component'; 398 | import { on } from '@ember/modifier'; 399 | import { action } from '@ember/object'; 400 | import { useMutation } from 'glimmer-apollo'; 401 | import { 402 | CREATE_NOTE, 403 | CreateNoteMutation, 404 | CreateNoteMutationVariables 405 | } from './mutations'; 406 | import { GET_NOTES, GetNotesQuery, GetNotesQueryVariables } from './queries'; 407 | 408 | export default class CreateNote extends Component { 409 | createNote = useMutation( 410 | this, 411 | () => [ 412 | CREATE_NOTE, 413 | { 414 | update(cache, result): void { 415 | const variables = { isArchived: false }; 416 | 417 | const data = cache.readQuery({ 418 | query: GET_NOTES, 419 | variables 420 | }); 421 | 422 | if (data) { 423 | const existingNotes = data.notes; 424 | const newNote = result.data?.createNote; 425 | 426 | if (newNote) { 427 | cache.writeQuery({ 428 | query: GET_NOTES, 429 | variables, 430 | data: { notes: [newNote, ...existingNotes] } 431 | }); 432 | } 433 | } 434 | } 435 | } 436 | ] 437 | ); 438 | 439 | @action 440 | async submit(): Promise { 441 | await this.createNote.mutate({ 442 | input: { 443 | title: 'Title', 444 | description: 'Description', 445 | isArchived: false 446 | } 447 | }); 448 | } 449 | 450 | 467 | } 468 | ``` 469 | -------------------------------------------------------------------------------- /docs/fetching/queries.md: -------------------------------------------------------------------------------- 1 | --- 2 | order: 1 3 | --- 4 | 5 | # Queries 6 | 7 | In this guide, we look into how to fetch GraphQL data using Glimmer Apollo. 8 | It assumes you're familiar with how GraphQL queries work. If you aren't, 9 | we recommend this [guide](https://graphql.org/learn/queries/) to learn more 10 | about GraphQL Queries. 11 | 12 | For the purpose of this guide, we will be using GTS (Glimmer TypeScript) format 13 | with inline templates instead of separated template files. This approach uses 14 | the modern `