Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bugfix: useSyncExternalStore() seems to be not solid in avoiding tearing #27180

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 12 additions & 13 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1617,21 +1617,20 @@ function updateSyncExternalStore<T>(
createEffectInstance(),
null,
);
}
// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
// stores were mutated.
const root: FiberRoot | null = getWorkInProgressRoot();
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just moved the consistency check out from the if condition, nothing more.


// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
// stores were mutated.
const root: FiberRoot | null = getWorkInProgressRoot();

if (root === null) {
throw new Error(
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
);
}
if (root === null) {
throw new Error(
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
);
}

if (!isHydrating && !includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}
if (!isHydrating && !includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}

return nextSnapshot;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ let Scheduler;
let act;
let useLayoutEffect;
let forwardRef;
let useEffect;
let useImperativeHandle;
let useRef;
let useState;
Expand All @@ -39,6 +40,7 @@ describe('useSyncExternalStore', () => {
useLayoutEffect = React.useLayoutEffect;
useImperativeHandle = React.useImperativeHandle;
forwardRef = React.forwardRef;
useEffect = React.useEffect;
useRef = React.useRef;
useState = React.useState;
useSyncExternalStore = React.useSyncExternalStore;
Expand Down Expand Up @@ -67,6 +69,9 @@ describe('useSyncExternalStore', () => {
listeners.forEach(listener => listener());
});
},
setWithoutEmit(text) {
currentState = text;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
Expand Down Expand Up @@ -184,6 +189,94 @@ describe('useSyncExternalStore', () => {
},
);

test(
'detects interleaved mutations(without emitting) during a concurrent read before ' +
'layout effects fire',
async () => {
const store1 = createExternalStore(0);

const Child = forwardRef(({store, label}, ref) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
useImperativeHandle(
ref,
() => {
return value;
},
[value],
);
return <Text text={label + value} />;
});

function App({store}) {
// eslint-disabxle-next-line no-unused-vars
const [, setCount] = useState(0);
const refA = useRef(null);
const refB = useRef(null);
const refC = useRef(null);
useLayoutEffect(() => {
// This layout effect reads children that depend on an external store.
// This demostrates whether the children are consistent when the
// layout phase runs.
const aText = refA.current;
const bText = refB.current;
const cText = refC.current;
Scheduler.log(
`Children observed during layout: A${aText}B${bText}C${cText}`,
);
});

// trigger re-render in concurrent mode
useEffect(() => {
startTransition(() => {
setCount(count => count + 1);
});
}, []);

return (
<>
<Child store={store} ref={refA} label="A" />
<Child store={store} ref={refB} label="B" />
<Child store={store} ref={refC} label="C" />
</>
);
}

const root = ReactNoop.createRoot();
await act(async () => {
// Start a mount in legacy mode
root.render(<App store={store1} />);

await waitFor([
'A0',
'B0',
'C0',
'Children observed during layout: A0B0C0',
'A0', // re-render starts
'B0',
]);

// During an interleaved event, the store is mutated without emitting
store1.setWithoutEmit(1);

// Then we continue rendering.
await waitForAll([
// C reads a newer value from the store than A or B, which means they
// are inconsistent.
'C1',

// Before committing the layout effects, React detects that the store
// has been mutated. So it throws out the entire completed tree and
// re-renders the new values.
'A1',
'B1',
'C1',
// The layout effects reads consistent children.
'Children observed during layout: A1B1C1',
]);
});
},
);

test('next value is correctly cached when state is dispatched in render phase', async () => {
const store = createExternalStore('value:initial');

Expand Down