Skip to content

Commit 508dca3

Browse files
alunyovfacebook-github-bot
authored andcommitted
Add handleMissedUpdates to subscription callback
Summary: I've spent quite some time trying to come up with a unit test for the issue we observed in one of our apps. The problem was with the `setState` function in the RelayStore subscription callback. In specific conditions, such as a specific server response for the refetch query, batched updates in React, and GC runs from Relay, and something else that I couldn't fully figure out, the `useFragment` may return stale data. Unfortunately, I don't have a unit test yet. So, I propose adding a logger function to detect how often we encounter these cases, and maybe there is a specific pattern we can identify that would help us find the root cause of the issue. To detect these cases, we can use the `handleMissedUpdates` function, which will return a new state with the updated snapshot. We would log how often we have missed updates (this also needs to be integrated with the internal logger). I believe that this change will fix the issue, as we would be returning fresh data from the subscription hook. Reviewed By: tyao1 Differential Revision: D49925999 fbshipit-source-id: 07944bb7209ed9f5550261b0549a7287d4e68909
1 parent ecb61fe commit 508dca3

File tree

3 files changed

+156
-3
lines changed

3 files changed

+156
-3
lines changed

packages/react-relay/relay-hooks/__tests__/useFragmentNode-test.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,133 @@ describe.each([
975975
});
976976
});
977977

