From 183751cf2ef4985b9d54ddf71fb6d1062a865c13 Mon Sep 17 00:00:00 2001 From: Jacob Date: Sat, 7 Dec 2019 15:06:31 -0800 Subject: [PATCH] feat: Add option in context to merge variables object from debounced calls instead of taking the last one --- package.json | 2 + src/DebounceLink.test.ts | 68 +++++++++++++++++++++++++- src/DebounceLink.ts | 103 +++++++++++++++++++++++++++------------ src/TestUtils.ts | 4 +- tsconfig.json | 4 +- yarn.lock | 17 +++++++ 6 files changed, 164 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 30db8a5..16190c9 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "An Apollo Link to debounce requests", "dependencies": { "apollo-link": "^1.2.2", + "lodash.merge": "^4.6.2", "zen-observable-ts": "^0.8.9" }, "devDependencies": { "@types/graphql": "^0.13.0", "@types/jest": "^23.1.4", + "@types/lodash.merge": "^4.6.6", "@types/node": "10.1.2", "codecov": "^3.0.3", "graphql": "^0.13.2", diff --git a/src/DebounceLink.test.ts b/src/DebounceLink.test.ts index 6986e73..ae4ae6f 100644 --- a/src/DebounceLink.test.ts +++ b/src/DebounceLink.test.ts @@ -1,4 +1,4 @@ -import DebounceLink from './DebounceLink'; +import DebounceLink, { DebounceOpts } from './DebounceLink'; import { ObservableEvent, TestSequenceLink, @@ -15,6 +15,7 @@ import { } from 'graphql'; import gql from 'graphql-tag'; +const merge = require('lodash.merge'); describe('DebounceLink', () => { let link: ApolloLink; @@ -56,6 +57,18 @@ describe('DebounceLink', () => { }; } + function makeVariableOp(debounceKey: string, variables: Record, debounceOpts: DebounceOpts = { mergeVariables: true }): GraphQLRequest { + return { + query: gql`{hello}`, + variables, + context: { + debounceKey, + debounceOpts, + testSequence: makeSimpleSequence(testResponse) + } + }; + } + function getTestSubscriber(observedSequence: ObservableEvent[]) { return { next(value: ExecutionResult) { @@ -412,4 +425,57 @@ describe('DebounceLink', () => { s1.unsubscribe(); }); + describe('with variables', () => { + let variables; + let mergeVariables; + + const createAndQueueOps = (contextKey = 'key1') => { + const variableOps = variables.map(v => makeVariableOp(contextKey, v, { mergeVariables })); + const subscriber = getTestSubscriber([]); + variableOps.forEach(vo => execute(link, vo).subscribe(subscriber)); + }; + + const subject = () => { + createAndQueueOps(); + jest.runTimersToTime(DEBOUNCE_TIMEOUT + 1); + }; + + beforeEach(() => { + variables = [{ a: 5, b: { c: 6 } }, { b: { d: 4 } }, { e: 3 }]; + mergeVariables = true; + }); + it('merges the operation variables key with the mergeVariables opt set', () => { + subject(); + + expect(testLink.operations.length).toEqual(1); + expect(testLink.operations[0].variables).toEqual(variables.reduce(merge, {})); + }); + it('does not merge the operation variables when the mergeVariables opt is false', () => { + mergeVariables = false; + subject(); + + expect(testLink.operations.length).toEqual(1); + expect(testLink.operations[0].variables).toEqual(variables.slice(-1)[0]); + }); + it('merges only variables within an interval', () => { + subject(); + + variables = [{ d: 5 }]; + subject(); + + expect(testLink.operations.length).toEqual(2); + expect(testLink.operations[1].variables).toEqual(variables[0]); + }); + it('merges variables with separate debounce keys separately', () => { + createAndQueueOps('key2'); + const mergedVariables = variables.reduce(merge, {}); + variables = [{ d: 5 }]; + subject(); + + expect(testLink.operations.length).toEqual(2); + expect(testLink.operations[0].variables).toEqual(mergedVariables); + expect(testLink.operations[1].variables).toEqual(variables.reduce(merge, {})); + }); + }); }); +}) diff --git a/src/DebounceLink.ts b/src/DebounceLink.ts index 602d75a..a156af5 100644 --- a/src/DebounceLink.ts +++ b/src/DebounceLink.ts @@ -1,10 +1,6 @@ -import { - ApolloLink, - FetchResult, - Operation, - NextLink, -} from 'apollo-link'; +import { ApolloLink, FetchResult, Operation, NextLink, GraphQLRequest, createOperation } from 'apollo-link'; import { Observable, Observer } from 'zen-observable-ts'; +const merge = require('lodash.merge'); interface OperationQueueEntry { operation: Operation; @@ -21,17 +17,29 @@ interface RunningSubscriptions { } interface DebounceMetadata { - // tslint:disable-next-line no-any - timeout: any; - runningSubscriptions: RunningSubscriptions; - queuedObservers: Observer[]; - currentGroupId: number; - lastRequest?: { operation: Operation, forward: NextLink }; + // tslint:disable-next-line no-any + timeout: any; + runningSubscriptions: RunningSubscriptions; + queuedObservers: Observer[]; + currentGroupId: number; + lastRequest?: { operation: Operation; forward: NextLink }; + queuedVariables?: Record[]; } interface ContextOptions { debounceKey: string; debounceTimeout: number; + debounceOpts: DebounceOpts; +} + +export interface DebounceOpts { + mergeVariables: Boolean; +} + +const defaultOpts = { mergeVariables: false }; + +function mutateOperation(ctx: Record, base: Operation): Operation { + return createOperation(ctx, base); } export default class DebounceLink extends ApolloLink { @@ -49,13 +57,20 @@ export default class DebounceLink extends ApolloLink { } public request(operation: Operation, forward: NextLink) { - const { debounceKey, debounceTimeout } = operation.getContext(); + const { + debounceKey, + debounceTimeout, + debounceOpts + } = operation.getContext(); if (!debounceKey) { return forward(operation); } return new Observable(observer => { - const debounceGroupId = this.enqueueRequest({ debounceKey, debounceTimeout }, { operation, forward, observer }); + const debounceGroupId = this.enqueueRequest( + { debounceKey, debounceTimeout, debounceOpts }, + { operation, forward, observer } + ); return () => { this.unsubscribe(debounceKey, debounceGroupId, observer); }; @@ -68,26 +83,38 @@ export default class DebounceLink extends ApolloLink { this.debounceInfo[debounceKey] = { runningSubscriptions: {}, queuedObservers: [], + queuedVariables: [], // NOTE(helfer): In theory we could run out of numbers for groupId, but it's not a realistic use-case. // If the debouncer fired once every ms, it would take about 300,000 years to run out of safe integers. currentGroupId: 0, timeout: undefined, - lastRequest: undefined, + lastRequest: undefined }; return this.debounceInfo[debounceKey]; } // Add a request to the debounce queue - private enqueueRequest({ debounceKey, debounceTimeout }: ContextOptions, { operation, forward, observer }: OperationQueueEntry) { - const dbi = this.debounceInfo[debounceKey] || this.setupDebounceInfo(debounceKey); + private enqueueRequest( + { debounceKey, debounceTimeout, debounceOpts }: ContextOptions, + { operation, forward, observer }: OperationQueueEntry + ) { + const mergedOpts = { ...defaultOpts, ...debounceOpts }; + const dbi = + this.debounceInfo[debounceKey] || this.setupDebounceInfo(debounceKey); dbi.queuedObservers.push(observer); dbi.lastRequest = { operation, forward }; + if (mergedOpts.mergeVariables) { + dbi.queuedVariables.push(operation.variables); + } if (dbi.timeout) { clearTimeout(dbi.timeout); } - dbi.timeout = setTimeout(() => this.flush(debounceKey), debounceTimeout || this.defaultDelay); + dbi.timeout = setTimeout( + () => this.flush(debounceKey), + debounceTimeout || this.defaultDelay + ); return dbi.currentGroupId; } @@ -103,45 +130,62 @@ export default class DebounceLink extends ApolloLink { clearTimeout(dbi.timeout); } - const noRunningSubscriptions = Object.keys(dbi.runningSubscriptions).length === 0; + const noRunningSubscriptions = + Object.keys(dbi.runningSubscriptions).length === 0; const noQueuedObservers = dbi.queuedObservers.length === 0; if (noRunningSubscriptions && noQueuedObservers) { delete this.debounceInfo[debounceKey]; } - } + }; // flush the currently queued requests private flush(debounceKey: string) { const dbi = this.debounceInfo[debounceKey]; - if (dbi.queuedObservers.length === 0 || typeof dbi.lastRequest === 'undefined') { + if ( + dbi.queuedObservers.length === 0 || + typeof dbi.lastRequest === 'undefined' + ) { // The first should never happen, the second is a type guard return; } const { operation, forward } = dbi.lastRequest; + const mergedVariables = [...dbi.queuedVariables, operation.variables].reduce( + merge, + {} + ) as Operation; + const mergedOperation = mutateOperation(operation.getContext(), { ...operation, variables: mergedVariables }); const currentObservers = [...dbi.queuedObservers]; const groupId = dbi.currentGroupId; - const sub = forward(operation).subscribe({ + const sub = forward(mergedOperation).subscribe({ next: (v: FetchResult) => { currentObservers.forEach(observer => observer.next && observer.next(v)); }, error: (e: Error) => { - currentObservers.forEach(observer => observer.error && observer.error(e)); + currentObservers.forEach( + observer => observer.error && observer.error(e) + ); this.cleanup(debounceKey, groupId); }, complete: () => { - currentObservers.forEach(observer => observer.complete && observer.complete()); + currentObservers.forEach( + observer => observer.complete && observer.complete() + ); this.cleanup(debounceKey, groupId); - }, + } }); dbi.runningSubscriptions[dbi.currentGroupId] = { subscription: sub, - observers: currentObservers, + observers: currentObservers }; dbi.queuedObservers = []; dbi.currentGroupId++; } - private unsubscribe = (debounceKey: string, debounceGroupId: number, observer: Observer) => { + private unsubscribe = ( + debounceKey: string, + debounceGroupId: number, + observer: Observer + ) => { // NOTE(helfer): This breaks if the same observer is // used for multiple subscriptions to the same observable. // To be fair, I think all Apollo Links will currently execute the request @@ -152,7 +196,7 @@ export default class DebounceLink extends ApolloLink { // TODO(helfer): Why do subscribers seem to unsubscribe when the subscription completes? // Isn't that unnecessary? - const isNotObserver = (obs: any) => obs !== observer; + const isNotObserver = (obs: any) => obs !== observer; const dbi = this.debounceInfo[debounceKey]; @@ -161,7 +205,6 @@ export default class DebounceLink extends ApolloLink { return; } - // if this observer is in the queue that hasn't been executed yet, remove it if (debounceGroupId === dbi.currentGroupId) { dbi.queuedObservers = dbi.queuedObservers.filter(isNotObserver); @@ -183,5 +226,5 @@ export default class DebounceLink extends ApolloLink { this.cleanup(debounceKey, debounceGroupId); } } - } + }; } diff --git a/src/TestUtils.ts b/src/TestUtils.ts index 5f3b65e..9bab78a 100644 --- a/src/TestUtils.ts +++ b/src/TestUtils.ts @@ -48,7 +48,7 @@ export class TestLink extends ApolloLink { this.operations = []; } - public request (operation: Operation) { + public request(operation: Operation) { this.operations.push(operation); // TODO(helfer): Throw an error if neither testError nor testResponse is defined return new Observable(observer => { @@ -69,7 +69,7 @@ export class TestSequenceLink extends ApolloLink { this.operations = []; } - public request (operation: Operation, forward: NextLink) { + public request(operation: Operation, forward: NextLink) { if (!operation.getContext().testSequence) { return forward(operation); } diff --git a/tsconfig.json b/tsconfig.json index 8426898..ce1241c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,9 @@ "module": "commonjs", "moduleResolution": "node", "noImplicitAny": true, - "lib": ["esnext"], + "lib": [ + "esnext" + ], "removeComments": true, "preserveConstEnums": true, "outDir": "build/dist", diff --git a/yarn.lock b/yarn.lock index a23540b..6bb821f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28,6 +28,18 @@ version "23.1.4" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.1.4.tgz#71e1e2d08b1db742f479ee2795536ebc999a2419" +"@types/lodash.merge@^4.6.6": + version "4.6.6" + resolved "https://registry.yarnpkg.com/@types/lodash.merge/-/lodash.merge-4.6.6.tgz#b84b403c1d31bc42d51772d1cd5557fa008cd3d6" + integrity sha512-IB90krzMf7YpfgP3u/EvZEdXVvm4e3gJbUvh5ieuI+o+XqiNEt6fCzqNRaiLlPVScLI59RxIGZMQ3+Ko/DJ8vQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.149" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440" + integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ== + "@types/node@10.1.2": version "10.1.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.1.2.tgz#1b928a0baa408fc8ae3ac012cc81375addc147c6" @@ -4429,6 +4441,11 @@ lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"