├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── OptimisticLink.test.ts ├── OptimisticLink.ts ├── TestUtils.ts └── index.ts ├── tsconfig.json ├── tsconfig.test.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | src 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | 5 | script: 6 | - yarn install && yarn test && yarn run coverage && yarn run lint 7 | 8 | sudo: false 9 | 10 | notifications: 11 | email: false 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 - 2018 Jonas Helfer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apollo-link-optimistic 2 | 3 | [![npm version](https://badge.fury.io/js/apollo-link-optimistic.svg)](https://badge.fury.io/js/apollo-link-optimistic) 4 | [![Build Status](https://travis-ci.org/helfer/apollo-link-optimistic.svg?branch=master)](https://travis-ci.org/helfer/apollo-link-optimistic) 5 | [![codecov](https://codecov.io/gh/helfer/apollo-link-optimistic/branch/master/graph/badge.svg)](https://codecov.io/gh/helfer/apollo-link-optimistic) 6 | 7 | An Apollo Link that immediately returns an optimistic response provided in the context of the request, before returning the server response(s). 8 | 9 | This link is useful if you're using Apollo Link without Apollo Client. If you're using Apollo Client, you can use Apollo Client's built-in optimistic responses instead. 10 | 11 | ### Install 12 | 13 | ```sh 14 | npm install apollo-link-optimistic 15 | ``` 16 | 17 | or 18 | 19 | ``` 20 | yarn add apollo-link-optimsitic 21 | ``` 22 | 23 | ### Usage 24 | 25 | ```js 26 | import { ApolloLink } from 'apollo-link'; 27 | import { HttpLink } from 'apollo-link-http'; 28 | import { RetryLink } from 'apollo-link-retry'; 29 | import gql from 'graphql-tag'; 30 | 31 | import OptimisticLink from 'apollo-link-optimistic'; 32 | 33 | this.link = ApolloLink.from([ 34 | new OptimisticLink(), 35 | new HttpLink({ uri: URI_TO_YOUR_GRAPHQL_SERVER }), 36 | ]); 37 | 38 | const optimisticResponse = { 39 | data: { 40 | hello: 'Optimistic World', 41 | }, 42 | }; 43 | 44 | const op = { 45 | query: gql`{ hello }`, 46 | context: { 47 | // OptimisticLink gets the optimistic response from the context. 48 | optimisticResponse, 49 | }, 50 | }; 51 | 52 | link.execute(op).subscribe({ 53 | next(response) { console.log(response.data.hello); }, 54 | complete() { console.log('complete!'); }, 55 | }); 56 | 57 | // Assuming the server responds with { data: { hello: "Server World" } } 58 | // This code will output: 59 | // "Optimistic World" 60 | // "Server World" 61 | // "complete!" 62 | ``` 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-link-optimistic", 3 | "version": "3.0.0", 4 | "description": "An Apollo link that immediately returns an optimistic response before returning the server response", 5 | "dependencies": { 6 | "apollo-link": "^1.2.4", 7 | "apollo-link-optimistic": "^2.0.0" 8 | }, 9 | "devDependencies": { 10 | "@types/graphql": "^0.11.7", 11 | "@types/jest": "^20.0.8", 12 | "@types/node": "^8.0.26", 13 | "codecov": "^2.3.0", 14 | "graphql": "^0.11.7", 15 | "graphql-tag": "^2.4.2", 16 | "nyc": "^11.2.1", 17 | "react-scripts-ts": "^2.6.0", 18 | "tslint": "^5.8.0", 19 | "typescript": "^2.6.2" 20 | }, 21 | "main": "build/dist/index.js", 22 | "module": "build/dist/index.js", 23 | "repository": { 24 | "type": "git", 25 | "url": "helfer/apollo-link-optimistic" 26 | }, 27 | "jsnext:main": "build/dist/index.js", 28 | "typings": "build/dist/index.d.ts", 29 | "scripts": { 30 | "build": "tsc", 31 | "lint": "tslint src/*.ts* src/**/*.ts*", 32 | "test": "react-scripts-ts test --coverage --collectCoverageFrom=src/**/*.ts* --collectCoverageFrom=!src/index.ts --collectCoverageFrom=!src/TestUtils.ts && yarn run lint", 33 | "testonly": "react-scripts-ts test --env=jsdom", 34 | "coverage": "codecov -f coverage/*.json" 35 | }, 36 | "keywords": [ 37 | "graphql", 38 | "apollo", 39 | "apollo-link", 40 | "optimistic", 41 | "observable" 42 | ], 43 | "author": "Jonas Helfer ", 44 | "license": "MIT" 45 | } 46 | -------------------------------------------------------------------------------- /src/OptimisticLink.test.ts: -------------------------------------------------------------------------------- 1 | import OptimisticLink from './OptimisticLink'; 2 | import { 3 | execute, 4 | GraphQLRequest, 5 | ApolloLink, 6 | Operation, 7 | Observable, 8 | } from 'apollo-link'; 9 | import { 10 | ExecutionResult, 11 | } from 'graphql'; 12 | import gql from 'graphql-tag'; 13 | import { TestLink, assertObservableSequence } from './TestUtils'; 14 | 15 | describe('OptimisticLink', () => { 16 | let link: ApolloLink; 17 | let testLink: TestLink; 18 | 19 | const optimisticResponse = { 20 | data: { 21 | hello: 'Optimism', 22 | }, 23 | }; 24 | const testResponse = { 25 | data: { 26 | hello: 'World', 27 | }, 28 | }; 29 | 30 | const op: GraphQLRequest = { 31 | query: gql`{ hello }`, 32 | context: { 33 | optimisticResponse, 34 | testResponse, 35 | }, 36 | }; 37 | 38 | beforeEach(() => { 39 | jest.useFakeTimers(); 40 | testLink = new TestLink(); 41 | link = ApolloLink.from([new OptimisticLink(), testLink]); 42 | }); 43 | 44 | it('forwards the operation', () => { 45 | return new Promise((resolve, reject) => { 46 | execute(link, op).subscribe({ 47 | next: (data) => undefined, 48 | error: (error) => reject(error), 49 | complete: () => { 50 | expect(testLink.operations.length).toBe(1); 51 | expect(testLink.operations[0].query).toEqual(op.query); 52 | resolve(); 53 | }, 54 | }); 55 | jest.runAllTimers(); 56 | }); 57 | }); 58 | it('returns the optimistic response before the real response', () => { 59 | return assertObservableSequence( 60 | execute(link, op), 61 | [ 62 | { type: 'next', value: optimisticResponse }, 63 | { type: 'next', value: testResponse }, 64 | { type: 'complete' }, 65 | ], 66 | () => jest.runAllTimers(), 67 | ); 68 | }); 69 | it('just forwards if context.optimisticResponse is not defined', () => { 70 | const nonOptimisticOp = { 71 | query: op.query, 72 | context: { testResponse }, 73 | }; 74 | return assertObservableSequence( 75 | execute(link, nonOptimisticOp), 76 | [ 77 | { type: 'next', value: testResponse }, 78 | { type: 'complete' }, 79 | ], 80 | () => jest.runAllTimers(), 81 | ); 82 | }); 83 | it('passes through errors', () => { 84 | const testError = new Error('Hello darkness my old friend'); 85 | const opWithError: GraphQLRequest = { 86 | query: gql`{ hello }`, 87 | context: { 88 | optimisticResponse, 89 | testError, 90 | }, 91 | }; 92 | return assertObservableSequence( 93 | execute(link, opWithError), 94 | [ 95 | { type: 'next', value: optimisticResponse }, 96 | { type: 'error', value: testError }, 97 | ], 98 | () => jest.runAllTimers(), 99 | ); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/OptimisticLink.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloLink, 3 | Observable, 4 | Operation, 5 | NextLink, 6 | } from 'apollo-link'; 7 | 8 | export default class OptimisticLink extends ApolloLink { 9 | public request(operation: Operation, forward: NextLink) { 10 | if (!operation.getContext().optimisticResponse) { 11 | return forward(operation); 12 | } 13 | return new Observable(observer => { 14 | // NOTE(helfer): If an upstream link calls next synchronously, the optimistic 15 | // response will arrive after that one. We could prevent this by sending the 16 | // optimistic response synchronously as well if we see next being invoked before 17 | // the timeout 18 | setTimeout(() => observer.next(operation.getContext().optimisticResponse), 0); 19 | 20 | const subscription = forward(operation).subscribe({ 21 | next: observer.next.bind(observer), 22 | error: observer.error.bind(observer), 23 | complete: observer.complete.bind(observer), 24 | }); 25 | 26 | return () => { 27 | subscription.unsubscribe(); 28 | }; 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/TestUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloLink, 3 | Operation, 4 | Observable, 5 | } from 'apollo-link'; 6 | import { 7 | ExecutionResult, 8 | } from 'graphql'; 9 | 10 | export class TestLink extends ApolloLink { 11 | public operations: Operation[]; 12 | constructor() { 13 | super(); 14 | this.operations = []; 15 | } 16 | 17 | public request (operation: Operation) { 18 | this.operations.push(operation); 19 | // TODO(helfer): Throw an error if neither testError nor testResponse is defined 20 | return new Observable(observer => { 21 | if (operation.getContext().testError) { 22 | setTimeout(() => observer.error(operation.getContext().testError), 0); 23 | return; 24 | } 25 | setTimeout(() => observer.next(operation.getContext().testResponse), 0); 26 | setTimeout(() => observer.complete(), 0); 27 | }); 28 | } 29 | } 30 | 31 | export interface ObservableValue { 32 | value?: ExecutionResult | Error; 33 | delay?: number; 34 | type: 'next' | 'error' | 'complete'; 35 | } 36 | 37 | export interface Unsubscribable { 38 | unsubscribe: () => void; 39 | } 40 | 41 | export const assertObservableSequence = ( 42 | observable: Observable, 43 | sequence: ObservableValue[], 44 | initializer: (sub: Unsubscribable) => void = () => undefined, 45 | ): Promise => { 46 | let index = 0; 47 | if (sequence.length === 0) { 48 | throw new Error('Observable sequence must have at least one element'); 49 | } 50 | return new Promise((resolve, reject) => { 51 | const sub = observable.subscribe({ 52 | next: (value) => { 53 | expect({ type: 'next', value }).toEqual(sequence[index]); 54 | index++; 55 | if (index === sequence.length) { 56 | resolve(true); 57 | } 58 | }, 59 | error: (value) => { 60 | expect({ type: 'error', value }).toEqual(sequence[index]); 61 | index++; 62 | // This check makes sure that there is no next element in 63 | // the sequence. If there is, it will print a somewhat useful error 64 | expect(undefined).toEqual(sequence[index]); 65 | resolve(true); 66 | }, 67 | complete: () => { 68 | expect({ type: 'complete' }).toEqual(sequence[index]); 69 | index++; 70 | // This check makes sure that there is no next element in 71 | // the sequence. If there is, it will print a somewhat useful error 72 | expect(undefined).toEqual(sequence[index]); 73 | resolve(true); 74 | }, 75 | }); 76 | initializer(sub); 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import OptimisticLink from './OptimisticLink'; 2 | export default OptimisticLink; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "noImplicitAny": true, 7 | "lib": ["esnext"], 8 | "removeComments": true, 9 | "preserveConstEnums": true, 10 | "outDir": "build/dist", 11 | "sourceMap": true 12 | }, 13 | "include": [ 14 | "src/**/*" 15 | ], 16 | "exclude": [ 17 | "node_modules", 18 | "**/*.test.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:latest", 3 | "rules": { 4 | "no-unnecessary-type-assertion": true, 5 | 6 | "array-type": [true, "array"], 7 | "ban-types": { 8 | "options": [ 9 | ["Object", "Avoid using the `Object` type. Did you mean `object`?"], 10 | ["Function", "Avoid using the `Function` type. Prefer a specific function type, like `() => void`, or use `ts.AnyFunction`."], 11 | ["Boolean", "Avoid using the `Boolean` type. Did you mean `boolean`?"], 12 | ["Number", "Avoid using the `Number` type. Did you mean `number`?"], 13 | ["String", "Avoid using the `String` type. Did you mean `string`?"] 14 | ] 15 | }, 16 | "boolean-trivia": true, 17 | "class-name": true, 18 | "comment-format": [true, 19 | "check-space" 20 | ], 21 | "curly":[true, "ignore-same-line"], 22 | "debug-assert": true, 23 | "indent": [true, 24 | "spaces" 25 | ], 26 | "interface-name": [true, "never-prefix"], 27 | "interface-over-type-literal": true, 28 | "jsdoc-format": true, 29 | "linebreak-style": [true, "LF"], 30 | "next-line": [true, 31 | "check-catch", 32 | "check-else" 33 | ], 34 | "no-bom": true, 35 | "no-double-space": true, 36 | "no-in-operator": true, 37 | "no-increment-decrement": true, 38 | "no-inferrable-types": true, 39 | "no-internal-module": true, 40 | "no-null-keyword": true, 41 | "no-switch-case-fall-through": true, 42 | "no-trailing-whitespace": [true, "ignore-template-strings"], 43 | "no-type-assertion-whitespace": true, 44 | "no-unnecessary-qualifier": true, 45 | "no-var-keyword": true, 46 | "object-literal-shorthand": true, 47 | "object-literal-surrounding-space": true, 48 | "one-line": [true, 49 | "check-open-brace", 50 | "check-whitespace" 51 | ], 52 | "prefer-const": true, 53 | "quotemark": [true, 54 | "single", 55 | "avoid-escape" 56 | ], 57 | "semicolon": [true, "always", "ignore-bound-class-methods"], 58 | "space-within-parens": true, 59 | "triple-equals": true, 60 | "type-operator-spacing": true, 61 | "typedef-whitespace": [ 62 | true, 63 | { 64 | "call-signature": "nospace", 65 | "index-signature": "nospace", 66 | "parameter": "nospace", 67 | "property-declaration": "nospace", 68 | "variable-declaration": "nospace" 69 | }, 70 | { 71 | "call-signature": "onespace", 72 | "index-signature": "onespace", 73 | "parameter": "onespace", 74 | "property-declaration": "onespace", 75 | "variable-declaration": "onespace" 76 | } 77 | ], 78 | "whitespace": [true, 79 | "check-branch", 80 | "check-decl", 81 | "check-operator", 82 | "check-module", 83 | "check-separator", 84 | "check-type" 85 | ], 86 | 87 | "no-implicit-dependencies": [true, "dev"], 88 | "object-literal-key-quotes": [true, "consistent-as-needed"], 89 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], 90 | 91 | "arrow-parens": false, 92 | "arrow-return-shorthand": false, 93 | "ban-types": false, 94 | "forin": false, 95 | "member-access": false, 96 | "no-conditional-assignment": false, 97 | "no-console": false, 98 | "no-debugger": false, 99 | "no-empty-interface": false, 100 | "no-eval": false, 101 | "no-object-literal-type-assertion": false, 102 | "no-shadowed-variable": false, 103 | "no-submodule-imports": false, 104 | "no-var-requires": false, 105 | "ordered-imports": false, 106 | "prefer-conditional-expression": false, 107 | "radix": false, 108 | "trailing-comma": false, 109 | 110 | "align": false, 111 | "eofline": false, 112 | "max-line-length": false, 113 | "no-consecutive-blank-lines": false, 114 | "space-before-function-paren": false, 115 | 116 | "ban-comma-operator": false, 117 | "max-classes-per-file": false, 118 | "member-ordering": false, 119 | "no-angle-bracket-type-assertion": false, 120 | "no-bitwise": false, 121 | "no-namespace": false, 122 | "no-reference": false, 123 | "object-literal-sort-keys": false, 124 | "one-variable-per-declaration": false 125 | } 126 | } 127 | --------------------------------------------------------------------------------