978+
it('should ignore updates to initially rendered data when fragment pointers change, but still handle updates to the new data', () => {
979+
const Scheduler = require('scheduler');
980+
const YieldChild = (props: any) => {
981+
// NOTE the unstable_yield method will move to the static renderer.
982+
// When React sync runs we need to update this.
983+
Scheduler.log(props.children);
984+
return props.children;
985+
};
986+
const YieldyUserComponent = ({user}: any) => (
987+
<>
988+
<YieldChild>Hey user,</YieldChild>
989+
<YieldChild>{user.name}</YieldChild>
990+
<YieldChild>with id {user.id}!</YieldChild>
991+
</>
992+
);
993+
994+
// Assert initial render
995+
// $FlowFixMe[incompatible-type]
996+
SingularRenderer = YieldyUserComponent;
997+
internalAct(() => {
998+
renderSingularFragment({isConcurrent: true});
999+
});
1000+
expectSchedulerToHaveYielded([
1001+
'Hey user,',
1002+
'Alice',
1003+
['with id ', '1', '!'],
1004+
]);
1005+
assertFragmentResults([
1006+
{
1007+
data: {
1008+
id: '1',
1009+
name: 'Alice',
1010+
profile_picture: null,
1011+
...createFragmentRef('1', singularQuery),
1012+
},
1013+
},
1014+
]);
1015+
1016+
const newVariables = {...singularVariables, id: '200'};
1017+
const newQuery = createOperationDescriptor(
1018+
gqlSingularQuery,
1019+
newVariables,
1020+
);
1021+
internalAct(() => {
1022+
environment.commitPayload(newQuery, {
1023+
node: {
1024+
__typename: 'User',
1025+
id: '200',
1026+
name: 'Foo',
1027+
username: 'userfoo',
1028+
profile_picture: null,
1029+
},
1030+
});
1031+
});
1032+
1033+
internalAct(() => {
1034+
// Pass new fragment ref that points to new ID 200
1035+
setSingularOwner(newQuery);
1036+
1037+
// Flush some of the changes, but don't commit
1038+
expectSchedulerToFlushAndYieldThrough(['Hey user,', 'Foo']);
1039+
1040+
// Trigger an update for initially rendered data and for the new data
1041+
// while second render is in progress
1042+
environment.commitUpdate(store => {
1043+
store.get('1')?.setValue('Alice in Wonderland', 'name');
1044+
store.get('200')?.setValue('Foo Bar', 'name');
1045+
});
1046+
1047+
// Assert the component renders the data from newQuery/newVariables,
1048+
// ignoring any updates triggered while render was in progress.
1049+
const expectedData = {
1050+
data: {
1051+
id: '200',
1052+
name: 'Foo',
1053+
profile_picture: null,
1054+
...createFragmentRef('200', newQuery),
1055+
},
1056+
};
1057+
expectSchedulerToFlushAndYield([
1058+
['with id ', '200', '!'],
1059+
'Hey user,',
1060+
'Foo Bar',
1061+
['with id ', '200', '!'],
1062+
]);
1063+
assertFragmentResults([
1064+
expectedData,
1065+
{
1066+
data: {
1067+
id: '200',
1068+
name: 'Foo Bar',
1069+
profile_picture: null,
1070+
...createFragmentRef('200', newQuery),
1071+
},
1072+
},
1073+
]);
1074+
1075+
// Update latest rendered data
1076+
environment.commitPayload(newQuery, {
1077+
node: {
1078+
__typename: 'User',
1079+
id: '200',
1080+
// Update name
1081+
name: 'Foo Updated',
1082+
username: 'userfoo',
1083+
profile_picture: null,
1084+
},
1085+
});
1086+
expectSchedulerToFlushAndYield([
1087+
'Hey user,',
1088+
'Foo Updated',
1089+
['with id ', '200', '!'],
1090+
]);
1091+
assertFragmentResults([
1092+
{
1093+
data: {
1094+
id: '200',
1095+
// Assert name is updated
1096+
name: 'Foo Updated',
1097+
profile_picture: null,
1098+
...createFragmentRef('200', newQuery),
1099+
},
1100+
},
1101+
]);
1102+
});
1103+
});
1104+
9781105
it('should re-read and resubscribe to fragment when variables change', () => {
9791106
renderSingularFragment();
9801107
assertFragmentResults([

packages/react-relay/relay-hooks/react-cache/useFragmentInternal_REACT_CACHE.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,17 @@ function subscribeToSnapshot(
257257
prevState.kind !== 'singular' ||
258258
prevState.snapshot.selector !== latestSnapshot.selector
259259
) {
260-
return prevState;
260+
const updates = handleMissedUpdates(environment, prevState);
261+
if (updates != null) {
262+
const [dataChanged, nextState] = updates;
263+
environment.__log({
264+
name: 'useFragment.subscription.missedUpdates',
265+
hasDataChanges: dataChanged,
266+
});
267+
return dataChanged ? nextState : prevState;
268+
} else {
269+
return prevState;
270+
}
261271
}
262272
return {
263273
kind: 'singular',
@@ -280,7 +290,17 @@ function subscribeToSnapshot(
280290
prevState.kind !== 'plural' ||
281291
prevState.snapshots[index]?.selector !== latestSnapshot.selector
282292
) {
283-
return prevState;
293+
const updates = handleMissedUpdates(environment, prevState);
294+
if (updates != null) {
295+
const [dataChanged, nextState] = updates;
296+
environment.__log({
297+
name: 'useFragment.subscription.missedUpdates',
298+
hasDataChanges: dataChanged,
299+
});
300+
return dataChanged ? nextState : prevState;
301+
} else {
302+
return prevState;
303+
}
284304
}
285305
const updated = [...prevState.snapshots];
286306
updated[index] = latestSnapshot;

packages/relay-runtime/store/RelayStoreTypes.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,11 @@ export type LiveResolverBatchEndLogEvent = {
714714
+name: 'liveresolver.batch.end',
715715
};
716716

717+
export type UseFragmentSubscriptionMissedUpdates = {
718+
+name: 'useFragment.subscription.missedUpdates',
719+
+hasDataChanges: boolean,
720+
};
721+
717722
export type LogEvent =
718723
| SuspenseFragmentLogEvent
719724
| SuspenseQueryLogEvent
@@ -741,7 +746,8 @@ export type LogEvent =
741746
| StoreNotifySubscriptionLogEvent
742747
| EntrypointRootConsumeLogEvent
743748
| LiveResolverBatchStartLogEvent
744-
| LiveResolverBatchEndLogEvent;
749+
| LiveResolverBatchEndLogEvent
750+
| UseFragmentSubscriptionMissedUpdates;
745751

746752
export type LogFunction = LogEvent => void;
747753
export type LogRequestInfoFunction = mixed => void;

0 commit comments

Comments
 (0)