mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
Interaction tracing works across hidden and SSR hydration boundaries (#15872)
* Interaction tracing works across hidden and SSR hydration boundaries
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
633
packages/react/src/__tests__/ReactDOMTracing-test.internal.js
Normal file
633
packages/react/src/__tests__/ReactDOMTracing-test.internal.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
74
scripts/jest/matchers/profilerMatchers.js
Normal file
74
scripts/jest/matchers/profilerMatchers.js
Normal 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,
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user