Interaction tracing works across hidden and SSR hydration boundaries (#15872)

* Interaction tracing works across hidden and SSR hydration boundaries
This commit is contained in:
Brian Vaughn
2019-06-14 18:08:23 -07:00
committed by GitHub
parent 661562fc52
commit 801feed95c
8 changed files with 780 additions and 20 deletions

View File

@@ -54,6 +54,7 @@ import {
debugRenderPhaseSideEffects,
debugRenderPhaseSideEffectsForStrictMode,
enableProfilerTimer,
enableSchedulerTracing,
enableSuspenseServerRenderer,
enableEventAPI,
} from 'shared/ReactFeatureFlags';
@@ -164,7 +165,11 @@ import {
createWorkInProgress,
isSimpleFunctionComponent,
} from './ReactFiber';
import {requestCurrentTime, retryTimedOutBoundary} from './ReactFiberWorkLoop';
import {
markDidDeprioritizeIdleSubtree,
requestCurrentTime,
retryTimedOutBoundary,
} from './ReactFiberWorkLoop';
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -988,6 +993,9 @@ function updateHostComponent(current, workInProgress, renderExpirationTime) {
renderExpirationTime !== Never &&
shouldDeprioritizeSubtree(type, nextProps)
) {
if (enableSchedulerTracing) {
markDidDeprioritizeIdleSubtree();
}
// Schedule this fiber to re-render at offscreen priority. Then bailout.
workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
return null;
@@ -2265,6 +2273,9 @@ function beginWork(
renderExpirationTime !== Never &&
shouldDeprioritizeSubtree(workInProgress.type, newProps)
) {
if (enableSchedulerTracing) {
markDidDeprioritizeIdleSubtree();
}
// Schedule this fiber to re-render at offscreen priority. Then bailout.
workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
return null;

View File

@@ -97,10 +97,12 @@ import {
popHydrationState,
} from './ReactFiberHydrationContext';
import {
enableSchedulerTracing,
enableSuspenseServerRenderer,
enableEventAPI,
} from 'shared/ReactFeatureFlags';
import {
markDidDeprioritizeIdleSubtree,
renderDidSuspend,
renderDidSuspendDelayIfPossible,
} from './ReactFiberWorkLoop';
@@ -815,6 +817,9 @@ function completeWork(
'A dehydrated suspense component was completed without a hydrated node. ' +
'This is probably a bug in React.',
);
if (enableSchedulerTracing) {
markDidDeprioritizeIdleSubtree();
}
skipPastDehydratedSuspenseInstance(workInProgress);
} else if ((workInProgress.effectTag & DidCapture) === NoEffect) {
// This boundary did not suspend so it's now hydrated.

View File

@@ -246,6 +246,10 @@ let nestedPassiveUpdateCount: number = 0;
let interruptedBy: Fiber | null = null;
// Marks the need to reschedule pending interactions at Never priority during the commit phase.
// This enables them to be traced accross hidden boundaries or suspended SSR hydration.
let didDeprioritizeIdleSubtree: boolean = false;
// Expiration times are computed by adding to the current time (the start
// time). However, if two updates are scheduled within the same event, we
// should treat their start times as simultaneous, even if the actual clock
@@ -378,7 +382,7 @@ export function scheduleUpdateOnFiber(
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
// Register pending interactions on the root to avoid losing traced interaction data.
schedulePendingInteraction(root, expirationTime);
schedulePendingInteractions(root, expirationTime);
// This is a legacy edge case. The initial mount of a ReactDOM.render-ed
// root inside of batchedUpdates should be synchronous, but layout updates
@@ -541,9 +545,8 @@ function scheduleCallbackForRoot(
}
}
// Add the current set of interactions to the pending set associated with
// this root.
schedulePendingInteraction(root, expirationTime);
// Associate the current interactions with this new root+priority.
schedulePendingInteractions(root, expirationTime);
}
function runRootCallback(root, callback, isSync) {
@@ -781,6 +784,10 @@ function prepareFreshStack(root, expirationTime) {
workInProgressRootCanSuspendUsingConfig = null;
workInProgressRootHasPendingPing = false;
if (enableSchedulerTracing) {
didDeprioritizeIdleSubtree = false;
}
if (__DEV__) {
ReactStrictModeWarnings.discardPendingWarnings();
componentsWithSuspendedDiscreteUpdates = null;
@@ -822,7 +829,7 @@ function renderRoot(
// and prepare a fresh one. Otherwise we'll continue where we left off.
if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
prepareFreshStack(root, expirationTime);
startWorkOnPendingInteraction(root, expirationTime);
startWorkOnPendingInteractions(root, expirationTime);
} else if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
// We could've received an update at a lower priority while we yielded.
// We're suspended in a delayed state. Once we complete this render we're
@@ -1680,19 +1687,14 @@ function commitRootImpl(root) {
stopCommitTimer();
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
if (rootDoesHavePassiveEffects) {
// This commit has passive effects. Stash a reference to them. But don't
// schedule a callback until after flushing layout work.
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsExpirationTime = expirationTime;
} else {
if (enableSchedulerTracing) {
// If there are no passive effects, then we can complete the pending
// interactions. Otherwise, we'll wait until after the passive effects
// are flushed.
finishPendingInteractions(root, expirationTime);
}
}
// Check if there's remaining work on this root
@@ -1703,6 +1705,14 @@ function commitRootImpl(root) {
currentTime,
remainingExpirationTime,
);
if (enableSchedulerTracing) {
if (didDeprioritizeIdleSubtree) {
didDeprioritizeIdleSubtree = false;
scheduleInteractions(root, Never, root.memoizedInteractions);
}
}
scheduleCallbackForRoot(root, priorityLevel, remainingExpirationTime);
} else {
// If there's no remaining work, we can clear the set of already failed
@@ -1710,6 +1720,16 @@ function commitRootImpl(root) {
legacyErrorBoundariesThatAlreadyFailed = null;
}
if (enableSchedulerTracing) {
if (!rootDidHavePassiveEffects) {
// If there are no passive effects, then we can complete the pending interactions.
// Otherwise, we'll wait until after the passive effects are flushed.
// Wait to do this until after remaining work has been scheduled,
// so that we don't prematurely signal complete for interactions when there's e.g. hidden work.
finishPendingInteractions(root, expirationTime);
}
}
onCommitRoot(finishedWork.stateNode, expirationTime);
if (remainingExpirationTime === Sync) {
@@ -2512,14 +2532,18 @@ function computeThreadID(root, expirationTime) {
return expirationTime * 1000 + root.interactionThreadID;
}
function schedulePendingInteraction(root, expirationTime) {
// This is called when work is scheduled on a root. It sets up a pending
// interaction, which is completed once the work commits.
export function markDidDeprioritizeIdleSubtree() {
if (!enableSchedulerTracing) {
return;
}
didDeprioritizeIdleSubtree = true;
}
function scheduleInteractions(root, expirationTime, interactions) {
if (!enableSchedulerTracing) {
return;
}
const interactions = __interactionsRef.current;
if (interactions.size > 0) {
const pendingInteractionMap = root.pendingInteractionMap;
const pendingInteractions = pendingInteractionMap.get(expirationTime);
@@ -2549,7 +2573,18 @@ function schedulePendingInteraction(root, expirationTime) {
}
}
function startWorkOnPendingInteraction(root, expirationTime) {
function schedulePendingInteractions(root, expirationTime) {
// This is called when work is scheduled on a root.
// It associates the current interactions with the newly-scheduled expiration.
// They will be restored when that expiration is later committed.
if (!enableSchedulerTracing) {
return;
}
scheduleInteractions(root, expirationTime, __interactionsRef.current);
}
function startWorkOnPendingInteractions(root, expirationTime) {
// This is called when new work is started on a root.
if (!enableSchedulerTracing) {
return;

View File

@@ -0,0 +1,633 @@
/**
* 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 ReactFeatureFlags;
let Scheduler;
let SchedulerTracing;
let TestUtils;
let onInteractionScheduledWorkCompleted;
let onInteractionTraced;
let onWorkCanceled;
let onWorkScheduled;
let onWorkStarted;
let onWorkStopped;
function loadModules() {
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.debugRenderPhaseSideEffects = false;
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
ReactFeatureFlags.enableSuspenseServerRenderer = true;
ReactFeatureFlags.enableProfilerTimer = true;
ReactFeatureFlags.enableSchedulerTracing = true;
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMServer = require('react-dom/server');
Scheduler = require('scheduler');
SchedulerTracing = require('scheduler/tracing');
TestUtils = require('react-dom/test-utils');
onInteractionScheduledWorkCompleted = jest.fn();
onInteractionTraced = jest.fn();
onWorkCanceled = jest.fn();
onWorkScheduled = jest.fn();
onWorkStarted = jest.fn();
onWorkStopped = jest.fn();
// Verify interaction subscriber methods are called as expected.
SchedulerTracing.unstable_subscribe({
onInteractionScheduledWorkCompleted,
onInteractionTraced,
onWorkCanceled,
onWorkScheduled,
onWorkStarted,
onWorkStopped,
});
}
describe('ReactDOMTracing', () => {
beforeEach(() => {
jest.resetModules();
loadModules();
});
describe('interaction tracing', () => {
describe('hidden', () => {
it('traces interaction through hidden subtree', () => {
const Child = () => {
const [didMount, setDidMount] = React.useState(false);
Scheduler.yieldValue('Child');
React.useEffect(
() => {
if (didMount) {
Scheduler.yieldValue('Child:update');
} else {
Scheduler.yieldValue('Child:mount');
setDidMount(true);
}
},
[didMount],
);
return <div />;
};
const App = () => {
Scheduler.yieldValue('App');
React.useEffect(() => {
Scheduler.yieldValue('App:mount');
}, []);
return (
<div hidden={true}>
<Child />
</div>
);
};
let interaction;
const onRender = jest.fn();
const container = document.createElement('div');
const root = ReactDOM.unstable_createRoot(container);
SchedulerTracing.unstable_trace('initialization', 0, () => {
interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
root.render(
<React.Profiler id="test" onRender={onRender}>
<App />
</React.Profiler>,
);
});
expect(onInteractionTraced).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
interaction,
);
expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(onRender).toHaveBeenCalledTimes(1);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
expect(Scheduler).toFlushAndYieldThrough(['Child', 'Child:mount']);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(onRender).toHaveBeenCalledTimes(2);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
expect(Scheduler).toFlushAndYield(['Child', 'Child:update']);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
expect(
onInteractionScheduledWorkCompleted,
).toHaveBeenLastNotifiedOfInteraction(interaction);
expect(onRender).toHaveBeenCalledTimes(3);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
});
it('traces interaction through hidden subtree when there is other pending traced work', () => {
const Child = () => {
Scheduler.yieldValue('Child');
return <div />;
};
let wrapped = null;
const App = () => {
Scheduler.yieldValue('App');
React.useEffect(() => {
wrapped = SchedulerTracing.unstable_wrap(() => {});
Scheduler.yieldValue('App:mount');
}, []);
return (
<div hidden={true}>
<Child />
</div>
);
};
let interaction;
const onRender = jest.fn();
const container = document.createElement('div');
const root = ReactDOM.unstable_createRoot(container);
SchedulerTracing.unstable_trace('initialization', 0, () => {
interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
root.render(
<React.Profiler id="test" onRender={onRender}>
<App />
</React.Profiler>,
);
});
expect(onInteractionTraced).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
interaction,
);
expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(onRender).toHaveBeenCalledTimes(1);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
expect(wrapped).not.toBeNull();
expect(Scheduler).toFlushAndYield(['Child']);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(onRender).toHaveBeenCalledTimes(2);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
wrapped();
expect(onInteractionTraced).toHaveBeenCalledTimes(1);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
expect(
onInteractionScheduledWorkCompleted,
).toHaveBeenLastNotifiedOfInteraction(interaction);
});
it('traces interaction through hidden subtree that schedules more idle/never work', () => {
const Child = () => {
const [didMount, setDidMount] = React.useState(false);
Scheduler.yieldValue('Child');
React.useLayoutEffect(
() => {
if (didMount) {
Scheduler.yieldValue('Child:update');
} else {
Scheduler.yieldValue('Child:mount');
Scheduler.unstable_runWithPriority(
Scheduler.unstable_IdlePriority,
() => setDidMount(true),
);
}
},
[didMount],
);
return <div />;
};
const App = () => {
Scheduler.yieldValue('App');
React.useEffect(() => {
Scheduler.yieldValue('App:mount');
}, []);
return (
<div hidden={true}>
<Child />
</div>
);
};
let interaction;
const onRender = jest.fn();
const container = document.createElement('div');
const root = ReactDOM.unstable_createRoot(container);
SchedulerTracing.unstable_trace('initialization', 0, () => {
interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
root.render(
<React.Profiler id="test" onRender={onRender}>
<App />
</React.Profiler>,
);
});
expect(onInteractionTraced).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
interaction,
);
expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(onRender).toHaveBeenCalledTimes(1);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
expect(Scheduler).toFlushAndYieldThrough(['Child', 'Child:mount']);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(onRender).toHaveBeenCalledTimes(2);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
expect(Scheduler).toFlushAndYield(['Child', 'Child:update']);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
expect(
onInteractionScheduledWorkCompleted,
).toHaveBeenLastNotifiedOfInteraction(interaction);
expect(onRender).toHaveBeenCalledTimes(3);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
});
it('does not continue interactions across pre-existing idle work', () => {
const Child = () => {
Scheduler.yieldValue('Child');
return <div />;
};
let update = null;
const WithHiddenWork = () => {
Scheduler.yieldValue('WithHiddenWork');
return (
<div hidden={true}>
<Child />
</div>
);
};
const Updater = () => {
Scheduler.yieldValue('Updater');
React.useEffect(() => {
Scheduler.yieldValue('Updater:effect');
});
const setCount = React.useState(0)[1];
update = () => {
setCount(current => current + 1);
};
return <div />;
};
const App = () => {
Scheduler.yieldValue('App');
React.useEffect(() => {
Scheduler.yieldValue('App:effect');
});
return (
<React.Fragment>
<WithHiddenWork />
<Updater />
</React.Fragment>
);
};
const onRender = jest.fn();
const container = document.createElement('div');
const root = ReactDOM.unstable_createRoot(container);
// Schedule some idle work without any interactions.
TestUtils.act(() => {
root.render(
<React.Profiler id="test" onRender={onRender}>
<App />
</React.Profiler>,
);
expect(Scheduler).toFlushAndYieldThrough([
'App',
'WithHiddenWork',
'Updater',
'Updater:effect',
'App:effect',
]);
expect(update).not.toBeNull();
// Trace a higher-priority update.
let interaction = null;
SchedulerTracing.unstable_trace('update', 0, () => {
interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
update();
});
expect(interaction).not.toBeNull();
expect(onRender).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
interaction,
);
// Ensure the traced interaction completes without being attributed to the pre-existing idle work.
expect(Scheduler).toFlushAndYieldThrough([
'Updater',
'Updater:effect',
]);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
expect(
onInteractionScheduledWorkCompleted,
).toHaveBeenLastNotifiedOfInteraction(interaction);
expect(onRender).toHaveBeenCalledTimes(2);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
// Complete low-priority work and ensure no lingering interaction.
expect(Scheduler).toFlushAndYield(['Child']);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
expect(onRender).toHaveBeenCalledTimes(3);
expect(onRender).toHaveLastRenderedWithInteractions(new Set([]));
});
});
it('should properly trace interactions when there is work of interleaved priorities', () => {
const Child = () => {
Scheduler.yieldValue('Child');
return <div />;
};
let scheduleUpdate = null;
let scheduleUpdateWithHidden = null;
const MaybeHiddenWork = () => {
const [flag, setFlag] = React.useState(false);
scheduleUpdateWithHidden = () => setFlag(true);
Scheduler.yieldValue('MaybeHiddenWork');
React.useEffect(() => {
Scheduler.yieldValue('MaybeHiddenWork:effect');
});
return flag ? (
<div hidden={true}>
<Child />
</div>
) : null;
};
const Updater = () => {
Scheduler.yieldValue('Updater');
React.useEffect(() => {
Scheduler.yieldValue('Updater:effect');
});
const setCount = React.useState(0)[1];
scheduleUpdate = () => setCount(current => current + 1);
return <div />;
};
const App = () => {
Scheduler.yieldValue('App');
React.useEffect(() => {
Scheduler.yieldValue('App:effect');
});
return (
<React.Fragment>
<MaybeHiddenWork />
<Updater />
</React.Fragment>
);
};
const onRender = jest.fn();
const container = document.createElement('div');
const root = ReactDOM.unstable_createRoot(container);
TestUtils.act(() => {
root.render(
<React.Profiler id="test" onRender={onRender}>
<App />
</React.Profiler>,
);
expect(Scheduler).toFlushAndYield([
'App',
'MaybeHiddenWork',
'Updater',
'MaybeHiddenWork:effect',
'Updater:effect',
'App:effect',
]);
expect(scheduleUpdate).not.toBeNull();
expect(scheduleUpdateWithHidden).not.toBeNull();
expect(onRender).toHaveBeenCalledTimes(1);
// schedule traced high-pri update and a (non-traced) low-pri update.
let interaction = null;
SchedulerTracing.unstable_trace('update', 0, () => {
interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
Scheduler.unstable_runWithPriority(
Scheduler.unstable_UserBlockingPriority,
() => scheduleUpdateWithHidden(),
);
});
scheduleUpdate();
expect(interaction).not.toBeNull();
expect(onRender).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
interaction,
);
// high-pri update should leave behind idle work and should not complete the interaction
expect(Scheduler).toFlushAndYieldThrough([
'MaybeHiddenWork',
'MaybeHiddenWork:effect',
]);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(onRender).toHaveBeenCalledTimes(2);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
// low-pri update should not have the interaction
expect(Scheduler).toFlushAndYieldThrough([
'Updater',
'Updater:effect',
]);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(onRender).toHaveBeenCalledTimes(3);
expect(onRender).toHaveLastRenderedWithInteractions(new Set([]));
// idle work should complete the interaction
expect(Scheduler).toFlushAndYield(['Child']);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
expect(
onInteractionScheduledWorkCompleted,
).toHaveBeenLastNotifiedOfInteraction(interaction);
expect(onRender).toHaveBeenCalledTimes(4);
expect(onRender).toHaveLastRenderedWithInteractions(
new Set([interaction]),
);
});
});
});
describe('hydration', () => {
it('traces interaction across hydration', async done => {
let ref = React.createRef();
function Child() {
return 'Hello';
}
function App() {
return (
<div>
<span ref={ref}>
<Child />
</span>
</div>
);
}
// Render the final HTML.
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
let interaction;
const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
// Hydrate it.
SchedulerTracing.unstable_trace('initialization', 0, () => {
interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
root.render(<App />);
});
Scheduler.flushAll();
jest.runAllTimers();
expect(ref.current).not.toBe(null);
expect(onInteractionTraced).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
interaction,
);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
expect(
onInteractionScheduledWorkCompleted,
).toHaveBeenLastNotifiedOfInteraction(interaction);
done();
});
it('traces interaction across suspended hydration', async done => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function App() {
return (
<div>
<React.Suspense fallback="Loading...">
<span ref={ref}>
<Child />
</span>
</React.Suspense>
</div>
);
}
// Render the final HTML.
// Don't suspend on the server.
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
let interaction;
const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
// Start hydrating but simulate blocking for suspense data.
suspend = true;
SchedulerTracing.unstable_trace('initialization', 0, () => {
interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
root.render(<App />);
});
Scheduler.flushAll();
jest.runAllTimers();
expect(ref.current).toBe(null);
expect(onInteractionTraced).toHaveBeenCalledTimes(1);
expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
interaction,
);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
// Resolving the promise should continue hydration
suspend = false;
resolve();
await promise;
Scheduler.flushAll();
jest.runAllTimers();
expect(ref.current).not.toBe(null);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
expect(
onInteractionScheduledWorkCompleted,
).toHaveBeenLastNotifiedOfInteraction(interaction);
done();
});
});
});
});

View File

@@ -0,0 +1,74 @@
'use strict';
const jestDiff = require('jest-diff');
function toHaveLastRenderedWithNoInteractions(onRenderMockFn) {
const calls = onRenderMockFn.mock.calls;
if (calls.length === 0) {
return {
message: () => 'Mock onRender function was not called',
pass: false,
};
}
}
function toHaveLastRenderedWithInteractions(
onRenderMockFn,
expectedInteractions
) {
const calls = onRenderMockFn.mock.calls;
if (calls.length === 0) {
return {
message: () => 'Mock onRender function was not called',
pass: false,
};
}
const lastCall = calls[calls.length - 1];
const actualInteractions = lastCall[6];
return toMatchInteractions(actualInteractions, expectedInteractions);
}
function toMatchInteraction(actual, expected) {
let attribute;
for (attribute in expected) {
if (actual[attribute] !== expected[attribute]) {
return {
message: () => jestDiff(expected, actual),
pass: false,
};
}
}
return {pass: true};
}
function toMatchInteractions(actualSetOrArray, expectedSetOrArray) {
const actualArray = Array.from(actualSetOrArray);
const expectedArray = Array.from(expectedSetOrArray);
if (actualArray.length !== expectedArray.length) {
return {
message: () =>
`Expected ${expectedArray.length} interactions but there were ${
actualArray.length
}`,
pass: false,
};
}
for (let i = 0; i < actualArray.length; i++) {
const result = toMatchInteraction(actualArray[i], expectedArray[i]);
if (result.pass === false) {
return result;
}
}
return {pass: true};
}
module.exports = {
toHaveLastRenderedWithInteractions,
toHaveLastRenderedWithNoInteractions,
};

View File

@@ -44,7 +44,8 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
}
expect.extend({
...require('./matchers/interactionTracing'),
...require('./matchers/interactionTracingMatchers'),
...require('./matchers/profilerMatchers'),
...require('./matchers/toWarnDev'),
...require('./matchers/reactTestMatchers'),
});

View File

@@ -46,7 +46,8 @@ global.spyOnProd = function(...args) {
};
expect.extend({
...require('../matchers/interactionTracing'),
...require('../matchers/interactionTracingMatchers'),
...require('../matchers/profilerMatchers'),
...require('../matchers/toWarnDev'),
...require('../matchers/reactTestMatchers'),
});