.',
@@ -1664,7 +1630,7 @@ describe('ReactDOMServerHooks', () => {
);
});
- it('useOpaqueIdentifier throws if you try to use the result as a string in a child component wrapped in a Suspense', async () => {
+ it('useOpaqueIdentifier warns if you try to use the result as a string in a child component wrapped in a Suspense', async () => {
function Child({appId}) {
return
;
}
@@ -1686,27 +1652,13 @@ describe('ReactDOMServerHooks', () => {
,
);
- if (gate(flags => flags.new)) {
- expect(() => Scheduler.unstable_flushAll()).toErrorDev([
- 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
- 'Do not read the value directly.',
- ]);
- } else {
- // In the old reconciler, the error isn't surfaced to the user. That
- // part isn't important, as long as It warns.
- expect(() =>
- expect(() => Scheduler.unstable_flushAll()).toThrow(
- 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
- 'Do not read the value directly.',
- ),
- ).toErrorDev([
- 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
- 'Do not read the value directly.',
- ]);
- }
+ expect(() => Scheduler.unstable_flushAll()).toErrorDev([
+ 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
+ 'Do not read the value directly.',
+ ]);
});
- it('useOpaqueIdentifier throws if you try to add the result as a number in a child component wrapped in a Suspense', async () => {
+ it('useOpaqueIdentifier warns if you try to add the result as a number in a child component wrapped in a Suspense', async () => {
function Child({appId}) {
return
;
}
@@ -1730,24 +1682,10 @@ describe('ReactDOMServerHooks', () => {
,
);
- if (gate(flags => flags.new)) {
- expect(() => Scheduler.unstable_flushAll()).toErrorDev([
- 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
- 'Do not read the value directly.',
- ]);
- } else {
- // In the old reconciler, the error isn't surfaced to the user. That
- // part isn't important, as long as It warns.
- expect(() =>
- expect(() => Scheduler.unstable_flushAll()).toThrow(
- 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
- 'Do not read the value directly.',
- ),
- ).toErrorDev([
- 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
- 'Do not read the value directly.',
- ]);
- }
+ expect(() => Scheduler.unstable_flushAll()).toErrorDev([
+ 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
+ 'Do not read the value directly.',
+ ]);
});
it('useOpaqueIdentifier with two opaque identifiers on the same page', () => {
diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js
index 3c29c2e86a66e..34a3223a0cd6e 100644
--- a/packages/react-dom/src/client/ReactDOMRoot.js
+++ b/packages/react-dom/src/client/ReactDOMRoot.js
@@ -9,9 +9,8 @@
import type {Container} from './ReactDOMHostConfig';
import type {RootTag} from 'react-reconciler/src/ReactRootTags';
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {MutableSource, ReactNodeList} from 'shared/ReactTypes';
import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
-import {findHostInstanceWithNoPortals} from 'react-reconciler/src/ReactFiberReconciler';
export type RootType = {
render(children: ReactNodeList): void,
@@ -30,6 +29,8 @@ export type RootOptions = {
...
};
+import {findHostInstanceWithNoPortals} from 'react-reconciler/src/ReactFiberReconciler';
+import {registerMutableSourceForHydration} from 'react-reconciler/src/ReactMutableSource';
import {
isContainerMarkedAsRoot,
markContainerAsRoot,
@@ -115,6 +116,13 @@ ReactDOMRoot.prototype.unmount = ReactDOMBlockingRoot.prototype.unmount = functi
});
};
+ReactDOMRoot.prototype.registerMutableSourceForHydration = ReactDOMBlockingRoot.prototype.registerMutableSourceForHydration = function(
+ mutableSource: MutableSource
,
+): void {
+ const root = this._internalRoot;
+ registerMutableSourceForHydration(root, mutableSource);
+};
+
function createRootImpl(
container: Container,
tag: RootTag,
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js
index c7b9e24ceac6d..5459e297b5901 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js
@@ -13,6 +13,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
import type {Fiber} from './ReactInternalTypes';
import type {FiberRoot} from './ReactInternalTypes';
import type {Lanes, Lane} from './ReactFiberLane';
+import type {MutableSource} from 'shared/ReactTypes';
import type {
SuspenseState,
SuspenseListRenderState,
@@ -197,8 +198,12 @@ import {
markSkippedUpdateLanes,
getWorkInProgressRoot,
pushRenderLanes,
+ getExecutionContext,
+ RetryAfterError,
+ NoContext,
} from './ReactFiberWorkLoop.new';
import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
+import {setWorkInProgressVersion} from './ReactMutableSource.new';
import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
@@ -1061,6 +1066,16 @@ function updateHostRoot(current, workInProgress, renderLanes) {
// be any children to hydrate which is effectively the same thing as
// not hydrating.
+ const mutableSourceEagerHydrationData =
+ root.mutableSourceEagerHydrationData;
+ for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) {
+ const mutableSource = ((mutableSourceEagerHydrationData[
+ i
+ ]: any): MutableSource);
+ const version = mutableSourceEagerHydrationData[i + 1];
+ setWorkInProgressVersion(mutableSource, version);
+ }
+
const child = mountChildFibers(
workInProgress,
null,
@@ -2263,6 +2278,14 @@ function updateDehydratedSuspenseComponent(
// but after we've already committed once.
warnIfHydrating();
+ if ((getExecutionContext() & RetryAfterError) !== NoContext) {
+ return retrySuspenseComponentWithoutHydrating(
+ current,
+ workInProgress,
+ renderLanes,
+ );
+ }
+
if ((workInProgress.mode & BlockingMode) === NoMode) {
return retrySuspenseComponentWithoutHydrating(
current,
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js
index 0a3a4e5a47317..f7179fe353d0a 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js
@@ -13,6 +13,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
import type {Fiber} from './ReactInternalTypes';
import type {FiberRoot} from './ReactInternalTypes';
import type {ExpirationTime} from './ReactFiberExpirationTime.old';
+import type {MutableSource} from 'shared/ReactTypes';
import type {
SuspenseState,
SuspenseListRenderState,
@@ -179,8 +180,12 @@ import {
renderDidSuspendDelayIfPossible,
markUnprocessedUpdateTime,
getWorkInProgressRoot,
+ getExecutionContext,
+ RetryAfterError,
+ NoContext,
} from './ReactFiberWorkLoop.old';
import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
+import {setWorkInProgressVersion} from './ReactMutableSource.old';
import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
@@ -1038,6 +1043,16 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) {
// be any children to hydrate which is effectively the same thing as
// not hydrating.
+ const mutableSourceEagerHydrationData =
+ root.mutableSourceEagerHydrationData;
+ for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) {
+ const mutableSource = ((mutableSourceEagerHydrationData[
+ i
+ ]: any): MutableSource);
+ const version = mutableSourceEagerHydrationData[i + 1];
+ setWorkInProgressVersion(mutableSource, version);
+ }
+
const child = mountChildFibers(
workInProgress,
null,
@@ -2239,6 +2254,14 @@ function updateDehydratedSuspenseComponent(
// but after we've already committed once.
warnIfHydrating();
+ if ((getExecutionContext() & RetryAfterError) !== NoContext) {
+ return retrySuspenseComponentWithoutHydrating(
+ current,
+ workInProgress,
+ renderExpirationTime,
+ );
+ }
+
if ((workInProgress.mode & BlockingMode) === NoMode) {
return retrySuspenseComponentWithoutHydrating(
current,
diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js
index b3fc36d51724a..6ee88ea9241ee 100644
--- a/packages/react-reconciler/src/ReactFiberRoot.new.js
+++ b/packages/react-reconciler/src/ReactFiberRoot.new.js
@@ -41,9 +41,10 @@ function FiberRootNode(containerInfo, tag, hydrate) {
this.pingedLanes = NoLanes;
this.expiredLanes = NoLanes;
this.mutableReadLanes = NoLanes;
-
this.finishedLanes = NoLanes;
+ this.mutableSourceEagerHydrationData = [];
+
if (enableSchedulerTracing) {
this.interactionThreadID = unstable_getThreadID();
this.memoizedInteractions = new Set();
diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js
index 60339c86e8626..e972e076b6211 100644
--- a/packages/react-reconciler/src/ReactFiberRoot.old.js
+++ b/packages/react-reconciler/src/ReactFiberRoot.old.js
@@ -45,6 +45,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
this.lastPingedTime = NoWork;
this.lastExpiredTime = NoWork;
this.mutableSourceLastPendingUpdateTime = NoWork;
+ this.mutableSourceEagerHydrationData = [];
if (enableSchedulerTracing) {
this.interactionThreadID = unstable_getThreadID();
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
index e2ff1032b8287..5c6c5bf719de0 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
@@ -61,6 +61,7 @@ import {
warnsIfNotActing,
beforeActiveInstanceBlur,
afterActiveInstanceBlur,
+ clearContainer,
} from './ReactFiberHostConfig';
import {
@@ -210,13 +211,14 @@ const {
type ExecutionContext = number;
-const NoContext = /* */ 0b000000;
-const BatchedContext = /* */ 0b000001;
-const EventContext = /* */ 0b000010;
-const DiscreteEventContext = /* */ 0b000100;
-const LegacyUnbatchedContext = /* */ 0b001000;
-const RenderContext = /* */ 0b010000;
-const CommitContext = /* */ 0b100000;
+export const NoContext = /* */ 0b0000000;
+const BatchedContext = /* */ 0b0000001;
+const EventContext = /* */ 0b0000010;
+const DiscreteEventContext = /* */ 0b0000100;
+const LegacyUnbatchedContext = /* */ 0b0001000;
+const RenderContext = /* */ 0b0010000;
+const CommitContext = /* */ 0b0100000;
+export const RetryAfterError = /* */ 0b1000000;
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
const RootIncomplete = 0;
@@ -703,6 +705,15 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
prepareFreshStack(root, NoLanes);
} else if (exitStatus !== RootIncomplete) {
if (exitStatus === RootErrored) {
+ executionContext |= RetryAfterError;
+
+ // If an error occurred during hydration,
+ // discard server response and fall back to client side render.
+ if (root.hydrate) {
+ root.hydrate = false;
+ clearContainer(root.containerInfo);
+ }
+
// If something threw an error, try rendering one more time. We'll render
// synchronously to block concurrent data mutations, and we'll includes
// all pending updates are included. If it still fails after the second
@@ -953,6 +964,15 @@ function performSyncWorkOnRoot(root) {
}
if (root.tag !== LegacyRoot && exitStatus === RootErrored) {
+ executionContext |= RetryAfterError;
+
+ // If an error occurred during hydration,
+ // discard server response and fall back to client side render.
+ if (root.hydrate) {
+ root.hydrate = false;
+ clearContainer(root.containerInfo);
+ }
+
// If something threw an error, try rendering one more time. We'll render
// synchronously to block concurrent data mutations, and we'll includes
// all pending updates are included. If it still fails after the second
@@ -993,6 +1013,10 @@ export function flushRoot(root: FiberRoot, lanes: Lanes) {
}
}
+export function getExecutionContext(): ExecutionContext {
+ return executionContext;
+}
+
export function flushDiscreteUpdates() {
// TODO: Should be able to flush inside batchedUpdates, but not inside `act`.
// However, `act` uses `batchedUpdates`, so there's no way to distinguish
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
index e76d0bf38b4f3..a6d20d79fad28 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
@@ -74,6 +74,7 @@ import {
warnsIfNotActing,
beforeActiveInstanceBlur,
afterActiveInstanceBlur,
+ clearContainer,
} from './ReactFiberHostConfig';
import {
@@ -207,13 +208,14 @@ const {
type ExecutionContext = number;
-const NoContext = /* */ 0b000000;
-const BatchedContext = /* */ 0b000001;
-const EventContext = /* */ 0b000010;
-const DiscreteEventContext = /* */ 0b000100;
-const LegacyUnbatchedContext = /* */ 0b001000;
-const RenderContext = /* */ 0b010000;
-const CommitContext = /* */ 0b100000;
+export const NoContext = /* */ 0b0000000;
+const BatchedContext = /* */ 0b0000001;
+const EventContext = /* */ 0b0000010;
+const DiscreteEventContext = /* */ 0b0000100;
+const LegacyUnbatchedContext = /* */ 0b0001000;
+const RenderContext = /* */ 0b0010000;
+const CommitContext = /* */ 0b0100000;
+export const RetryAfterError = /* */ 0b1000000;
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
const RootIncomplete = 0;
@@ -728,6 +730,15 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
if (exitStatus !== RootIncomplete) {
if (exitStatus === RootErrored) {
+ executionContext |= RetryAfterError;
+
+ // If an error occurred during hydration,
+ // discard server response and fall back to client side render.
+ if (root.hydrate) {
+ root.hydrate = false;
+ clearContainer(root.containerInfo);
+ }
+
// If something threw an error, try rendering one more time. We'll
// render synchronously to block concurrent data mutations, and we'll
// render at Idle (or lower) so that all pending updates are included.
@@ -1011,6 +1022,15 @@ function performSyncWorkOnRoot(root) {
let exitStatus = renderRootSync(root, expirationTime);
if (root.tag !== LegacyRoot && exitStatus === RootErrored) {
+ executionContext |= RetryAfterError;
+
+ // If an error occurred during hydration,
+ // discard server response and fall back to client side render.
+ if (root.hydrate) {
+ root.hydrate = false;
+ clearContainer(root.containerInfo);
+ }
+
// If something threw an error, try rendering one more time. We'll
// render synchronously to block concurrent data mutations, and we'll
// render at Idle (or lower) so that all pending updates are included.
@@ -1051,6 +1071,10 @@ export function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) {
}
}
+export function getExecutionContext(): ExecutionContext {
+ return executionContext;
+}
+
export function flushDiscreteUpdates() {
// TODO: Should be able to flush inside batchedUpdates, but not inside `act`.
// However, `act` uses `batchedUpdates`, so there's no way to distinguish
diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js
index 2b12735c8c58f..bd929f8f0d8d9 100644
--- a/packages/react-reconciler/src/ReactInternalTypes.js
+++ b/packages/react-reconciler/src/ReactInternalTypes.js
@@ -16,6 +16,7 @@ import type {
ReactContext,
MutableSourceSubscribeFn,
MutableSourceGetSnapshotFn,
+ MutableSourceVersion,
MutableSource,
} from 'shared/ReactTypes';
import type {SuspenseInstance} from './ReactFiberHostConfig';
@@ -248,6 +249,11 @@ type BaseFiberRootProperties = {|
// when external, mutable sources are read from during render.
mutableSourceLastPendingUpdateTime: ExpirationTime,
+ // Used by useMutableSource hook to avoid tearing during hydrtaion.
+ mutableSourceEagerHydrationData: Array<
+ MutableSource | MutableSourceVersion,
+ >,
+
// Only used by new reconciler
// Represents the next task that the root should work on, or the current one
diff --git a/packages/react-reconciler/src/ReactMutableSource.js b/packages/react-reconciler/src/ReactMutableSource.js
new file mode 100644
index 0000000000000..0b48c6183c3fb
--- /dev/null
+++ b/packages/react-reconciler/src/ReactMutableSource.js
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import {enableNewReconciler} from 'shared/ReactFeatureFlags';
+
+// The entry file imports either the old or new version of mutable source.
+// This is necessary since ReactDOMRoot imports this module directly.
+// Note that it's not possible to export all of the API methods,
+// as the new and old implementations fork slightly (due to the lanes refactor).
+// It's only necessary to export the subset of the API required by ReactDOMRoot.
+
+import {registerMutableSourceForHydration as registerMutableSourceForHydration_old} from './ReactMutableSource.old';
+
+import {registerMutableSourceForHydration as registerMutableSourceForHydration_new} from './ReactMutableSource.new';
+
+export const registerMutableSourceForHydration = enableNewReconciler
+ ? registerMutableSourceForHydration_new
+ : registerMutableSourceForHydration_old;
diff --git a/packages/react-reconciler/src/ReactMutableSource.new.js b/packages/react-reconciler/src/ReactMutableSource.new.js
index be7bb4dfdf594..313d3d18fa436 100644
--- a/packages/react-reconciler/src/ReactMutableSource.new.js
+++ b/packages/react-reconciler/src/ReactMutableSource.new.js
@@ -8,6 +8,7 @@
*/
import type {MutableSource, MutableSourceVersion} from 'shared/ReactTypes';
+import type {FiberRoot} from './ReactInternalTypes';
import {isPrimaryRenderer} from './ReactFiberHostConfig';
@@ -95,3 +96,19 @@ export function warnAboutMultipleRenderersDEV(
}
}
}
+
+// Eager reads the version of a mutable source and stores it on the root.
+// This ensures that the version used for server rendering matches the one
+// that is eventually read during hydration.
+// If they don't match there's a potential tear and a full deopt render is required.
+export function registerMutableSourceForHydration(
+ root: FiberRoot,
+ mutableSource: MutableSource,
+): void {
+ const getVersion = mutableSource._getVersion;
+ const version = getVersion(mutableSource._source);
+
+ // TODO Clear this data once all pending hydration work is finished.
+ // Retaining it forever may interfere with GC.
+ root.mutableSourceEagerHydrationData.push(mutableSource, version);
+}
diff --git a/packages/react-reconciler/src/ReactMutableSource.old.js b/packages/react-reconciler/src/ReactMutableSource.old.js
index f0658bea5592a..300b6dc322a00 100644
--- a/packages/react-reconciler/src/ReactMutableSource.old.js
+++ b/packages/react-reconciler/src/ReactMutableSource.old.js
@@ -126,3 +126,19 @@ export function warnAboutMultipleRenderersDEV(
}
}
}
+
+// Eager reads the version of a mutable source and stores it on the root.
+// This ensures that the version used for server rendering matches the one
+// that is eventually read during hydration.
+// If they don't match there's a potential tear and a full deopt render is required.
+export function registerMutableSourceForHydration(
+ root: FiberRoot,
+ mutableSource: MutableSource,
+): void {
+ const getVersion = mutableSource._getVersion;
+ const version = getVersion(mutableSource._source);
+
+ // TODO Clear this data once all pending hydration work is finished.
+ // Retaining it forever may interfere with GC.
+ root.mutableSourceEagerHydrationData.push(mutableSource, version);
+}
diff --git a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js
new file mode 100644
index 0000000000000..229282ad4434c
--- /dev/null
+++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js
@@ -0,0 +1,376 @@
+/**
+ * Copyright (c) Facebook, Inc. and its 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';
+
+let React;
+let ReactDOM;
+let ReactDOMServer;
+let Scheduler;
+let act;
+let useMutableSource;
+
+describe('useMutableSourceHydration', () => {
+ beforeEach(() => {
+ jest.resetModules();
+
+ React = require('react');
+ ReactDOM = require('react-dom');
+ ReactDOMServer = require('react-dom/server');
+ Scheduler = require('scheduler');
+
+ useMutableSource = React.useMutableSource;
+ act = require('react-dom/test-utils').act;
+ });
+
+ const defaultGetSnapshot = source => source.value;
+ const defaultSubscribe = (source, callback) => source.subscribe(callback);
+
+ function createComplexSource(initialValueA, initialValueB) {
+ const callbacksA = [];
+ const callbacksB = [];
+ let revision = 0;
+ let valueA = initialValueA;
+ let valueB = initialValueB;
+
+ const subscribeHelper = (callbacks, callback) => {
+ if (callbacks.indexOf(callback) < 0) {
+ callbacks.push(callback);
+ }
+ return () => {
+ const index = callbacks.indexOf(callback);
+ if (index >= 0) {
+ callbacks.splice(index, 1);
+ }
+ };
+ };
+
+ return {
+ subscribeA(callback) {
+ return subscribeHelper(callbacksA, callback);
+ },
+ subscribeB(callback) {
+ return subscribeHelper(callbacksB, callback);
+ },
+
+ get listenerCountA() {
+ return callbacksA.length;
+ },
+ get listenerCountB() {
+ return callbacksB.length;
+ },
+
+ set valueA(newValue) {
+ revision++;
+ valueA = newValue;
+ callbacksA.forEach(callback => callback());
+ },
+ get valueA() {
+ return valueA;
+ },
+
+ set valueB(newValue) {
+ revision++;
+ valueB = newValue;
+ callbacksB.forEach(callback => callback());
+ },
+ get valueB() {
+ return valueB;
+ },
+
+ get version() {
+ return revision;
+ },
+ };
+ }
+
+ function createSource(initialValue) {
+ const callbacks = [];
+ let revision = 0;
+ let value = initialValue;
+ return {
+ subscribe(callback) {
+ if (callbacks.indexOf(callback) < 0) {
+ callbacks.push(callback);
+ }
+ return () => {
+ const index = callbacks.indexOf(callback);
+ if (index >= 0) {
+ callbacks.splice(index, 1);
+ }
+ };
+ },
+ get listenerCount() {
+ return callbacks.length;
+ },
+ set value(newValue) {
+ revision++;
+ value = newValue;
+ callbacks.forEach(callback => callback());
+ },
+ get value() {
+ return value;
+ },
+ get version() {
+ return revision;
+ },
+ };
+ }
+
+ function createMutableSource(source) {
+ return React.createMutableSource(source, param => param.version);
+ }
+
+ function Component({getSnapshot, label, mutableSource, subscribe}) {
+ const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe);
+ Scheduler.unstable_yieldValue(`${label}:${snapshot}`);
+ return {`${label}:${snapshot}`}
;
+ }
+
+ // @gate experimental
+ it('should render and hydrate', () => {
+ const source = createSource('one');
+ const mutableSource = createMutableSource(source);
+
+ function TestComponent() {
+ return (
+
+ );
+ }
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const htmlString = ReactDOMServer.renderToString();
+ container.innerHTML = htmlString;
+ expect(Scheduler).toHaveYielded(['only:one']);
+ expect(source.listenerCount).toBe(0);
+
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ act(() => {
+ root.registerMutableSourceForHydration(mutableSource);
+ root.render();
+ });
+ expect(Scheduler).toHaveYielded(['only:one']);
+ expect(source.listenerCount).toBe(1);
+ });
+
+ // @gate experimental
+ it('should detect a tear before hydrating a component', () => {
+ const source = createSource('one');
+ const mutableSource = createMutableSource(source);
+
+ function TestComponent() {
+ return (
+
+ );
+ }
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const htmlString = ReactDOMServer.renderToString();
+ container.innerHTML = htmlString;
+ expect(Scheduler).toHaveYielded(['only:one']);
+ expect(source.listenerCount).toBe(0);
+
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ expect(() => {
+ act(() => {
+ root.registerMutableSourceForHydration(mutableSource);
+ root.render();
+
+ source.value = 'two';
+ });
+ }).toErrorDev(
+ 'Warning: Did not expect server HTML to contain a in
.',
+ {withoutStack: true},
+ );
+ expect(Scheduler).toHaveYielded(['only:two']);
+ expect(source.listenerCount).toBe(1);
+ });
+
+ // @gate experimental
+ it('should detect a tear between hydrating components', () => {
+ const source = createSource('one');
+ const mutableSource = createMutableSource(source);
+
+ function TestComponent() {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const htmlString = ReactDOMServer.renderToString(
);
+ container.innerHTML = htmlString;
+ expect(Scheduler).toHaveYielded(['a:one', 'b:one']);
+ expect(source.listenerCount).toBe(0);
+
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ expect(() => {
+ act(() => {
+ root.registerMutableSourceForHydration(mutableSource);
+ root.render(
);
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
+ source.value = 'two';
+ });
+ }).toErrorDev(
+ 'Warning: Did not expect server HTML to contain a
in
.',
+ {withoutStack: true},
+ );
+ expect(Scheduler).toHaveYielded(['a:two', 'b:two']);
+ expect(source.listenerCount).toBe(2);
+ });
+
+ // @gate experimental
+ it('should detect a tear between hydrating components reading from different parts of a source', () => {
+ const source = createComplexSource('a:one', 'b:one');
+ const mutableSource = createMutableSource(source);
+
+ // Subscribe to part of the store.
+ const getSnapshotA = s => s.valueA;
+ const subscribeA = (s, callback) => s.subscribeA(callback);
+ const getSnapshotB = s => s.valueB;
+ const subscribeB = (s, callback) => s.subscribeB(callback);
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const htmlString = ReactDOMServer.renderToString(
+ <>
+
+
+ >,
+ );
+ container.innerHTML = htmlString;
+ expect(Scheduler).toHaveYielded(['0:a:one', '1:b:one']);
+
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ expect(() => {
+ act(() => {
+ root.registerMutableSourceForHydration(mutableSource);
+ root.render(
+ <>
+
+
+ >,
+ );
+ expect(Scheduler).toFlushAndYieldThrough(['0:a:one']);
+ source.valueB = 'b:two';
+ });
+ }).toErrorDev(
+ 'Warning: Did not expect server HTML to contain a
in
.',
+ {withoutStack: true},
+ );
+ expect(Scheduler).toHaveYielded(['0:a:one', '1:b:two']);
+ });
+
+ // @gate experimental
+ it('should detect a tear during a higher priority interruption', () => {
+ const source = createSource('one');
+ const mutableSource = createMutableSource(source);
+
+ function Unrelated({flag}) {
+ Scheduler.unstable_yieldValue(flag);
+ return flag;
+ }
+
+ function TestComponent({flag}) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const htmlString = ReactDOMServer.renderToString(
+ ,
+ );
+ container.innerHTML = htmlString;
+ expect(Scheduler).toHaveYielded([1, 'a:one']);
+ expect(source.listenerCount).toBe(0);
+
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ expect(() => {
+ act(() => {
+ root.registerMutableSourceForHydration(mutableSource);
+ root.render();
+ expect(Scheduler).toFlushAndYieldThrough([1]);
+
+ // Render an update which will be higher priority than the hydration.
+ Scheduler.unstable_runWithPriority(
+ Scheduler.unstable_UserBlockingPriority,
+ () => root.render(),
+ );
+ expect(Scheduler).toFlushAndYieldThrough([2]);
+
+ source.value = 'two';
+ });
+ }).toErrorDev(
+ 'Warning: Text content did not match. Server: "1" Client: "2"',
+ );
+ expect(Scheduler).toHaveYielded([2, 'a:two']);
+ expect(source.listenerCount).toBe(1);
+ });
+});
diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js
index 9a4af65cf0c59..7f525dee4891e 100644
--- a/scripts/jest/setupHostConfigs.js
+++ b/scripts/jest/setupHostConfigs.js
@@ -10,6 +10,14 @@ jest.mock('react-reconciler/src/ReactFiberReconciler', () => {
);
});
+jest.mock('react-reconciler/src/ReactMutableSource', () => {
+ return require.requireActual(
+ __VARIANT__
+ ? 'react-reconciler/src/ReactMutableSource.new'
+ : 'react-reconciler/src/ReactMutableSource.old'
+ );
+});
+
// When testing the custom renderer code path through `react-reconciler`,
// turn the export into a function, and use the argument as host config.
const shimHostConfigPath = 'react-reconciler/src/ReactFiberHostConfig';
diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js
index b0319333ea80d..93e12fe04dbd8 100644
--- a/scripts/rollup/forks.js
+++ b/scripts/rollup/forks.js
@@ -280,6 +280,26 @@ const forks = Object.freeze({
return 'react-reconciler/src/ReactFiberReconciler.old.js';
},
+ 'react-reconciler/src/ReactMutableSource': (
+ bundleType,
+ entry,
+ dependencies,
+ moduleType,
+ bundle
+ ) => {
+ if (bundle.enableNewReconciler) {
+ switch (bundleType) {
+ case FB_WWW_DEV:
+ case FB_WWW_PROD:
+ case FB_WWW_PROFILING:
+ // Use the forked version of the reconciler
+ return 'react-reconciler/src/ReactMutableSource.new.js';
+ }
+ }
+ // Otherwise, use the non-forked version.
+ return 'react-reconciler/src/ReactMutableSource.old.js';
+ },
+
'react-reconciler/src/ReactFiberHotReloading': (
bundleType,
entry,