Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ class ActorSpecificEnvironment implements IActorEnvironment {
return this.multiActorEnvironment.isRequestActive(this, requestIdentifier);
}

getPromiseForInFlightOperation(
requestIdentifier: string,
): Promise<void> | null {
return this.multiActorEnvironment.getPromiseForInFlightOperation(
requestIdentifier,
);
}

isServer(): boolean {
return this.multiActorEnvironment.isServer();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ class MultiActorEnvironment implements IMultiActorEnvironment {
readonly _missingFieldHandlers: ReadonlyArray<MissingFieldHandler>;
readonly _normalizeResponse: NormalizeResponseFunction;
readonly _operationExecutions: Map<string, ActiveState>;
readonly _inFlightOperationCompletions: Map<
string,
{promise: Promise<void>, resolve: () => void},
>;
readonly _operationLoader: ?OperationLoader;
readonly _relayFieldLogger: RelayFieldLogger;
readonly _scheduler: ?TaskScheduler;
Expand All @@ -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;
Expand Down Expand Up @@ -435,6 +440,13 @@ class MultiActorEnvironment implements IMultiActorEnvironment {
return activeState === 'active';
}

getPromiseForInFlightOperation(
requestIdentifier: string,
): Promise<void> | null {
const entry = this._inFlightOperationCompletions.get(requestIdentifier);
return entry?.promise ?? null;
}

isServer(): boolean {
return this._isServer;
}
Expand Down Expand Up @@ -462,6 +474,7 @@ class MultiActorEnvironment implements IMultiActorEnvironment {
isClientPayload,
operation,
operationExecutions: this._operationExecutions,
inFlightOperationCompletions: this._inFlightOperationCompletions,
operationLoader: this._operationLoader,
operationTracker: actorEnvironment.getOperationTracker(),
optimisticConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null;

/**
* Returns `true` if execute in the server environment
*/
Expand Down
42 changes: 42 additions & 0 deletions packages/relay-runtime/query/__tests__/fetchQueryInternal-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
29 changes: 29 additions & 0 deletions packages/relay-runtime/store/OperationExecutor.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export type ExecuteConfig<TMutation extends MutationParameters> = {
readonly isClientPayload?: boolean,
readonly operation: OperationDescriptor,
readonly operationExecutions: Map<string, ActiveState>,
readonly inFlightOperationCompletions: Map<
string,
{promise: Promise<void>, resolve: () => void},
>,
readonly operationLoader: ?OperationLoader,
readonly operationTracker: OperationTracker,
readonly optimisticConfig: ?OptimisticResponseConfig<TMutation>,
Expand Down Expand Up @@ -141,6 +145,10 @@ class Executor<TMutation extends MutationParameters> {
_nextSubscriptionId: number;
_operation: OperationDescriptor;
_operationExecutions: Map<string, ActiveState>;
_inFlightOperationCompletions: Map<
string,
{promise: Promise<void>, resolve: () => void},
>;
_operationLoader: ?OperationLoader;
_operationTracker: OperationTracker;
_operationUpdateEpochs: Map<string, number>;
Expand Down Expand Up @@ -180,6 +188,7 @@ class Executor<TMutation extends MutationParameters> {
isClientPayload,
operation,
operationExecutions,
inFlightOperationCompletions,
operationLoader,
operationTracker,
optimisticConfig,
Expand All @@ -204,6 +213,17 @@ class Executor<TMutation extends MutationParameters> {
this._nextSubscriptionId = 0;
this._operation = operation;
this._operationExecutions = operationExecutions;
this._inFlightOperationCompletions = inFlightOperationCompletions;
if (!inFlightOperationCompletions.has(operation.request.identifier)) {
let resolve: () => void = () => {};
const promise: Promise<void> = new Promise(r => {
resolve = r;
});
inFlightOperationCompletions.set(operation.request.identifier, {
promise,
resolve,
});
}
this._operationLoader = operationLoader;
this._operationTracker = operationTracker;
this._operationUpdateEpochs = new Map();
Expand Down Expand Up @@ -298,6 +318,15 @@ class Executor<TMutation extends MutationParameters> {
}
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());
Expand Down
13 changes: 13 additions & 0 deletions packages/relay-runtime/store/RelayModernEnvironment.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ class RelayModernEnvironment implements IEnvironment {
_treatMissingFieldsAsNull: boolean;
_deferDeduplicatedFields: boolean;
_operationExecutions: Map<string, ActiveState>;
_inFlightOperationCompletions: Map<
string,
{promise: Promise<void>, resolve: () => void},
>;
readonly options: unknown;
readonly _isServer: boolean;
relayFieldLogger: RelayFieldLogger;
Expand Down Expand Up @@ -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 ?? [];
Expand Down Expand Up @@ -191,6 +196,13 @@ class RelayModernEnvironment implements IEnvironment {
return activeState === 'active';
}

getPromiseForInFlightOperation(
requestIdentifier: string,
): Promise<void> | null {
const entry = this._inFlightOperationCompletions.get(requestIdentifier);
return entry?.promise ?? null;
}

UNSTABLE_getDefaultRenderPolicy(): RenderPolicy {
return this._defaultRenderPolicy;
}
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/relay-runtime/store/RelayStoreTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null;

/**
* Returns true if the environment is for use during server side rendering.
* functions like getQueryResource key off of this in order to determine
Expand Down
2 changes: 2 additions & 0 deletions packages/relay-runtime/util/RelayFeatureFlags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/relay-runtime/util/getPendingOperationsForFragment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down