From 26de1b094e4980d38af3b88b0ce66e8fe983f531 Mon Sep 17 00:00:00 2001 From: Jon Reading Date: Thu, 16 Jul 2026 11:44:15 +0100 Subject: [PATCH 1/2] Track in-flight operations by executor lifetime, not request-cache subscribers Introduce a per-environment map of operation completion promises keyed by request identifier. OperationExecutor writes an entry on construction and resolves + removes it on cancel(). Environment exposes it via getPromiseForInFlightOperation. getPendingOperationsForFragment consults it as a last-resort correlation when neither the fetch-cache subject nor the operation tracker holds a promise. This closes a fragment-to-owner correlation gap on streaming responses where the request-cache subject is drained between render passes while incremental payloads are still being processed. --- ...gment-WithOperationTrackerSuspense-test.js | 37 ++++++++++++++++ .../ActorSpecificEnvironment.js | 8 ++++ .../MultiActorEnvironment.js | 13 ++++++ .../MultiActorEnvironmentTypes.js | 8 ++++ .../__tests__/fetchQueryInternal-test.js | 42 +++++++++++++++++++ .../relay-runtime/store/OperationExecutor.js | 29 +++++++++++++ .../store/RelayModernEnvironment.js | 13 ++++++ .../relay-runtime/store/RelayStoreTypes.js | 10 +++++ .../relay-runtime/util/RelayFeatureFlags.js | 2 + .../util/getPendingOperationsForFragment.js | 15 +++++++ 10 files changed, 177 insertions(+) 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 9cb82d5039f0d..930bddcc8f2ca 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,39 @@ 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'); + }); }); diff --git a/packages/relay-runtime/multi-actor-environment/ActorSpecificEnvironment.js b/packages/relay-runtime/multi-actor-environment/ActorSpecificEnvironment.js index 23a16736178dd..c6025886d69c5 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 f897edc7d9eed..41ec545bb46a9 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 817e0d768f1bb..82691a936d3cb 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 9f491cd655222..057562e4ce056 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 4340570563e46..3a69b7fb3597d 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 49a8f85729e5c..e177a63070c87 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 f455df3b86899..c2f98bf78702d 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 297411bddf604..b005becc98d4e 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 a49088dc119dd..f3aa9fdf159a2 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,20 @@ function getPendingOperationsForFragment( promise = result?.promise ?? null; } + if ( + promise == null && + RelayFeatureFlags.ENABLE_IN_FLIGHT_OPERATION_CORRELATION && + environment.isRequestActive(fragmentOwner.identifier) + ) { + const inFlightPromise = environment.getPromiseForInFlightOperation( + fragmentOwner.identifier, + ); + if (inFlightPromise != null) { + promise = inFlightPromise; + pendingOperations = [fragmentOwner]; + } + } + if (!promise) { return null; } From 1192e91be3b86a5df479af70fa199a7a57ebde75 Mon Sep 17 00:00:00 2001 From: Jon Reading Date: Mon, 20 Jul 2026 11:21:43 +0100 Subject: [PATCH 2/2] Drop redundant isRequestActive gate on in-flight correlation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_operationExecutions` flips to `'inactive'` while `_state === 'loading_final'` (with no pending module payloads) — before the network stream completes and `_inFlightOperationCompletions` is cleared in `cancel()`. Under incremental delivery, fragments reading during that "final-but-not-complete" window fall through to partial-data reads instead of suspending on the still-valid completion promise, defeating this correlation. Gate solely on the completion map entry's presence (via `getPromiseForInFlightOperation`) — populated in the Executor constructor and cleared atomically when `_complete` fires — which IS the correct in-flight signal for this branch. Adds a test in useFragment-WithOperationTrackerSuspense-test.js exercising the mid-stream window where isRequestActive returns false while the completion promise is still valid; the fragment must suspend on that promise. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...gment-WithOperationTrackerSuspense-test.js | 52 +++++++++++++++++++ .../util/getPendingOperationsForFragment.js | 3 +- 2 files changed, 53 insertions(+), 2 deletions(-) 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 930bddcc8f2ca..f96e8cc04276e 100644 --- a/packages/react-relay/relay-hooks/__tests__/useFragment-WithOperationTrackerSuspense-test.js +++ b/packages/react-relay/relay-hooks/__tests__/useFragment-WithOperationTrackerSuspense-test.js @@ -545,4 +545,56 @@ describe('useFragment with Operation Tracker and Suspense behavior', () => { 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/util/getPendingOperationsForFragment.js b/packages/relay-runtime/util/getPendingOperationsForFragment.js index f3aa9fdf159a2..c4780381a8081 100644 --- a/packages/relay-runtime/util/getPendingOperationsForFragment.js +++ b/packages/relay-runtime/util/getPendingOperationsForFragment.js @@ -41,8 +41,7 @@ function getPendingOperationsForFragment( if ( promise == null && - RelayFeatureFlags.ENABLE_IN_FLIGHT_OPERATION_CORRELATION && - environment.isRequestActive(fragmentOwner.identifier) + RelayFeatureFlags.ENABLE_IN_FLIGHT_OPERATION_CORRELATION ) { const inFlightPromise = environment.getPromiseForInFlightOperation( fragmentOwner.identifier,