diff --git a/packages/react-relay/relay-hooks/__tests__/useFragment-WithOperationTrackerSuspense-test.js b/packages/react-relay/relay-hooks/__tests__/useFragment-WithOperationTrackerSuspense-test.js index 9cb82d5039f0..f96e8cc04276 100644 --- a/packages/react-relay/relay-hooks/__tests__/useFragment-WithOperationTrackerSuspense-test.js +++ b/packages/react-relay/relay-hooks/__tests__/useFragment-WithOperationTrackerSuspense-test.js @@ -50,6 +50,7 @@ describe('useFragment with Operation Tracker and Suspense behavior', () => { beforeEach(() => { RelayFeatureFlags.ENABLE_OPERATION_TRACKER_OPTIMISTIC_UPDATES = true; RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE = true; + RelayFeatureFlags.ENABLE_IN_FLIGHT_OPERATION_CORRELATION = true; operationTracker = new RelayOperationTracker(); logger = jest.fn<[LogEvent], void>(); environment = createMockEnvironment({ @@ -196,6 +197,7 @@ describe('useFragment with Operation Tracker and Suspense behavior', () => { afterEach(() => { RelayFeatureFlags.ENABLE_OPERATION_TRACKER_OPTIMISTIC_UPDATES = false; RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE = false; + RelayFeatureFlags.ENABLE_IN_FLIGHT_OPERATION_CORRELATION = false; }); it('should throw promise for pending operation affecting fragment owner', async () => { @@ -508,4 +510,91 @@ describe('useFragment with Operation Tracker and Suspense behavior', () => { 'useFragmentWithOperationTrackerSuspenseTestQuery', ); }); + + it('should throw promise while the fragment owner operation is still in flight but has no request-cache entry', async () => { + // Start the operation directly via environment.execute — this creates + // an OperationExecutor (registering it with the in-flight-operation + // registry) without populating fetchQueryDeduped's request cache. That + // matches the state that surfaces mid-stream on incremental responses + // when the request-cache subject has been drained. + environment.execute({operation: nodeOperation}).subscribe({}); + + const fragmentRef = { + __id: 'user-id-1', + __fragments: { + useFragmentWithOperationTrackerSuspenseTestFragment: {}, + }, + __fragmentOwner: nodeOperation.request, + }; + + const renderer = await render({userRef: fragmentRef}); + expect(renderer?.container.textContent).toBe('Singular Fallback'); + + await act(() => { + environment.mock.nextValue(nodeOperation, { + data: { + node: { + __typename: 'User', + id: 'user-id-1', + name: 'Alice', + }, + }, + }); + environment.mock.complete(nodeOperation.request.node); + }); + + expect(renderer?.container.textContent).toBe('Alice'); + }); + + it('should throw promise via in-flight correlation even when isRequestActive returns false', async () => { + environment.execute({operation: nodeOperation}).subscribe({}); + + const realIsRequestActive = + environment.isRequestActive.bind(environment); + const isRequestActiveSpy = jest + .spyOn(environment, 'isRequestActive') + .mockImplementation(id => { + if (id === nodeOperation.request.identifier) { + return false; + } + return realIsRequestActive(id); + }); + + expect( + environment.getPromiseForInFlightOperation( + nodeOperation.request.identifier, + ), + ).not.toBeNull(); + expect( + environment.isRequestActive(nodeOperation.request.identifier), + ).toBe(false); + + const fragmentRef = { + __id: 'user-id-1', + __fragments: { + useFragmentWithOperationTrackerSuspenseTestFragment: {}, + }, + __fragmentOwner: nodeOperation.request, + }; + + const renderer = await render({userRef: fragmentRef}); + expect(renderer?.container.textContent).toBe('Singular Fallback'); + + isRequestActiveSpy.mockRestore(); + + await act(() => { + environment.mock.nextValue(nodeOperation, { + data: { + node: { + __typename: 'User', + id: 'user-id-1', + name: 'Alice', + }, + }, + }); + environment.mock.complete(nodeOperation.request.node); + }); + + expect(renderer?.container.textContent).toBe('Alice'); + }); }); diff --git a/packages/relay-runtime/multi-actor-environment/ActorSpecificEnvironment.js b/packages/relay-runtime/multi-actor-environment/ActorSpecificEnvironment.js index 23a16736178d..c6025886d69c 100644 --- a/packages/relay-runtime/multi-actor-environment/ActorSpecificEnvironment.js +++ b/packages/relay-runtime/multi-actor-environment/ActorSpecificEnvironment.js @@ -226,6 +226,14 @@ class ActorSpecificEnvironment implements IActorEnvironment { return this.multiActorEnvironment.isRequestActive(this, requestIdentifier); } + getPromiseForInFlightOperation( + requestIdentifier: string, + ): Promise | null { + return this.multiActorEnvironment.getPromiseForInFlightOperation( + requestIdentifier, + ); + } + isServer(): boolean { return this.multiActorEnvironment.isServer(); } diff --git a/packages/relay-runtime/multi-actor-environment/MultiActorEnvironment.js b/packages/relay-runtime/multi-actor-environment/MultiActorEnvironment.js index f897edc7d9ee..41ec545bb46a 100644 --- a/packages/relay-runtime/multi-actor-environment/MultiActorEnvironment.js +++ b/packages/relay-runtime/multi-actor-environment/MultiActorEnvironment.js @@ -91,6 +91,10 @@ class MultiActorEnvironment implements IMultiActorEnvironment { readonly _missingFieldHandlers: ReadonlyArray; readonly _normalizeResponse: NormalizeResponseFunction; readonly _operationExecutions: Map; + readonly _inFlightOperationCompletions: Map< + string, + {promise: Promise, resolve: () => void}, + >; readonly _operationLoader: ?OperationLoader; readonly _relayFieldLogger: RelayFieldLogger; readonly _scheduler: ?TaskScheduler; @@ -109,6 +113,7 @@ class MultiActorEnvironment implements IMultiActorEnvironment { : RelayDefaultHandlerProvider; this._logFn = config.logFn ?? emptyFunction; this._operationExecutions = new Map(); + this._inFlightOperationCompletions = new Map(); this._relayFieldLogger = config.relayFieldLogger ?? defaultRelayFieldLogger; this._shouldProcessClientComponents = config.shouldProcessClientComponents; this._treatMissingFieldsAsNull = config.treatMissingFieldsAsNull ?? false; @@ -435,6 +440,13 @@ class MultiActorEnvironment implements IMultiActorEnvironment { return activeState === 'active'; } + getPromiseForInFlightOperation( + requestIdentifier: string, + ): Promise | null { + const entry = this._inFlightOperationCompletions.get(requestIdentifier); + return entry?.promise ?? null; + } + isServer(): boolean { return this._isServer; } @@ -462,6 +474,7 @@ class MultiActorEnvironment implements IMultiActorEnvironment { isClientPayload, operation, operationExecutions: this._operationExecutions, + inFlightOperationCompletions: this._inFlightOperationCompletions, operationLoader: this._operationLoader, operationTracker: actorEnvironment.getOperationTracker(), optimisticConfig, diff --git a/packages/relay-runtime/multi-actor-environment/MultiActorEnvironmentTypes.js b/packages/relay-runtime/multi-actor-environment/MultiActorEnvironmentTypes.js index 817e0d768f1b..82691a936d3c 100644 --- a/packages/relay-runtime/multi-actor-environment/MultiActorEnvironmentTypes.js +++ b/packages/relay-runtime/multi-actor-environment/MultiActorEnvironmentTypes.js @@ -255,6 +255,14 @@ export interface IMultiActorEnvironment { requestIdentifier: string, ): boolean; + /** + * Returns a Promise that resolves when the operation with this identifier + * completes, or null if no such operation is currently in flight. + */ + getPromiseForInFlightOperation( + requestIdentifier: string, + ): Promise | null; + /** * Returns `true` if execute in the server environment */ diff --git a/packages/relay-runtime/query/__tests__/fetchQueryInternal-test.js b/packages/relay-runtime/query/__tests__/fetchQueryInternal-test.js index 9f491cd65522..057562e4ce05 100644 --- a/packages/relay-runtime/query/__tests__/fetchQueryInternal-test.js +++ b/packages/relay-runtime/query/__tests__/fetchQueryInternal-test.js @@ -1176,3 +1176,45 @@ describe('getObservableForActiveRequest', () => { }); }); }); + +describe('environment.getPromiseForInFlightOperation', () => { + it('returns null before any fetch has started', () => { + expect( + environment.getPromiseForInFlightOperation(query.request.identifier), + ).toEqual(null); + }); + + it('returns a promise while the operation is in flight and resolves on completion', async () => { + const observer = { + complete: jest.fn<[], unknown>(), + error: jest.fn<[Error], unknown>(), + next: jest.fn<[GraphQLResponse], unknown>(), + unsubscribe: jest.fn<[Subscription], unknown>(), + }; + fetchQuery(environment, query).subscribe(observer); + + const inFlightPromise = environment.getPromiseForInFlightOperation( + query.request.identifier, + ); + expect(inFlightPromise).not.toEqual(null); + + environment.mock.resolve(gqlQuery, response); + + await expect(inFlightPromise).resolves.toBeUndefined(); + }); + + it('returns null after the operation has completed', () => { + const observer = { + complete: jest.fn<[], unknown>(), + error: jest.fn<[Error], unknown>(), + next: jest.fn<[GraphQLResponse], unknown>(), + unsubscribe: jest.fn<[Subscription], unknown>(), + }; + fetchQuery(environment, query).subscribe(observer); + environment.mock.resolve(gqlQuery, response); + + expect( + environment.getPromiseForInFlightOperation(query.request.identifier), + ).toEqual(null); + }); +}); diff --git a/packages/relay-runtime/store/OperationExecutor.js b/packages/relay-runtime/store/OperationExecutor.js index 4340570563e4..3a69b7fb3597 100644 --- a/packages/relay-runtime/store/OperationExecutor.js +++ b/packages/relay-runtime/store/OperationExecutor.js @@ -85,6 +85,10 @@ export type ExecuteConfig = { readonly isClientPayload?: boolean, readonly operation: OperationDescriptor, readonly operationExecutions: Map, + readonly inFlightOperationCompletions: Map< + string, + {promise: Promise, resolve: () => void}, + >, readonly operationLoader: ?OperationLoader, readonly operationTracker: OperationTracker, readonly optimisticConfig: ?OptimisticResponseConfig, @@ -141,6 +145,10 @@ class Executor { _nextSubscriptionId: number; _operation: OperationDescriptor; _operationExecutions: Map; + _inFlightOperationCompletions: Map< + string, + {promise: Promise, resolve: () => void}, + >; _operationLoader: ?OperationLoader; _operationTracker: OperationTracker; _operationUpdateEpochs: Map; @@ -180,6 +188,7 @@ class Executor { isClientPayload, operation, operationExecutions, + inFlightOperationCompletions, operationLoader, operationTracker, optimisticConfig, @@ -204,6 +213,17 @@ class Executor { this._nextSubscriptionId = 0; this._operation = operation; this._operationExecutions = operationExecutions; + this._inFlightOperationCompletions = inFlightOperationCompletions; + if (!inFlightOperationCompletions.has(operation.request.identifier)) { + let resolve: () => void = () => {}; + const promise: Promise = new Promise(r => { + resolve = r; + }); + inFlightOperationCompletions.set(operation.request.identifier, { + promise, + resolve, + }); + } this._operationLoader = operationLoader; this._operationTracker = operationTracker; this._operationUpdateEpochs = new Map(); @@ -298,6 +318,15 @@ class Executor { } this._state = 'completed'; this._operationExecutions.delete(this._operation.request.identifier); + const completion = this._inFlightOperationCompletions.get( + this._operation.request.identifier, + ); + if (completion != null) { + this._inFlightOperationCompletions.delete( + this._operation.request.identifier, + ); + completion.resolve(); + } if (this._subscriptions.size !== 0) { this._subscriptions.forEach(sub => sub.unsubscribe()); diff --git a/packages/relay-runtime/store/RelayModernEnvironment.js b/packages/relay-runtime/store/RelayModernEnvironment.js index 49a8f85729e5..e177a63070c8 100644 --- a/packages/relay-runtime/store/RelayModernEnvironment.js +++ b/packages/relay-runtime/store/RelayModernEnvironment.js @@ -100,6 +100,10 @@ class RelayModernEnvironment implements IEnvironment { _treatMissingFieldsAsNull: boolean; _deferDeduplicatedFields: boolean; _operationExecutions: Map; + _inFlightOperationCompletions: Map< + string, + {promise: Promise, resolve: () => void}, + >; readonly options: unknown; readonly _isServer: boolean; relayFieldLogger: RelayFieldLogger; @@ -137,6 +141,7 @@ class RelayModernEnvironment implements IEnvironment { config.UNSTABLE_defaultRenderPolicy ?? 'partial'; this._operationLoader = operationLoader; this._operationExecutions = new Map(); + this._inFlightOperationCompletions = new Map(); this._network = wrapNetworkWithLogObserver(this, config.network); this._getDataID = config.getDataID ?? defaultGetDataID; this._missingFieldHandlers = config.missingFieldHandlers ?? []; @@ -191,6 +196,13 @@ class RelayModernEnvironment implements IEnvironment { return activeState === 'active'; } + getPromiseForInFlightOperation( + requestIdentifier: string, + ): Promise | null { + const entry = this._inFlightOperationCompletions.get(requestIdentifier); + return entry?.promise ?? null; + } + UNSTABLE_getDefaultRenderPolicy(): RenderPolicy { return this._defaultRenderPolicy; } @@ -505,6 +517,7 @@ class RelayModernEnvironment implements IEnvironment { normalizeResponse: this._normalizeResponse, operation, operationExecutions: this._operationExecutions, + inFlightOperationCompletions: this._inFlightOperationCompletions, operationLoader: this._operationLoader, operationTracker: this._operationTracker, optimisticConfig, diff --git a/packages/relay-runtime/store/RelayStoreTypes.js b/packages/relay-runtime/store/RelayStoreTypes.js index f455df3b8689..c2f98bf78702 100644 --- a/packages/relay-runtime/store/RelayStoreTypes.js +++ b/packages/relay-runtime/store/RelayStoreTypes.js @@ -1097,6 +1097,16 @@ export interface IEnvironment { */ isRequestActive(requestIdentifier: string): boolean; + /** + * Returns a Promise that resolves when the operation with this identifier + * completes, or null if no such operation is currently in flight. Backs + * fragment-to-owner correlation across render passes that momentarily + * drop the underlying request-cache subject. + */ + getPromiseForInFlightOperation( + requestIdentifier: string, + ): Promise | null; + /** * Returns true if the environment is for use during server side rendering. * functions like getQueryResource key off of this in order to determine diff --git a/packages/relay-runtime/util/RelayFeatureFlags.js b/packages/relay-runtime/util/RelayFeatureFlags.js index 297411bddf60..b005becc98d4 100644 --- a/packages/relay-runtime/util/RelayFeatureFlags.js +++ b/packages/relay-runtime/util/RelayFeatureFlags.js @@ -45,6 +45,7 @@ export type FeatureFlags = { // more compatible. ENABLE_LOOSE_SUBSCRIPTION_ATTRIBUTION: boolean, ENABLE_OPERATION_TRACKER_OPTIMISTIC_UPDATES: boolean, + ENABLE_IN_FLIGHT_OPERATION_CORRELATION: boolean, PROCESS_OPTIMISTIC_UPDATE_BEFORE_SUBSCRIPTION: boolean, @@ -123,6 +124,7 @@ const RelayFeatureFlags: FeatureFlags = { ENABLE_NONCOMPLIANT_ERROR_HANDLING_ON_LISTS: false, ENABLE_LOOSE_SUBSCRIPTION_ATTRIBUTION: false, ENABLE_OPERATION_TRACKER_OPTIMISTIC_UPDATES: false, + ENABLE_IN_FLIGHT_OPERATION_CORRELATION: false, ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE: false, PROCESS_OPTIMISTIC_UPDATE_BEFORE_SUBSCRIPTION: false, MARK_RESOLVER_VALUES_AS_CLEAN_AFTER_FRAGMENT_REREAD: false, diff --git a/packages/relay-runtime/util/getPendingOperationsForFragment.js b/packages/relay-runtime/util/getPendingOperationsForFragment.js index a49088dc119d..c4780381a808 100644 --- a/packages/relay-runtime/util/getPendingOperationsForFragment.js +++ b/packages/relay-runtime/util/getPendingOperationsForFragment.js @@ -15,6 +15,7 @@ import type {IEnvironment, RequestDescriptor} from '../store/RelayStoreTypes'; import type {ReaderFragment} from './ReaderNode'; const {getPromiseForActiveRequest} = require('../query/fetchQueryInternal'); +const RelayFeatureFlags = require('./RelayFeatureFlags'); function getPendingOperationsForFragment( environment: IEnvironment, @@ -38,6 +39,19 @@ function getPendingOperationsForFragment( promise = result?.promise ?? null; } + if ( + promise == null && + RelayFeatureFlags.ENABLE_IN_FLIGHT_OPERATION_CORRELATION + ) { + const inFlightPromise = environment.getPromiseForInFlightOperation( + fragmentOwner.identifier, + ); + if (inFlightPromise != null) { + promise = inFlightPromise; + pendingOperations = [fragmentOwner]; + } + } + if (!promise) { return null; }