diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
index fb2f33a4bade..8add80c6e0df 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
@@ -655,4 +655,429 @@ describe('ReactDOMFizzShellHydration', () => {
expect(container.innerHTML).toBe('Client');
},
);
+
+ it(
+ 'keeps hydrated content instead of flashing the fallback when the ' +
+ 'post-hydration store patch-up suspends',
+ async () => {
+ const useSyncExternalStore = React.useSyncExternalStore;
+ const Suspense = React.Suspense;
+
+ let resolveClient;
+ const clientPromise = new Promise(res => {
+ resolveClient = res;
+ });
+
+ function subscribe() {
+ return () => {};
+ }
+ function getSnapshot() {
+ return 'Client';
+ }
+ function getServerSnapshot() {
+ return 'Server';
+ }
+
+ function Child({value}) {
+ if (value === 'Client') {
+ // Client data is not ready yet, so the patch-up render suspends.
+ return React.use(clientPromise);
+ }
+ return value;
+ }
+
+ function App() {
+ const value = useSyncExternalStore(
+ subscribe,
+ getSnapshot,
+ getServerSnapshot,
+ );
+ return (
+
+
+
+ );
+ }
+
+ // Server render uses getServerSnapshot and reveals "Server".
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container.textContent).toBe('Server');
+
+ // Hydrate. The mount renders with getServerSnapshot ("Server"), matching
+ // the HTML. The post-hydration passive effect sees getSnapshot ("Client")
+ // differ and schedules the sync patch-up, which suspends inside the
+ // already-revealed boundary.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+
+ if (gate(flags => flags.enableHoldHydratedContentOnStoreSync)) {
+ // The patch-up is delay-able: the hydrated "Server" content stays
+ // visible and the fallback is not committed.
+ expect(container.textContent).toBe('Server');
+ } else {
+ // Today's behavior: the boundary commits its fallback (the hidden
+ // primary subtree remains in the DOM, so the fallback text is present).
+ expect(container.textContent).toContain('Loading');
+ }
+
+ // Once client data resolves, the boundary reveals the client value.
+ await clientAct(async () => {
+ resolveClient('Client');
+ });
+ expect(container.textContent).toBe('Client');
+ },
+ );
+
+ // Regression test: the hydration marker lives on the store instance and is
+ // cleared by a passive effect (updateStoreInstance). StrictMode
+ // double-invokes passive effects on mount, so the keep-content behavior must
+ // survive the marker's owning effect running twice.
+ it(
+ 'keeps hydrated content under StrictMode (double-invoked passive effects ' +
+ 'must not clear the hydration marker before the patch-up consumes it)',
+ async () => {
+ const useSyncExternalStore = React.useSyncExternalStore;
+ const Suspense = React.Suspense;
+ const StrictMode = React.StrictMode;
+
+ let resolveClient;
+ const clientPromise = new Promise(res => {
+ resolveClient = res;
+ });
+
+ function subscribe() {
+ return () => {};
+ }
+ function getSnapshot() {
+ return 'Client';
+ }
+ function getServerSnapshot() {
+ return 'Server';
+ }
+
+ function Child({value}) {
+ if (value === 'Client') {
+ // Client data is not ready yet, so the patch-up render suspends.
+ return React.use(clientPromise);
+ }
+ return value;
+ }
+
+ function App() {
+ const value = useSyncExternalStore(
+ subscribe,
+ getSnapshot,
+ getServerSnapshot,
+ );
+ return (
+
+
+
+ );
+ }
+
+ // Server render uses getServerSnapshot and reveals "Server".
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+ ,
+ );
+ pipe(writable);
+ });
+ expect(container.textContent).toBe('Server');
+
+ // Hydrate under StrictMode. The passive effect that owns the hydration
+ // marker is double-invoked; the marker must not be cleared before the
+ // patch-up render consumes it.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(
+ container,
+
+
+ ,
+ );
+ });
+
+ if (gate(flags => flags.enableHoldHydratedContentOnStoreSync)) {
+ // The patch-up is still delay-able despite the double invoke: the
+ // hydrated "Server" content stays visible.
+ expect(container.textContent).toBe('Server');
+ } else {
+ expect(container.textContent).toContain('Loading');
+ }
+
+ await clientAct(async () => {
+ resolveClient('Client');
+ });
+ expect(container.textContent).toBe('Client');
+ },
+ );
+
+ // The hydration marker must be cleared after the patch-up commits, so a later
+ // steady-state sync store update that suspends still flashes the fallback (it
+ // is NOT delay-able). If the marker leaked, the content would be held instead.
+ it(
+ 'clears the hydration marker so a later steady-state store update flashes ' +
+ 'the fallback',
+ async () => {
+ const useSyncExternalStore = React.useSyncExternalStore;
+ const Suspense = React.Suspense;
+
+ let resolveNext;
+ const nextPromise = new Promise(res => {
+ resolveNext = res;
+ });
+
+ const listeners = new Set();
+ let snapshot = 'Client';
+ const store = {
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ getSnapshot() {
+ return snapshot;
+ },
+ getServerSnapshot() {
+ return 'Server';
+ },
+ set(next) {
+ snapshot = next;
+ listeners.forEach(listener => listener());
+ },
+ };
+
+ function Child({value}) {
+ if (value === 'Next') {
+ // Only the later steady-state value is unresolved, so the patch-up
+ // commits cleanly and the marker is cleared on that commit.
+ return React.use(nextPromise);
+ }
+ return value;
+ }
+
+ function App() {
+ const value = useSyncExternalStore(
+ store.subscribe,
+ store.getSnapshot,
+ store.getServerSnapshot,
+ );
+ return (
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container.textContent).toBe('Server');
+
+ // Patch-up reconciles "Server" -> "Client". "Client" is ready, so it
+ // commits and the hydration marker is cleared.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+ expect(container.textContent).toBe('Client');
+
+ // Steady-state update to an unresolved value: must flash the fallback even
+ // with the flag on, because the hydration marker has been cleared.
+ await clientAct(async () => {
+ store.set('Next');
+ });
+ expect(container.textContent).toContain('Loading');
+
+ await clientAct(async () => {
+ resolveNext('Next');
+ });
+ expect(container.textContent).toBe('Next');
+ },
+ );
+
+ // When the client snapshot matches the server snapshot at hydration there is
+ // no patch-up, so the marker is never armed: a later suspending sync update
+ // flashes the fallback.
+ it(
+ 'does not hold content when the client snapshot matches the server ' +
+ 'snapshot at hydration',
+ async () => {
+ const useSyncExternalStore = React.useSyncExternalStore;
+ const Suspense = React.Suspense;
+
+ let resolveNext;
+ const nextPromise = new Promise(res => {
+ resolveNext = res;
+ });
+
+ const listeners = new Set();
+ let snapshot = 'Same';
+ const store = {
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ getSnapshot() {
+ return snapshot;
+ },
+ getServerSnapshot() {
+ return 'Same';
+ },
+ set(next) {
+ snapshot = next;
+ listeners.forEach(listener => listener());
+ },
+ };
+
+ function Child({value}) {
+ if (value === 'Next') {
+ return React.use(nextPromise);
+ }
+ return value;
+ }
+
+ function App() {
+ const value = useSyncExternalStore(
+ store.subscribe,
+ store.getSnapshot,
+ store.getServerSnapshot,
+ );
+ return (
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container.textContent).toBe('Same');
+
+ // No client/server mismatch, so no patch-up and the marker is never armed.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+ expect(container.textContent).toBe('Same');
+
+ // A later sync update that suspends flashes the fallback.
+ await clientAct(async () => {
+ store.set('Next');
+ });
+ expect(container.textContent).toContain('Loading');
+
+ await clientAct(async () => {
+ resolveNext('Next');
+ });
+ expect(container.textContent).toBe('Next');
+ },
+ );
+
+ // The hold covers the whole post-hydration reconcile window, not just the
+ // first update. If the store updates again before the boundary resolves, the
+ // marker is still armed (it is cleared only once a consistent snapshot
+ // commits), so the later update is held too instead of flashing the fallback.
+ it(
+ 'keeps content across multiple store updates that arrive before the ' +
+ 'patch-up resolves',
+ async () => {
+ const useSyncExternalStore = React.useSyncExternalStore;
+ const Suspense = React.Suspense;
+
+ const resolvers = new Map();
+ function getResolver(key) {
+ let entry = resolvers.get(key);
+ if (entry === undefined) {
+ let resolve;
+ const promise = new Promise(res => {
+ resolve = res;
+ });
+ entry = {promise, resolve};
+ resolvers.set(key, entry);
+ }
+ return entry;
+ }
+
+ const listeners = new Set();
+ let snapshot = 'B';
+ const store = {
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ getSnapshot() {
+ return snapshot;
+ },
+ getServerSnapshot() {
+ return 'A';
+ },
+ set(next) {
+ snapshot = next;
+ listeners.forEach(listener => listener());
+ },
+ };
+
+ function Child({value}) {
+ if (value === 'A') {
+ // The hydrated server value renders directly: the boundary is revealed.
+ return value;
+ }
+ // Every client value suspends until its own promise resolves.
+ return React.use(getResolver(value).promise);
+ }
+
+ function App() {
+ const value = useSyncExternalStore(
+ store.subscribe,
+ store.getSnapshot,
+ store.getServerSnapshot,
+ );
+ return (
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container.textContent).toBe('A');
+
+ // Patch-up reconciles "A" -> "B"; "B" is not ready, so it suspends.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+ if (gate(flags => flags.enableHoldHydratedContentOnStoreSync)) {
+ expect(container.textContent).toBe('A');
+ } else {
+ expect(container.textContent).toContain('Loading');
+ }
+
+ // A second update arrives before "B" resolved. The marker has not been
+ // cleared (no consistent commit yet), so this update is held too.
+ await clientAct(async () => {
+ store.set('C');
+ });
+ if (gate(flags => flags.enableHoldHydratedContentOnStoreSync)) {
+ expect(container.textContent).toBe('A');
+ } else {
+ expect(container.textContent).toContain('Loading');
+ }
+
+ // Resolving the latest value commits it and clears the marker.
+ await clientAct(async () => {
+ getResolver('C').resolve('C');
+ });
+ expect(container.textContent).toBe('C');
+ },
+ );
});
diff --git a/packages/react-reconciler/src/__tests__/useSyncExternalStoreSuspenseFlicker-test.js b/packages/react-reconciler/src/__tests__/useSyncExternalStoreSuspenseFlicker-test.js
new file mode 100644
index 000000000000..ebb72717d9ad
--- /dev/null
+++ b/packages/react-reconciler/src/__tests__/useSyncExternalStoreSuspenseFlicker-test.js
@@ -0,0 +1,145 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+'use strict';
+
+// Scoping guard for the post-hydration useSyncExternalStore keep-content feature
+// (enableHoldHydratedContentOnStoreSync). The feature itself is verified in
+// ReactDOMFizzShellHydration-test.js, which needs real SSR + hydration. Here we
+// lock in the boundary of that feature: a STEADY-STATE (non-hydration) sync store
+// update that re-suspends an already-revealed boundary is NOT delay-able; it
+// still flashes the fallback, with or without the flag.
+
+let React;
+let ReactNoop;
+let Scheduler;
+let act;
+let useSyncExternalStore;
+let Suspense;
+let assertLog;
+let textCache;
+
+describe('useSyncExternalStore Suspense flicker', () => {
+ beforeEach(() => {
+ jest.resetModules();
+
+ React = require('react');
+ ReactNoop = require('react-noop-renderer');
+ Scheduler = require('scheduler');
+ useSyncExternalStore = React.useSyncExternalStore;
+ Suspense = React.Suspense;
+ textCache = new Map();
+
+ const InternalTestUtils = require('internal-test-utils');
+ assertLog = InternalTestUtils.assertLog;
+ act = InternalTestUtils.act;
+ });
+
+ function resolveText(text) {
+ const record = textCache.get(text);
+ if (record === undefined) {
+ textCache.set(text, {status: 'resolved', value: text});
+ } else if (record.status === 'pending') {
+ const thenable = record.value;
+ record.status = 'resolved';
+ record.value = text;
+ thenable.pings.forEach(t => t());
+ }
+ }
+
+ function readText(text) {
+ const record = textCache.get(text);
+ if (record !== undefined) {
+ switch (record.status) {
+ case 'pending':
+ throw record.value;
+ case 'rejected':
+ throw record.value;
+ case 'resolved':
+ return record.value;
+ }
+ } else {
+ const thenable = {
+ pings: [],
+ then(resolve) {
+ if (newRecord.status === 'pending') {
+ thenable.pings.push(resolve);
+ } else {
+ Promise.resolve().then(() => resolve(newRecord.value));
+ }
+ },
+ };
+ const newRecord = {status: 'pending', value: thenable};
+ textCache.set(text, newRecord);
+ throw thenable;
+ }
+ }
+
+ function AsyncText({text}) {
+ const result = readText(text);
+ Scheduler.log(text);
+ return {result};
+ }
+
+ function createExternalStore(initialState) {
+ const listeners = new Set();
+ let currentState = initialState;
+ return {
+ set(text) {
+ currentState = text;
+ ReactNoop.batchedUpdates(() => {
+ listeners.forEach(listener => listener());
+ });
+ },
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ getState() {
+ return currentState;
+ },
+ };
+ }
+
+ // A steady-state (non-hydration) sync store update is NOT delay-able: it still
+ // commits the fallback, hiding the previous content. Only the post-hydration
+ // patch-up keeps content (covered in the react-dom hydration test).
+ it('steady-state sync store update still flashes the fallback', async () => {
+ const store = createExternalStore('A');
+ resolveText('A');
+
+ function App() {
+ const value = useSyncExternalStore(store.subscribe, store.getState);
+ return (
+ Loading}>
+
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => root.render());
+ assertLog(['A']);
+ expect(root).toMatchRenderedOutput(A);
+
+ await act(() => store.set('B'));
+ // Previous content is hidden and the fallback is shown (today's behavior,
+ // preserved for steady-state updates).
+ expect(root).toMatchRenderedOutput(
+ <>
+ A
+ Loading
+ >,
+ );
+
+ await act(() => resolveText('B'));
+ assertLog(['B']);
+ expect(root).toMatchRenderedOutput(B);
+ });
+});