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:
parent
aefb97e6bb
commit
142d4f1c00
|
@ -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,25 +1460,15 @@ 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__
|
||||
? [
|
||||
'Child with ID',
|
||||
// Fallbacks are immediately committed in TestUtils version
|
||||
// of act
|
||||
// 'Child with ID',
|
||||
// 'Child with ID',
|
||||
'Child One',
|
||||
'Child Two',
|
||||
]
|
||||
: [
|
||||
'Child with ID',
|
||||
'Child with ID',
|
||||
'Child with ID',
|
||||
'Child One',
|
||||
'Child Two',
|
||||
],
|
||||
);
|
||||
expect(Scheduler).toFlushAndYieldThrough([
|
||||
'Child with ID',
|
||||
// Fallbacks are immediately committed in TestUtils version
|
||||
// of act
|
||||
// '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. ' +
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -88,6 +88,7 @@ import {
|
|||
findHostInstancesForRefresh,
|
||||
} from './ReactFiberHotReloading.new';
|
||||
|
||||
export {registerMutableSourceForHydration} from './ReactMutableSource.new';
|
||||
export {createPortal} from './ReactPortal';
|
||||
export {
|
||||
createComponentSelector,
|
||||
|
|
|
@ -85,6 +85,7 @@ import {
|
|||
findHostInstancesForRefresh,
|
||||
} from './ReactFiberHotReloading.old';
|
||||
|
||||
export {registerMutableSourceForHydration} from './ReactMutableSource.old';
|
||||
export {createPortal} from './ReactPortal';
|
||||
export {
|
||||
createComponentSelector,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue