diff --git a/packages/relay-runtime/store/RelayModernQueryExecutor.js b/packages/relay-runtime/store/RelayModernQueryExecutor.js index 12e1ab09c8916..8dd6ee130495e 100644 --- a/packages/relay-runtime/store/RelayModernQueryExecutor.js +++ b/packages/relay-runtime/store/RelayModernQueryExecutor.js @@ -54,6 +54,7 @@ import type { import type { NormalizationLinkedField, NormalizationSplitOperation, + NormalizationSelectableNode, } from '../util/NormalizationNode'; import type {DataID, Variables} from '../util/RelayRuntimeTypes'; import type {GetDataID} from './RelayResponseNormalizer'; @@ -345,48 +346,7 @@ class Executor { payload, updater, }); - if (payload.moduleImportPayloads && payload.moduleImportPayloads.length) { - const moduleImportPayloads = payload.moduleImportPayloads; - const operationLoader = this._operationLoader; - invariant( - operationLoader, - 'RelayModernEnvironment: Expected an operationLoader to be ' + - 'configured when using `@match`.', - ); - while (moduleImportPayloads.length) { - const moduleImportPayload = moduleImportPayloads.shift(); - const operation = operationLoader.get( - moduleImportPayload.operationReference, - ); - if (operation == null) { - continue; - } - const selector = createNormalizationSelector( - operation, - moduleImportPayload.dataID, - moduleImportPayload.variables, - ); - const modulePayload = normalizeResponse( - {data: moduleImportPayload.data}, - selector, - moduleImportPayload.typeName, - { - getDataID: this._getDataID, - path: moduleImportPayload.path, - request: this._operation.request, - }, - ); - validateOptimisticResponsePayload(modulePayload); - optimisticUpdates.push({ - operation: this._operation, - payload: modulePayload, - updater: null, - }); - if (modulePayload.moduleImportPayloads) { - moduleImportPayloads.push(...modulePayload.moduleImportPayloads); - } - } - } + this._processOptimisticFollowups(payload, optimisticUpdates); } else if (updater) { optimisticUpdates.push({ operation: this._operation, @@ -406,6 +366,109 @@ class Executor { this._publishQueue.run(); } + _processOptimisticFollowups( + payload: RelayResponsePayload, + optimisticUpdates: Array, + ): void { + if (payload.moduleImportPayloads && payload.moduleImportPayloads.length) { + const moduleImportPayloads = payload.moduleImportPayloads; + const operationLoader = this._operationLoader; + invariant( + operationLoader, + 'RelayModernEnvironment: Expected an operationLoader to be ' + + 'configured when using `@match`.', + ); + for (const moduleImportPayload of moduleImportPayloads) { + const operation = operationLoader.get( + moduleImportPayload.operationReference, + ); + if (operation == null) { + this._processAsyncOptimisticModuleImport( + operationLoader, + moduleImportPayload, + ); + } else { + const moduleImportOptimisitcUpdates = this._processOptimisticModuleImport( + operation, + moduleImportPayload, + ); + optimisticUpdates.push(...moduleImportOptimisitcUpdates); + } + } + } + } + + _normalizeModuleImport( + moduleImportPayload: ModuleImportPayload, + operation: NormalizationSelectableNode, + ) { + const selector = createNormalizationSelector( + operation, + moduleImportPayload.dataID, + moduleImportPayload.variables, + ); + return normalizeResponse( + {data: moduleImportPayload.data}, + selector, + moduleImportPayload.typeName, + { + getDataID: this._getDataID, + path: moduleImportPayload.path, + request: this._operation.request, + }, + ); + } + + _processOptimisticModuleImport( + operation: NormalizationSplitOperation, + moduleImportPayload: ModuleImportPayload, + ): $ReadOnlyArray { + const optimisticUpdates = []; + const modulePayload = this._normalizeModuleImport( + moduleImportPayload, + operation, + ); + validateOptimisticResponsePayload(modulePayload); + optimisticUpdates.push({ + operation: this._operation, + payload: modulePayload, + updater: null, + }); + this._processOptimisticFollowups(modulePayload, optimisticUpdates); + return optimisticUpdates; + } + + _processAsyncOptimisticModuleImport( + operationLoader: OperationLoader, + moduleImportPayload: ModuleImportPayload, + ): void { + operationLoader + .load(moduleImportPayload.operationReference) + .then(operation => { + if (operation == null || this._state !== 'started') { + return; + } + const moduleImportOptimisitcUpdates = this._processOptimisticModuleImport( + operation, + moduleImportPayload, + ); + moduleImportOptimisitcUpdates.forEach(update => + this._publishQueue.applyUpdate(update), + ); + if (this._optimisticUpdates == null) { + warning( + false, + 'RelayModernQueryExecutor: Unexpected ModuleImport optimisitc ' + + 'update in operation %s.' + + this._operation.request.node.params.name, + ); + } else { + this._optimisticUpdates.push(...moduleImportOptimisitcUpdates); + this._publishQueue.run(); + } + }); + } + _processResponse(response: GraphQLResponseWithData): void { if (this._optimisticUpdates !== null) { this._optimisticUpdates.forEach(update => @@ -563,20 +626,9 @@ class Executor { moduleImportPayload: ModuleImportPayload, operation: NormalizationSplitOperation, ): void { - const selector = createNormalizationSelector( + const relayPayload = this._normalizeModuleImport( + moduleImportPayload, operation, - moduleImportPayload.dataID, - moduleImportPayload.variables, - ); - const relayPayload = normalizeResponse( - {data: moduleImportPayload.data}, - selector, - moduleImportPayload.typeName, - { - getDataID: this._getDataID, - path: moduleImportPayload.path, - request: this._operation.request, - }, ); this._publishQueue.commitPayload(this._operation, relayPayload); const updatedOwners = this._publishQueue.run(); diff --git a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteMutationWithMatch-test.js b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteMutationWithMatch-test.js index 10b26e337ebf0..2112fd8e87f25 100644 --- a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteMutationWithMatch-test.js +++ b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteMutationWithMatch-test.js @@ -513,14 +513,14 @@ describe('executeMutation() with @match', () => { ).toBe(null); }); - it('optimistically creates @match fields', () => { + describe('optimistic updates', () => { const optimisticResponse = { commentCreate: { comment: { id: commentID, actor: { id: '4', - name: 'actor-name', + name: 'optimisitc-actor-name', __typename: 'User', nameRenderer: { __typename: 'MarkdownUserNameRenderer', @@ -530,64 +530,259 @@ describe('executeMutation() with @match', () => { 'MarkdownUserNameRenderer_name$normalization.graphql', markdown: 'markdown payload', data: { - markup: '', // server data is lowercase + markup: '', // server data is lowercase }, }, }, }, }, }; - operationLoader.get.mockImplementationOnce(name => { - return markdownRendererNormalizationFragment; + + it('optimistically creates @match fields', () => { + operationLoader.get.mockImplementationOnce(name => { + return markdownRendererNormalizationFragment; + }); + environment + .executeMutation({operation, optimisticResponse}) + .subscribe(callbacks); + jest.runAllTimers(); + + expect(next.mock.calls.length).toBe(0); + expect(complete).not.toBeCalled(); + expect(error.mock.calls.map(call => call[0].message)).toEqual([]); + expect(operationCallback).toBeCalledTimes(1); + const operationSnapshot = operationCallback.mock.calls[0][0]; + expect(operationSnapshot.isMissingData).toBe(false); + expect(operationSnapshot.data).toEqual({ + commentCreate: { + comment: { + actor: { + name: 'optimisitc-actor-name', + nameRenderer: { + __id: + 'client:4:nameRenderer(supported:["PlainUserNameRenderer","MarkdownUserNameRenderer"])', + __fragmentPropName: 'name', + __fragments: { + MarkdownUserNameRenderer_name: {}, + }, + __fragmentOwner: operation.request, + __module_component: 'MarkdownUserNameRenderer.react', + }, + }, + }, + }, + }); + operationCallback.mockClear(); + + const matchSelector = nullthrows( + getSingularSelector( + markdownRendererFragment, + (operationSnapshot.data: any)?.commentCreate?.comment?.actor + ?.nameRenderer, + ), + ); + const initialMatchSnapshot = environment.lookup(matchSelector); + expect(initialMatchSnapshot.isMissingData).toBe(false); + expect(initialMatchSnapshot.data).toEqual({ + __typename: 'MarkdownUserNameRenderer', + data: { + // NOTE: should be uppercased by the MarkupHandler + markup: '', + }, + markdown: 'markdown payload', + }); }); - environment - .executeMutation({operation, optimisticResponse}) - .subscribe(callbacks); - jest.runAllTimers(); - expect(next.mock.calls.length).toBe(0); - expect(complete).not.toBeCalled(); - expect(error.mock.calls.map(call => call[0].message)).toEqual([]); - expect(operationCallback).toBeCalledTimes(1); - const operationSnapshot = operationCallback.mock.calls[0][0]; - expect(operationSnapshot.isMissingData).toBe(false); - expect(operationSnapshot.data).toEqual({ - commentCreate: { - comment: { - actor: { - name: 'actor-name', - nameRenderer: { - __id: - 'client:4:nameRenderer(supported:["PlainUserNameRenderer","MarkdownUserNameRenderer"])', - __fragmentPropName: 'name', - __fragments: { - MarkdownUserNameRenderer_name: {}, + it('optimistically creates @match fields and loads resources', () => { + operationLoader.load.mockImplementationOnce(() => { + return new Promise(resolve => { + setImmediate(() => { + resolve(markdownRendererNormalizationFragment); + }); + }); + }); + environment + .executeMutation({operation, optimisticResponse}) + .subscribe(callbacks); + jest.runAllTimers(); + + expect(next.mock.calls.length).toBe(0); + expect(complete).not.toBeCalled(); + expect(error.mock.calls.map(call => call[0].message)).toEqual([]); + expect(operationCallback).toBeCalledTimes(1); + const operationSnapshot = operationCallback.mock.calls[0][0]; + expect(operationSnapshot.isMissingData).toBe(false); + expect(operationSnapshot.data).toEqual({ + commentCreate: { + comment: { + actor: { + name: 'optimisitc-actor-name', + nameRenderer: { + __id: + 'client:4:nameRenderer(supported:["PlainUserNameRenderer","MarkdownUserNameRenderer"])', + __fragmentPropName: 'name', + __fragments: { + MarkdownUserNameRenderer_name: {}, + }, + __fragmentOwner: operation.request, + __module_component: 'MarkdownUserNameRenderer.react', }, - __fragmentOwner: operation.request, - __module_component: 'MarkdownUserNameRenderer.react', }, }, }, - }, + }); + operationCallback.mockClear(); + + const matchSelector = nullthrows( + getSingularSelector( + markdownRendererFragment, + (operationSnapshot.data: any)?.commentCreate?.comment?.actor + ?.nameRenderer, + ), + ); + const initialMatchSnapshot = environment.lookup(matchSelector); + expect(initialMatchSnapshot.isMissingData).toBe(false); + expect(initialMatchSnapshot.data).toEqual({ + __typename: 'MarkdownUserNameRenderer', + data: { + // NOTE: should be uppercased by the MarkupHandler + markup: '', + }, + markdown: 'markdown payload', + }); }); - operationCallback.mockClear(); - const matchSelector = nullthrows( - getSingularSelector( - markdownRendererFragment, - (operationSnapshot.data: any)?.commentCreate?.comment?.actor - ?.nameRenderer, - ), - ); - const initialMatchSnapshot = environment.lookup(matchSelector); - expect(initialMatchSnapshot.isMissingData).toBe(false); - expect(initialMatchSnapshot.data).toEqual({ - __typename: 'MarkdownUserNameRenderer', - data: { - // NOTE: should be uppercased by the MarkupHandler - markup: '', - }, - markdown: 'markdown payload', + it('does not apply aysnc 3D optimistic updates if the server response arrives first', () => { + operationLoader.load.mockImplementationOnce(() => { + return new Promise(resolve => { + setTimeout(() => { + resolve(markdownRendererNormalizationFragment); + }, 1000); + }); + }); + + environment + .executeMutation({operation, optimisticResponse}) + .subscribe(callbacks); + + const serverPayload = { + data: { + commentCreate: { + comment: { + id: commentID, + actor: { + id: '4', + name: 'actor-name', + __typename: 'User', + nameRenderer: { + __typename: 'MarkdownUserNameRenderer', + __module_component_CreateCommentMutation: + 'MarkdownUserNameRenderer.react', + __module_operation_CreateCommentMutation: + 'MarkdownUserNameRenderer_name$normalization.graphql', + markdown: 'markdown payload', + data: { + markup: '', // server data is lowercase + }, + }, + }, + }, + }, + }, + }; + dataSource.next(serverPayload); + jest.runAllTimers(); + + expect(next.mock.calls.length).toBe(1); + expect(complete).not.toBeCalled(); + expect(error).not.toBeCalled(); + + expect(operationCallback).toBeCalledTimes(2); + const operationSnapshot = operationCallback.mock.calls[1][0]; + expect(operationSnapshot.isMissingData).toBe(false); + expect(operationSnapshot.data).toEqual({ + commentCreate: { + comment: { + actor: { + name: 'actor-name', + nameRenderer: { + __id: + 'client:4:nameRenderer(supported:["PlainUserNameRenderer","MarkdownUserNameRenderer"])', + __fragmentPropName: 'name', + __fragments: { + MarkdownUserNameRenderer_name: {}, + }, + __fragmentOwner: operation.request, + __module_component: 'MarkdownUserNameRenderer.react', + }, + }, + }, + }, + }); + + const matchSelector = nullthrows( + getSingularSelector( + markdownRendererFragment, + (operationSnapshot.data: any)?.commentCreate?.comment?.actor + ?.nameRenderer, + ), + ); + const matchSnapshot = environment.lookup(matchSelector); + // optimistic update should not be applied + expect(matchSnapshot.isMissingData).toBe(true); + expect(matchSnapshot.data).toEqual({ + __typename: 'MarkdownUserNameRenderer', + data: undefined, + markdown: undefined, + }); + }); + + it('does not apply async 3D optimistic updates if the operation is cancelled', () => { + operationLoader.load.mockImplementationOnce(() => { + return new Promise(resolve => { + setTimeout(() => { + resolve(markdownRendererNormalizationFragment); + }, 1000); + }); + }); + const disposable = environment + .executeMutation({operation, optimisticResponse}) + .subscribe(callbacks); + disposable.unsubscribe(); + + jest.runAllImmediates(); + jest.runAllTimers(); + + expect(next).not.toBeCalled(); + expect(complete).not.toBeCalled(); + expect(error).not.toBeCalled(); + expect(operationCallback).toBeCalledTimes(2); + // get the match snapshot from sync optimistic response + const operationSnapshot = operationCallback.mock.calls[0][0]; + expect(operationSnapshot.isMissingData).toBe(false); + const matchSelector = nullthrows( + getSingularSelector( + markdownRendererFragment, + (operationSnapshot.data: any)?.commentCreate?.comment?.actor + ?.nameRenderer, + ), + ); + const matchSnapshot = environment.lookup(matchSelector); + // optimistic update should not be applied + expect(matchSnapshot.isMissingData).toBe(true); + expect(matchSnapshot.data).toEqual(undefined); + }); + + it('catches error when opeartionLoader.load fails synchoronously', () => { + operationLoader.load.mockImplementationOnce(() => { + throw new Error(''); + }); + environment + .executeMutation({operation, optimisticResponse}) + .subscribe(callbacks); + jest.runAllTimers(); + expect(error.mock.calls.length).toBe(1); + expect(error.mock.calls[0][0]).toEqual(new Error('')); }); }); });