useMutableSource hydration support (#18771)

* useMutableSource hydration support

* Remove unnecessary ReactMutableSource fork

* Replaced root.registerMutableSourceForHydration() with mutableSources option

* Response to PR feedback:

1. Moved mutableSources root option to hydrationOptions object
2. Only initialize root mutableSourceEagerHydrationData if supportsHydration config is true
3. Lazily initialize mutableSourceEagerHydrationData on root object
This commit is contained in:
Brian Vaughn 2020-05-21 16:00:46 -07:00 committed by GitHub
parent aefb97e6bb
commit 142d4f1c00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 626 additions and 87 deletions

View File

@ -1445,8 +1445,6 @@ describe('ReactDOMServerHooks', () => {
.getAttribute('id');
expect(serverId).not.toBeNull();
const childOneSpan = container.getElementsByTagName('span')[0];
const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(<App show={false} />);
expect(Scheduler).toHaveYielded([]);
@ -1462,9 +1460,7 @@ describe('ReactDOMServerHooks', () => {
// State update should trigger the ID to update, which changes the props
// of ChildWithID. This should cause ChildWithID to hydrate before Children
expect(Scheduler).toFlushAndYieldThrough(
__DEV__
? [
expect(Scheduler).toFlushAndYieldThrough([
'Child with ID',
// Fallbacks are immediately committed in TestUtils version
// of act
@ -1472,15 +1468,7 @@ describe('ReactDOMServerHooks', () => {
// 'Child with ID',
'Child One',
'Child Two',
]
: [
'Child with ID',
'Child with ID',
'Child with ID',
'Child One',
'Child Two',
],
);
]);
expect(child1Ref.current).toBe(null);
expect(childWithIDRef.current).toEqual(
@ -1500,7 +1488,9 @@ describe('ReactDOMServerHooks', () => {
});
// Children hydrates after ChildWithID
expect(child1Ref.current).toBe(childOneSpan);
expect(child1Ref.current).toBe(
container.getElementsByTagName('span')[0],
);
Scheduler.unstable_flushAll();
@ -1606,9 +1596,7 @@ describe('ReactDOMServerHooks', () => {
ReactDOM.unstable_createRoot(container, {hydrate: true}).render(
<App />,
);
expect(() =>
expect(() => Scheduler.unstable_flushAll()).toThrow(),
).toErrorDev([
expect(() => Scheduler.unstable_flushAll()).toErrorDev([
'Warning: Expected server HTML to contain a matching <div> in <div>.',
]);
});
@ -1694,14 +1682,12 @@ describe('ReactDOMServerHooks', () => {
ReactDOM.unstable_createRoot(container, {hydrate: true}).render(
<App />,
);
expect(() =>
expect(() => Scheduler.unstable_flushAll()).toThrow(),
).toErrorDev([
expect(() => Scheduler.unstable_flushAll()).toErrorDev([
'Warning: Expected server HTML to contain a matching <div> in <div>.',
]);
});
it('useOpaqueIdentifier throws when there is a hydration error and we are using ID as a string', async () => {
it('useOpaqueIdentifier warns when there is a hydration error and we are using ID as a string', async () => {
function Child({appId}) {
return <div aria-labelledby={appId + ''} />;
}
@ -1718,12 +1704,7 @@ describe('ReactDOMServerHooks', () => {
ReactDOM.unstable_createRoot(container, {hydrate: true}).render(
<App />,
);
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(
expect(() => Scheduler.unstable_flushAll()).toErrorDev(
[
'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.',
'Warning: Did not expect server HTML to contain a <span> in <div>.',
@ -1732,7 +1713,7 @@ describe('ReactDOMServerHooks', () => {
);
});
it('useOpaqueIdentifier throws when there is a hydration error and we are using ID as a string', async () => {
it('useOpaqueIdentifier warns when there is a hydration error and we are using ID as a string', async () => {
function Child({appId}) {
return <div aria-labelledby={appId + ''} />;
}
@ -1749,12 +1730,7 @@ describe('ReactDOMServerHooks', () => {
ReactDOM.unstable_createRoot(container, {hydrate: true}).render(
<App />,
);
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(
expect(() => Scheduler.unstable_flushAll()).toErrorDev(
[
'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.',
'Warning: Did not expect server HTML to contain a <span> in <div>.',
@ -1763,7 +1739,7 @@ describe('ReactDOMServerHooks', () => {
);
});
it('useOpaqueIdentifier throws if you try to use the result as a string in a child component', async () => {
it('useOpaqueIdentifier warns if you try to use the result as a string in a child component', async () => {
function Child({appId}) {
return <div aria-labelledby={appId + ''} />;
}
@ -1779,12 +1755,7 @@ describe('ReactDOMServerHooks', () => {
ReactDOM.unstable_createRoot(container, {hydrate: true}).render(
<App />,
);
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(
expect(() => Scheduler.unstable_flushAll()).toErrorDev(
[
'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.',
'Warning: Did not expect server HTML to contain a <div> in <div>.',
@ -1793,7 +1764,7 @@ describe('ReactDOMServerHooks', () => {
);
});
it('useOpaqueIdentifier throws if you try to use the result as a string', async () => {
it('useOpaqueIdentifier warns if you try to use the result as a string', async () => {
function App() {
const id = useOpaqueIdentifier();
return <div aria-labelledby={id + ''} />;
@ -1806,12 +1777,7 @@ describe('ReactDOMServerHooks', () => {
ReactDOM.unstable_createRoot(container, {hydrate: true}).render(
<App />,
);
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(
expect(() => Scheduler.unstable_flushAll()).toErrorDev(
[
'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.',
'Warning: Did not expect server HTML to contain a <div> in <div>.',
@ -1820,7 +1786,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 <div aria-labelledby={appId + ''} />;
}
@ -1842,16 +1808,14 @@ describe('ReactDOMServerHooks', () => {
<App />,
);
if (
gate(flags => flags.new && flags.deferRenderPhaseUpdateToNextBatch)
) {
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.
// This error isn't surfaced to the user; only the warning is.
// The error is just the mechanism that restarts the render.
expect(() =>
expect(() => Scheduler.unstable_flushAll()).toThrow(
'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +
@ -1864,7 +1828,7 @@ describe('ReactDOMServerHooks', () => {
}
});
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 <div aria-labelledby={+appId} />;
}
@ -1888,16 +1852,14 @@ describe('ReactDOMServerHooks', () => {
<App />,
);
if (
gate(flags => flags.new && flags.deferRenderPhaseUpdateToNextBatch)
) {
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.
// This error isn't surfaced to the user; only the warning is.
// The error is just the mechanism that restarts the render.
expect(() =>
expect(() => Scheduler.unstable_flushAll()).toThrow(
'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' +

View File

@ -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,
@ -25,6 +24,7 @@ export type RootOptions = {
hydrationOptions?: {
onHydrated?: (suspenseNode: Comment) => void,
onDeleted?: (suspenseNode: Comment) => void,
mutableSources?: Array<MutableSource<any>>,
...
},
...
@ -47,6 +47,8 @@ import {ensureListeningTo} from './ReactDOMComponent';
import {
createContainer,
updateContainer,
findHostInstanceWithNoPortals,
registerMutableSourceForHydration,
} from 'react-reconciler/src/ReactFiberReconciler';
import invariant from 'shared/invariant';
import {
@ -124,6 +126,11 @@ function createRootImpl(
const hydrate = options != null && options.hydrate === true;
const hydrationCallbacks =
(options != null && options.hydrationOptions) || null;
const mutableSources =
(options != null &&
options.hydrationOptions != null &&
options.hydrationOptions.mutableSources) ||
null;
const root = createContainer(container, tag, hydrate, hydrationCallbacks);
markContainerAsRoot(root.current, container);
const containerNodeType = container.nodeType;
@ -143,6 +150,14 @@ function createRootImpl(
) {
ensureListeningTo(container, 'onMouseEnter');
}
if (mutableSources) {
for (let i = 0; i < mutableSources.length; i++) {
const mutableSource = mutableSources[i];
registerMutableSourceForHydration(root, mutableSource);
}
}
return root;
}

View File

@ -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,
@ -126,6 +127,7 @@ import {
isSuspenseInstancePending,
isSuspenseInstanceFallback,
registerSuspenseInstanceRetry,
supportsHydration,
} from './ReactFiberHostConfig';
import type {SuspenseInstance} from './ReactFiberHostConfig';
import {shouldSuspend} from './ReactFiberReconciler';
@ -193,8 +195,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';
@ -1074,6 +1080,20 @@ function updateHostRoot(current, workInProgress, renderLanes) {
// be any children to hydrate which is effectively the same thing as
// not hydrating.
if (supportsHydration) {
const mutableSourceEagerHydrationData =
root.mutableSourceEagerHydrationData;
if (mutableSourceEagerHydrationData != null) {
for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) {
const mutableSource = ((mutableSourceEagerHydrationData[
i
]: any): MutableSource<any>);
const version = mutableSourceEagerHydrationData[i + 1];
setWorkInProgressVersion(mutableSource, version);
}
}
}
const child = mountChildFibers(
workInProgress,
null,
@ -2256,6 +2276,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,

View File

@ -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,
@ -114,6 +115,7 @@ import {
isSuspenseInstancePending,
isSuspenseInstanceFallback,
registerSuspenseInstanceRetry,
supportsHydration,
} from './ReactFiberHostConfig';
import type {SuspenseInstance} from './ReactFiberHostConfig';
import {shouldSuspend} from './ReactFiberReconciler';
@ -179,8 +181,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 +1044,20 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) {
// be any children to hydrate which is effectively the same thing as
// not hydrating.
if (supportsHydration) {
const mutableSourceEagerHydrationData =
root.mutableSourceEagerHydrationData;
if (mutableSourceEagerHydrationData != null) {
for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) {
const mutableSource = ((mutableSourceEagerHydrationData[
i
]: any): MutableSource<any>);
const version = mutableSourceEagerHydrationData[i + 1];
setWorkInProgressVersion(mutableSource, version);
}
}
}
const child = mountChildFibers(
workInProgress,
null,
@ -2239,6 +2259,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,

View File

@ -49,6 +49,7 @@ import {
findBoundingRects as findBoundingRects_old,
focusWithin as focusWithin_old,
observeVisibleRects as observeVisibleRects_old,
registerMutableSourceForHydration as registerMutableSourceForHydration_old,
} from './ReactFiberReconciler.old';
import {
@ -86,6 +87,7 @@ import {
findBoundingRects as findBoundingRects_new,
focusWithin as focusWithin_new,
observeVisibleRects as observeVisibleRects_new,
registerMutableSourceForHydration as registerMutableSourceForHydration_new,
} from './ReactFiberReconciler.new';
export const createContainer = enableNewReconciler
@ -186,3 +188,7 @@ export const focusWithin = enableNewReconciler
export const observeVisibleRects = enableNewReconciler
? observeVisibleRects_new
: observeVisibleRects_old;
export const registerMutableSourceForHydration = enableNewReconciler
? registerMutableSourceForHydration_new
: registerMutableSourceForHydration_old;

View File

@ -88,6 +88,7 @@ import {
findHostInstancesForRefresh,
} from './ReactFiberHotReloading.new';
export {registerMutableSourceForHydration} from './ReactMutableSource.new';
export {createPortal} from './ReactPortal';
export {
createComponentSelector,

View File

@ -85,6 +85,7 @@ import {
findHostInstancesForRefresh,
} from './ReactFiberHotReloading.old';
export {registerMutableSourceForHydration} from './ReactMutableSource.old';
export {createPortal} from './ReactPortal';
export {
createComponentSelector,

View File

@ -10,7 +10,7 @@
import type {FiberRoot, SuspenseHydrationCallbacks} from './ReactInternalTypes';
import type {RootTag} from './ReactRootTags';
import {noTimeout} from './ReactFiberHostConfig';
import {noTimeout, supportsHydration} from './ReactFiberHostConfig';
import {createHostRootFiber} from './ReactFiber.new';
import {
NoLanes,
@ -47,12 +47,15 @@ function FiberRootNode(containerInfo, tag, hydrate) {
this.pingedLanes = NoLanes;
this.expiredLanes = NoLanes;
this.mutableReadLanes = NoLanes;
this.finishedLanes = NoLanes;
this.entangledLanes = NoLanes;
this.entanglements = createLaneMap(NoLanes);
if (supportsHydration) {
this.mutableSourceEagerHydrationData = null;
}
if (enableSchedulerTracing) {
this.interactionThreadID = unstable_getThreadID();
this.memoizedInteractions = new Set();

View File

@ -11,7 +11,7 @@ import type {FiberRoot, SuspenseHydrationCallbacks} from './ReactInternalTypes';
import type {ExpirationTime} from './ReactFiberExpirationTime.old';
import type {RootTag} from './ReactRootTags';
import {noTimeout} from './ReactFiberHostConfig';
import {noTimeout, supportsHydration} from './ReactFiberHostConfig';
import {createHostRootFiber} from './ReactFiber.old';
import {NoWork} from './ReactFiberExpirationTime.old';
import {
@ -47,6 +47,10 @@ function FiberRootNode(containerInfo, tag, hydrate) {
this.lastExpiredTime = NoWork;
this.mutableSourceLastPendingUpdateTime = NoWork;
if (supportsHydration) {
this.mutableSourceEagerHydrationData = null;
}
if (enableSchedulerTracing) {
this.interactionThreadID = unstable_getThreadID();
this.memoizedInteractions = new Set();

View File

@ -62,6 +62,7 @@ import {
warnsIfNotActing,
beforeActiveInstanceBlur,
afterActiveInstanceBlur,
clearContainer,
} from './ReactFiberHostConfig';
import {
@ -215,13 +216,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;
@ -724,6 +726,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
@ -976,6 +987,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
@ -1016,6 +1036,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

View File

@ -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

View File

@ -16,6 +16,7 @@ import type {
ReactContext,
MutableSourceSubscribeFn,
MutableSourceGetSnapshotFn,
MutableSourceVersion,
MutableSource,
} from 'shared/ReactTypes';
import type {SuspenseInstance} from './ReactFiberHostConfig';
@ -247,6 +248,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<any> | MutableSourceVersion,
> | null,
// Only used by new reconciler
// Represents the next task that the root should work on, or the current one

View File

@ -8,6 +8,7 @@
*/
import type {MutableSource, MutableSourceVersion} from 'shared/ReactTypes';
import type {FiberRoot} from './ReactInternalTypes';
import {isPrimaryRenderer} from './ReactFiberHostConfig';
@ -85,3 +86,23 @@ 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<any>,
): 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.
if (root.mutableSourceEagerHydrationData == null) {
root.mutableSourceEagerHydrationData = [mutableSource, version];
} else {
root.mutableSourceEagerHydrationData.push(mutableSource, version);
}
}

View File

@ -116,3 +116,23 @@ 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<any>,
): 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.
if (root.mutableSourceEagerHydrationData == null) {
root.mutableSourceEagerHydrationData = [mutableSource, version];
} else {
root.mutableSourceEagerHydrationData.push(mutableSource, version);
}
}

View File

@ -0,0 +1,396 @@
/**
* 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 <div>{`${label}:${snapshot}`}</div>;
}
// @gate experimental
it('should render and hydrate', () => {
const source = createSource('one');
const mutableSource = createMutableSource(source);
function TestComponent() {
return (
<Component
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
);
}
const container = document.createElement('div');
document.body.appendChild(container);
const htmlString = ReactDOMServer.renderToString(<TestComponent />);
container.innerHTML = htmlString;
expect(Scheduler).toHaveYielded(['only:one']);
expect(source.listenerCount).toBe(0);
const root = ReactDOM.unstable_createRoot(container, {
hydrate: true,
hydrationOptions: {
mutableSources: [mutableSource],
},
});
act(() => {
root.render(<TestComponent />);
});
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 (
<Component
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
);
}
const container = document.createElement('div');
document.body.appendChild(container);
const htmlString = ReactDOMServer.renderToString(<TestComponent />);
container.innerHTML = htmlString;
expect(Scheduler).toHaveYielded(['only:one']);
expect(source.listenerCount).toBe(0);
const root = ReactDOM.unstable_createRoot(container, {
hydrate: true,
hydrationOptions: {
mutableSources: [mutableSource],
},
});
expect(() => {
act(() => {
root.render(<TestComponent />);
source.value = 'two';
});
}).toErrorDev(
'Warning: Did not expect server HTML to contain a <div> in <div>.',
{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 (
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>
);
}
const container = document.createElement('div');
document.body.appendChild(container);
const htmlString = ReactDOMServer.renderToString(<TestComponent />);
container.innerHTML = htmlString;
expect(Scheduler).toHaveYielded(['a:one', 'b:one']);
expect(source.listenerCount).toBe(0);
const root = ReactDOM.unstable_createRoot(container, {
hydrate: true,
hydrationOptions: {
mutableSources: [mutableSource],
},
});
expect(() => {
act(() => {
root.render(<TestComponent />);
expect(Scheduler).toFlushAndYieldThrough(['a:one']);
source.value = 'two';
});
}).toErrorDev(
'Warning: Did not expect server HTML to contain a <div> in <div>.',
{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(
<>
<Component
label="0"
getSnapshot={getSnapshotA}
mutableSource={mutableSource}
subscribe={subscribeA}
/>
<Component
label="1"
getSnapshot={getSnapshotB}
mutableSource={mutableSource}
subscribe={subscribeB}
/>
</>,
);
container.innerHTML = htmlString;
expect(Scheduler).toHaveYielded(['0:a:one', '1:b:one']);
const root = ReactDOM.unstable_createRoot(container, {
hydrate: true,
hydrationOptions: {
mutableSources: [mutableSource],
},
});
expect(() => {
act(() => {
root.render(
<>
<Component
label="0"
getSnapshot={getSnapshotA}
mutableSource={mutableSource}
subscribe={subscribeA}
/>
<Component
label="1"
getSnapshot={getSnapshotB}
mutableSource={mutableSource}
subscribe={subscribeB}
/>
</>,
);
expect(Scheduler).toFlushAndYieldThrough(['0:a:one']);
source.valueB = 'b:two';
});
}).toErrorDev(
'Warning: Did not expect server HTML to contain a <div> in <div>.',
{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 (
<>
<Unrelated flag={flag} />
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>
);
}
const container = document.createElement('div');
document.body.appendChild(container);
const htmlString = ReactDOMServer.renderToString(
<TestComponent flag={1} />,
);
container.innerHTML = htmlString;
expect(Scheduler).toHaveYielded([1, 'a:one']);
expect(source.listenerCount).toBe(0);
const root = ReactDOM.unstable_createRoot(container, {
hydrate: true,
hydrationOptions: {
mutableSources: [mutableSource],
},
});
expect(() => {
act(() => {
root.render(<TestComponent flag={1} />);
expect(Scheduler).toFlushAndYieldThrough([1]);
// Render an update which will be higher priority than the hydration.
Scheduler.unstable_runWithPriority(
Scheduler.unstable_UserBlockingPriority,
() => root.render(<TestComponent flag={2} />),
);
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);
});
});