@samitouri / QOS-React-2 / commits / 357a61324f

[DevTools][Transition Tracing] Added support for Suspense Boundaries (#23365)

This PR: * During the passive effect complete phase for Offscreen, we add all the transitions that were added to the update queue in the render phase to the transitions set on the memoziedState. We also add the stateNode for the Offscreen component to the root pendingSuspenseBoundaries map if the suspense boundary has gone from shown to fallback. We remove the stateNode if the boundary goes from fallback to shown. * During the passive effect complete phase for the HostRoot, for each transition that was initiated during this commit, we add a pending transitionStart callback. We also add them to the transition set on the memoizedState for the HostRoot. If the root pendingSuspenseBoundaries is empty, we add a pending transitionComplete callback.

Luna Ruan committed May 16, 2022 at 10:23 UTC 357a61324f8d746a7f745dfc57009ba952c7d640
3 files changed +497 -18
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+158 -9
@@ -22,7 +22,10 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
22 import type {UpdateQueue} from './ReactUpdateQueue.new';
23 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new';
24 import type {Wakeable} from 'shared/ReactTypes';
25 -import type {OffscreenState} from './ReactFiberOffscreenComponent';
25 +import type {
26 + OffscreenState,
27 + OffscreenInstance,
28 +} from './ReactFiberOffscreenComponent';
29 import type {HookFlags} from './ReactHookEffectTags';
30 import type {Cache} from './ReactFiberCacheComponent.new';
31 import type {RootState} from './ReactFiberRoot.new';
@@ -62,6 +65,7 @@ import {
65 OffscreenComponent,
66 LegacyHiddenComponent,
67 CacheComponent,
68 + TracingMarkerComponent,
69 } from './ReactWorkTags';
70 import {detachDeletedInstance} from './ReactFiberHostConfig';
71 import {
@@ -1001,7 +1005,8 @@ function commitLayoutEffectOnFiber(
1005 case IncompleteClassComponent:
1006 case ScopeComponent:
1007 case OffscreenComponent:
1004 - case LegacyHiddenComponent: {
1008 + case LegacyHiddenComponent:
1009 + case TracingMarkerComponent: {
1010 break;
1011 }
1012
@@ -1066,6 +1071,89 @@ function reappearLayoutEffectsOnFiber(node: Fiber) {
1071 }
1072 }
1073
1074 +function commitTransitionProgress(
1075 + finishedRoot: FiberRoot,
1076 + offscreenFiber: Fiber,
1077 +) {
1078 + if (enableTransitionTracing) {
1079 + // This function adds suspense boundaries to the root
1080 + // or tracing marker's pendingSuspenseBoundaries map.
1081 + // When a suspense boundary goes from a resolved to a fallback
1082 + // state we add the boundary to the map, and when it goes from
1083 + // a fallback to a resolved state, we remove the boundary from
1084 + // the map.
1085 +
1086 + // We use stateNode on the Offscreen component as a stable object
1087 + // that doesnt change from render to render. This way we can
1088 + // distinguish between different Offscreen instances (vs. the same
1089 + // Offscreen instance with different fibers)
1090 + const offscreenInstance: OffscreenInstance = offscreenFiber.stateNode;
1091 +
1092 + let prevState: SuspenseState | null = null;
1093 + const previousFiber = offscreenFiber.alternate;
1094 + if (previousFiber !== null && previousFiber.memoizedState !== null) {
1095 + prevState = previousFiber.memoizedState;
1096 + }
1097 + const nextState: SuspenseState | null = offscreenFiber.memoizedState;
1098 +
1099 + const wasHidden = prevState !== null;
1100 + const isHidden = nextState !== null;
1101 +
1102 + const rootState: RootState = finishedRoot.current.memoizedState;
1103 + // TODO(luna) move pendingSuspenseBoundaries and transitions from
1104 + // HostRoot fiber to FiberRoot
1105 + const rootPendingBoundaries = rootState.pendingSuspenseBoundaries;
1106 + const rootTransitions = rootState.transitions;
1107 +
1108 + // If there is a name on the suspense boundary, store that in
1109 + // the pending boundaries.
1110 + let name = null;
1111 + const parent = offscreenFiber.return;
1112 + if (
1113 + parent !== null &&
1114 + parent.tag === SuspenseComponent &&
1115 + parent.memoizedProps.unstable_name
1116 + ) {
1117 + name = parent.memoizedProps.unstable_name;
1118 + }
1119 +
1120 + if (rootPendingBoundaries !== null) {
1121 + if (previousFiber === null) {
1122 + // Initial mount
1123 + if (isHidden) {
1124 + rootPendingBoundaries.set(offscreenInstance, {
1125 + name,
1126 + });
1127 + }
1128 + } else {
1129 + if (wasHidden && !isHidden) {
1130 + // The suspense boundary went from hidden to visible. Remove
1131 + // the boundary from the pending suspense boundaries set
1132 + // if it's there
1133 + if (rootPendingBoundaries.has(offscreenInstance)) {
1134 + rootPendingBoundaries.delete(offscreenInstance);
1135 +
1136 + if (rootPendingBoundaries.size === 0 && rootTransitions !== null) {
1137 + rootTransitions.forEach(transition => {
1138 + addTransitionCompleteCallbackToPendingTransition({
1139 + transitionName: transition.name,
1140 + startTime: transition.startTime,
1141 + });
1142 + });
1143 + }
1144 + }
1145 + } else if (!wasHidden && isHidden) {
1146 + // The suspense boundaries was just hidden. Add the boundary
1147 + // to the pending boundary set if it's there
1148 + rootPendingBoundaries.set(offscreenInstance, {
1149 + name,
1150 + });
1151 + }
1152 + }
1153 + }
1154 + }
1155 +}
1156 +
1157 function hideOrUnhideAllChildren(finishedWork, isHidden) {
1158 // Only hide or unhide the top-most host nodes.
1159 let hostSubtreeRoot = null;
@@ -2747,22 +2835,48 @@ function commitPassiveMountOnFiber(
2835 }
2836
2837 if (enableTransitionTracing) {
2838 + // Get the transitions that were initiatized during the render
2839 + // and add a start transition callback for each of them
2840 + const state = finishedWork.memoizedState;
2841 + // TODO Since it's a mutable field, this should live on the FiberRoot
2842 + if (state.transitions === null) {
2843 + state.transitions = new Set([]);
2844 + }
2845 + const pendingTransitions = state.transitions;
2846 + const pendingSuspenseBoundaries = state.pendingSuspenseBoundaries;
2847 +
2848 + // Initial render
2849 if (committedTransitions !== null) {
2850 committedTransitions.forEach(transition => {
2752 - // TODO(luna) Do we want to log TransitionStart in the startTransition callback instead?
2851 addTransitionStartCallbackToPendingTransition({
2852 transitionName: transition.name,
2853 startTime: transition.startTime,
2854 });
2855 + pendingTransitions.add(transition);
2856 + });
2857
2758 - addTransitionCompleteCallbackToPendingTransition({
2759 - transitionName: transition.name,
2760 - startTime: transition.startTime,
2858 + if (
2859 + pendingSuspenseBoundaries === null ||
2860 + pendingSuspenseBoundaries.size === 0
2861 + ) {
2862 + pendingTransitions.forEach(transition => {
2863 + addTransitionCompleteCallbackToPendingTransition({
2864 + transitionName: transition.name,
2865 + startTime: transition.startTime,
2866 + });
2867 });
2762 - });
2868 + }
2869
2870 clearTransitionsForLanes(finishedRoot, committedLanes);
2765 - finishedWork.memoizedState.transitions = null;
2871 + }
2872 +
2873 + // If there are no more pending suspense boundaries we
2874 + // clear the transitions because they are all complete.
2875 + if (
2876 + pendingSuspenseBoundaries === null ||
2877 + pendingSuspenseBoundaries.size === 0
2878 + ) {
2879 + state.transitions = null;
2880 }
2881 }
2882 break;
@@ -2800,9 +2914,44 @@ function commitPassiveMountOnFiber(
2914 }
2915
2916 if (enableTransitionTracing) {
2803 - // TODO: Add code to actually process the update queue
2917 + const isFallback = finishedWork.memoizedState;
2918 + const queue = (finishedWork.updateQueue: any);
2919 + const rootMemoizedState = finishedRoot.current.memoizedState;
2920 +
2921 + if (queue !== null) {
2922 + // We have one instance of the pendingSuspenseBoundaries map.
2923 + // We only need one because we update it during the commit phase.
2924 + // We instantiate a new Map if we haven't already
2925 + if (rootMemoizedState.pendingSuspenseBoundaries === null) {
2926 + rootMemoizedState.pendingSuspenseBoundaries = new Map();
2927 + }
2928 +
2929 + if (isFallback) {
2930 + const transitions = queue.transitions;
2931 + let prevTransitions = finishedWork.memoizedState.transitions;
2932 + // Add all the transitions saved in the update queue during
2933 + // the render phase (ie the transitions associated with this boundary)
2934 + // into the transitions set.
2935 + if (transitions !== null) {
2936 + if (prevTransitions === null) {
2937 + // We only have one instance of the transitions set
2938 + // because we update it only during the commit phase. We
2939 + // will create the set on a as needed basis in the commit phase
2940 + finishedWork.memoizedState.transitions = prevTransitions = new Set();
2941 + }
2942 +
2943 + transitions.forEach(transition => {
2944 + prevTransitions.add(transition);
2945 + });
2946 + }
2947 + }
2948 + }
2949 +
2950 + commitTransitionProgress(finishedRoot, finishedWork);
2951 +
2952 finishedWork.updateQueue = null;
2953 }
2954 +
2955 break;
2956 }
2957 case CacheComponent: {
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+158 -9
@@ -22,7 +22,10 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
22 import type {UpdateQueue} from './ReactUpdateQueue.old';
23 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.old';
24 import type {Wakeable} from 'shared/ReactTypes';
25 -import type {OffscreenState} from './ReactFiberOffscreenComponent';
25 +import type {
26 + OffscreenState,
27 + OffscreenInstance,
28 +} from './ReactFiberOffscreenComponent';
29 import type {HookFlags} from './ReactHookEffectTags';
30 import type {Cache} from './ReactFiberCacheComponent.old';
31 import type {RootState} from './ReactFiberRoot.old';
@@ -62,6 +65,7 @@ import {
65 OffscreenComponent,
66 LegacyHiddenComponent,
67 CacheComponent,
68 + TracingMarkerComponent,
69 } from './ReactWorkTags';
70 import {detachDeletedInstance} from './ReactFiberHostConfig';
71 import {
@@ -1001,7 +1005,8 @@ function commitLayoutEffectOnFiber(
1005 case IncompleteClassComponent:
1006 case ScopeComponent:
1007 case OffscreenComponent:
1004 - case LegacyHiddenComponent: {
1008 + case LegacyHiddenComponent:
1009 + case TracingMarkerComponent: {
1010 break;
1011 }
1012
@@ -1066,6 +1071,89 @@ function reappearLayoutEffectsOnFiber(node: Fiber) {
1071 }
1072 }
1073
1074 +function commitTransitionProgress(
1075 + finishedRoot: FiberRoot,
1076 + offscreenFiber: Fiber,
1077 +) {
1078 + if (enableTransitionTracing) {
1079 + // This function adds suspense boundaries to the root
1080 + // or tracing marker's pendingSuspenseBoundaries map.
1081 + // When a suspense boundary goes from a resolved to a fallback
1082 + // state we add the boundary to the map, and when it goes from
1083 + // a fallback to a resolved state, we remove the boundary from
1084 + // the map.
1085 +
1086 + // We use stateNode on the Offscreen component as a stable object
1087 + // that doesnt change from render to render. This way we can
1088 + // distinguish between different Offscreen instances (vs. the same
1089 + // Offscreen instance with different fibers)
1090 + const offscreenInstance: OffscreenInstance = offscreenFiber.stateNode;
1091 +
1092 + let prevState: SuspenseState | null = null;
1093 + const previousFiber = offscreenFiber.alternate;
1094 + if (previousFiber !== null && previousFiber.memoizedState !== null) {
1095 + prevState = previousFiber.memoizedState;
1096 + }
1097 + const nextState: SuspenseState | null = offscreenFiber.memoizedState;
1098 +
1099 + const wasHidden = prevState !== null;
1100 + const isHidden = nextState !== null;
1101 +
1102 + const rootState: RootState = finishedRoot.current.memoizedState;
1103 + // TODO(luna) move pendingSuspenseBoundaries and transitions from
1104 + // HostRoot fiber to FiberRoot
1105 + const rootPendingBoundaries = rootState.pendingSuspenseBoundaries;
1106 + const rootTransitions = rootState.transitions;
1107 +
1108 + // If there is a name on the suspense boundary, store that in
1109 + // the pending boundaries.
1110 + let name = null;
1111 + const parent = offscreenFiber.return;
1112 + if (
1113 + parent !== null &&
1114 + parent.tag === SuspenseComponent &&
1115 + parent.memoizedProps.unstable_name
1116 + ) {
1117 + name = parent.memoizedProps.unstable_name;
1118 + }
1119 +
1120 + if (rootPendingBoundaries !== null) {
1121 + if (previousFiber === null) {
1122 + // Initial mount
1123 + if (isHidden) {
1124 + rootPendingBoundaries.set(offscreenInstance, {
1125 + name,
1126 + });
1127 + }
1128 + } else {
1129 + if (wasHidden && !isHidden) {
1130 + // The suspense boundary went from hidden to visible. Remove
1131 + // the boundary from the pending suspense boundaries set
1132 + // if it's there
1133 + if (rootPendingBoundaries.has(offscreenInstance)) {
1134 + rootPendingBoundaries.delete(offscreenInstance);
1135 +
1136 + if (rootPendingBoundaries.size === 0 && rootTransitions !== null) {
1137 + rootTransitions.forEach(transition => {
1138 + addTransitionCompleteCallbackToPendingTransition({
1139 + transitionName: transition.name,
1140 + startTime: transition.startTime,
1141 + });
1142 + });
1143 + }
1144 + }
1145 + } else if (!wasHidden && isHidden) {
1146 + // The suspense boundaries was just hidden. Add the boundary
1147 + // to the pending boundary set if it's there
1148 + rootPendingBoundaries.set(offscreenInstance, {
1149 + name,
1150 + });
1151 + }
1152 + }
1153 + }
1154 + }
1155 +}
1156 +
1157 function hideOrUnhideAllChildren(finishedWork, isHidden) {
1158 // Only hide or unhide the top-most host nodes.
1159 let hostSubtreeRoot = null;
@@ -2747,22 +2835,48 @@ function commitPassiveMountOnFiber(
2835 }
2836
2837 if (enableTransitionTracing) {
2838 + // Get the transitions that were initiatized during the render
2839 + // and add a start transition callback for each of them
2840 + const state = finishedWork.memoizedState;
2841 + // TODO Since it's a mutable field, this should live on the FiberRoot
2842 + if (state.transitions === null) {
2843 + state.transitions = new Set([]);
2844 + }
2845 + const pendingTransitions = state.transitions;
2846 + const pendingSuspenseBoundaries = state.pendingSuspenseBoundaries;
2847 +
2848 + // Initial render
2849 if (committedTransitions !== null) {
2850 committedTransitions.forEach(transition => {
2752 - // TODO(luna) Do we want to log TransitionStart in the startTransition callback instead?
2851 addTransitionStartCallbackToPendingTransition({
2852 transitionName: transition.name,
2853 startTime: transition.startTime,
2854 });
2855 + pendingTransitions.add(transition);
2856 + });
2857
2758 - addTransitionCompleteCallbackToPendingTransition({
2759 - transitionName: transition.name,
2760 - startTime: transition.startTime,
2858 + if (
2859 + pendingSuspenseBoundaries === null ||
2860 + pendingSuspenseBoundaries.size === 0
2861 + ) {
2862 + pendingTransitions.forEach(transition => {
2863 + addTransitionCompleteCallbackToPendingTransition({
2864 + transitionName: transition.name,
2865 + startTime: transition.startTime,
2866 + });
2867 });
2762 - });
2868 + }
2869
2870 clearTransitionsForLanes(finishedRoot, committedLanes);
2765 - finishedWork.memoizedState.transitions = null;
2871 + }
2872 +
2873 + // If there are no more pending suspense boundaries we
2874 + // clear the transitions because they are all complete.
2875 + if (
2876 + pendingSuspenseBoundaries === null ||
2877 + pendingSuspenseBoundaries.size === 0
2878 + ) {
2879 + state.transitions = null;
2880 }
2881 }
2882 break;
@@ -2800,9 +2914,44 @@ function commitPassiveMountOnFiber(
2914 }
2915
2916 if (enableTransitionTracing) {
2803 - // TODO: Add code to actually process the update queue
2917 + const isFallback = finishedWork.memoizedState;
2918 + const queue = (finishedWork.updateQueue: any);
2919 + const rootMemoizedState = finishedRoot.current.memoizedState;
2920 +
2921 + if (queue !== null) {
2922 + // We have one instance of the pendingSuspenseBoundaries map.
2923 + // We only need one because we update it during the commit phase.
2924 + // We instantiate a new Map if we haven't already
2925 + if (rootMemoizedState.pendingSuspenseBoundaries === null) {
2926 + rootMemoizedState.pendingSuspenseBoundaries = new Map();
2927 + }
2928 +
2929 + if (isFallback) {
2930 + const transitions = queue.transitions;
2931 + let prevTransitions = finishedWork.memoizedState.transitions;
2932 + // Add all the transitions saved in the update queue during
2933 + // the render phase (ie the transitions associated with this boundary)
2934 + // into the transitions set.
2935 + if (transitions !== null) {
2936 + if (prevTransitions === null) {
2937 + // We only have one instance of the transitions set
2938 + // because we update it only during the commit phase. We
2939 + // will create the set on a as needed basis in the commit phase
2940 + finishedWork.memoizedState.transitions = prevTransitions = new Set();
2941 + }
2942 +
2943 + transitions.forEach(transition => {
2944 + prevTransitions.add(transition);
2945 + });
2946 + }
2947 + }
2948 + }
2949 +
2950 + commitTransitionProgress(finishedRoot, finishedWork);
2951 +
2952 finishedWork.updateQueue = null;
2953 }
2954 +
2955 break;
2956 }
2957 case CacheComponent: {
packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js
+181
@@ -13,9 +13,14 @@ let ReactNoop;
13 let Scheduler;
14 let act;
15
16 +let getCacheForType;
17 let useState;
18 +let Suspense;
19 let startTransition;
20
21 +let caches;
22 +let seededCache;
23 +
24 describe('ReactInteractionTracing', () => {
25 beforeEach(() => {
26 jest.resetModules();
@@ -28,13 +33,121 @@ describe('ReactInteractionTracing', () => {
33
34 useState = React.useState;
35 startTransition = React.startTransition;
36 + Suspense = React.Suspense;
37 +
38 + getCacheForType = React.unstable_getCacheForType;
39 +
40 + caches = [];
41 + seededCache = null;
42 });
43
44 + function createTextCache() {
45 + if (seededCache !== null) {
46 + const cache = seededCache;
47 + seededCache = null;
48 + return cache;
49 + }
50 +
51 + const data = new Map();
52 + const cache = {
53 + data,
54 + resolve(text) {
55 + const record = data.get(text);
56 +
57 + if (record === undefined) {
58 + const newRecord = {
59 + status: 'resolved',
60 + value: text,
61 + };
62 + data.set(text, newRecord);
63 + } else if (record.status === 'pending') {
64 + const thenable = record.value;
65 + record.status = 'resolved';
66 + record.value = text;
67 + thenable.pings.forEach(t => t());
68 + }
69 + },
70 + reject(text, error) {
71 + const record = data.get(text);
72 + if (record === undefined) {
73 + const newRecord = {
74 + status: 'rejected',
75 + value: error,
76 + };
77 + data.set(text, newRecord);
78 + } else if (record.status === 'pending') {
79 + const thenable = record.value;
80 + record.status = 'rejected';
81 + record.value = error;
82 + thenable.pings.forEach(t => t());
83 + }
84 + },
85 + };
86 + caches.push(cache);
87 + return cache;
88 + }
89 +
90 + function readText(text) {
91 + const textCache = getCacheForType(createTextCache);
92 + const record = textCache.data.get(text);
93 + if (record !== undefined) {
94 + switch (record.status) {
95 + case 'pending':
96 + Scheduler.unstable_yieldValue(`Suspend [${text}]`);
97 + throw record.value;
98 + case 'rejected':
99 + Scheduler.unstable_yieldValue(`Error [${text}]`);
100 + throw record.value;
101 + case 'resolved':
102 + return record.value;
103 + }
104 + } else {
105 + Scheduler.unstable_yieldValue(`Suspend [${text}]`);
106 +
107 + const thenable = {
108 + pings: [],
109 + then(resolve) {
110 + if (newRecord.status === 'pending') {
111 + thenable.pings.push(resolve);
112 + } else {
113 + Promise.resolve().then(() => resolve(newRecord.value));
114 + }
115 + },
116 + };
117 +
118 + const newRecord = {
119 + status: 'pending',
120 + value: thenable,
121 + };
122 + textCache.data.set(text, newRecord);
123 +
124 + throw thenable;
125 + }
126 + }
127 +
128 + function AsyncText({text}) {
129 + const fullText = readText(text);
130 + Scheduler.unstable_yieldValue(fullText);
131 + return fullText;
132 + }
133 +
134 function Text({text}) {
135 Scheduler.unstable_yieldValue(text);
136 return text;
137 }
138
139 + function resolveMostRecentTextCache(text) {
140 + if (caches.length === 0) {
141 + throw Error('Cache does not exist');
142 + } else {
143 + // Resolve the most recently created cache. An older cache can by
144 + // resolved with `caches[index].resolve(text)`.
145 + caches[caches.length - 1].resolve(text);
146 + }
147 + }
148 +
149 + const resolveText = resolveMostRecentTextCache;
150 +
151 function advanceTimers(ms) {
152 // Note: This advances Jest's virtual time but not React's. Use
153 // ReactNoop.expire for that.
@@ -98,4 +211,72 @@ describe('ReactInteractionTracing', () => {
211 });
212 });
213 });
214 +
215 + // @gate enableTransitionTracing
216 + it('should correctly trace interactions for async roots', async () => {
217 + const transitionCallbacks = {
218 + onTransitionStart: (name, startTime) => {
219 + Scheduler.unstable_yieldValue(
220 + `onTransitionStart(${name}, ${startTime})`,
221 + );
222 + },
223 + onTransitionComplete: (name, startTime, endTime) => {
224 + Scheduler.unstable_yieldValue(
225 + `onTransitionComplete(${name}, ${startTime}, ${endTime})`,
226 + );
227 + },
228 + };
229 + let navigateToPageTwo;
230 + function App() {
231 + const [navigate, setNavigate] = useState(false);
232 + navigateToPageTwo = () => {
233 + setNavigate(true);
234 + };
235 +
236 + return (
237 + <div>
238 + {navigate ? (
239 + <Suspense
240 + fallback={<Text text="Loading..." />}
241 + name="suspense page">
242 + <AsyncText text="Page Two" />
243 + </Suspense>
244 + ) : (
245 + <Text text="Page One" />
246 + )}
247 + </div>
248 + );
249 + }
250 +
251 + const root = ReactNoop.createRoot({transitionCallbacks});
252 + await act(async () => {
253 + root.render(<App />);
254 + ReactNoop.expire(1000);
255 + await advanceTimers(1000);
256 +
257 + expect(Scheduler).toFlushAndYield(['Page One']);
258 + });
259 +
260 + await act(async () => {
261 + startTransition(() => navigateToPageTwo(), {name: 'page transition'});
262 +
263 + ReactNoop.expire(1000);
264 + await advanceTimers(1000);
265 +
266 + expect(Scheduler).toFlushAndYield([
267 + 'Suspend [Page Two]',
268 + 'Loading...',
269 + 'onTransitionStart(page transition, 1000)',
270 + ]);
271 +
272 + ReactNoop.expire(1000);
273 + await advanceTimers(1000);
274 + await resolveText('Page Two');
275 +
276 + expect(Scheduler).toFlushAndYield([
277 + 'Page Two',
278 + 'onTransitionComplete(page transition, 1000, 3000)',
279 + ]);
280 + });
281 + });
282 });