Skip to content

Commit 01ad906

Browse files
committed
[DevTools] Scuffed version of Suspense timeline
Only implements document-order reveal for now. Doesn't handle what happens if new Suspense boundaries are added while stepping through timeline. Current implementation leverages `input[type="range"]` since that matches the final UX pretty closely. The styling is certainly not final. This removes the scuffed Suspense treelist which will be replaced by Activity slices. The timeline and rects are sufficient replacement for debugging.
1 parent ae5c2f8 commit 01ad906

File tree

5 files changed

+186
-82
lines changed

5 files changed

+186
-82
lines changed

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7455,6 +7455,13 @@ export function attach(
74557455
}
74567456
74577457
function overrideSuspense(id: number, forceFallback: boolean) {
7458+
if (!supportsTogglingSuspense) {
7459+
// TODO:: Add getter to decide if overrideSuspense is available.
7460+
// Currently only available on inspectElement.
7461+
// Probably need a different affordance to batch since the timeline
7462+
// fallback is not the same as resuspending.
7463+
return;
7464+
}
74587465
if (
74597466
typeof setSuspenseHandler !== 'function' ||
74607467
typeof scheduleUpdate !== 'function'

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import InspectedElement from '../Components/InspectedElement';
2020
import portaledContent from '../portaledContent';
2121
import styles from './SuspenseTab.css';
2222
import SuspenseRects from './SuspenseRects';
23+
import SuspenseTimeline from './SuspenseTimeline';
2324
import SuspenseTreeList from './SuspenseTreeList';
2425
import Button from '../Button';
2526

@@ -45,10 +46,6 @@ type LayoutState = {
4546
};
4647
type LayoutDispatch = (action: LayoutAction) => void;
4748

48-
function SuspenseTimeline() {
49-
return <div className={styles.Timeline}>timeline</div>;
50-
}
51-
5249
function ToggleTreeList({
5350
dispatch,
5451
state,
@@ -308,7 +305,9 @@ function SuspenseTab(_: {}) {
308305
<div className={styles.TreeView}>
309306
<div className={styles.TimelineWrapper}>
310307
<ToggleTreeList dispatch={dispatch} state={state} />
311-
<SuspenseTimeline />
308+
<div className={styles.Timeline}>
309+
<SuspenseTimeline />
310+
</div>
312311
<ToggleInspectedElement
313312
dispatch={dispatch}
314313
state={state}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.SuspenseTimelineSlider {
2+
width: 100%;
3+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {Element, SuspenseNode} from '../../../frontend/types';
11+
import type Store from '../../store';
12+
13+
import * as React from 'react';
14+
import {useContext, useLayoutEffect, useMemo, useRef} from 'react';
15+
import {BridgeContext, StoreContext} from '../context';
16+
import {TreeDispatcherContext} from '../Components/TreeContext';
17+
import {useHighlightHostInstance} from '../hooks';
18+
import {SuspenseTreeStateContext} from './SuspenseTreeContext';
19+
import styles from './SuspenseTimeline.css';
20+
21+
// TODO: This returns the roots which would mean we attempt to suspend the shell.
22+
// Suspending the shell is currently not supported and we don't have a good view
23+
// for inspecting the root. But we probably should?
24+
function getDocumentOrderSuspense(
25+
store: Store,
26+
roots: $ReadOnlyArray<Element['id']>,
27+
): Array<SuspenseNode> {
28+
const suspenseTreeList: SuspenseNode[] = [];
29+
for (let i = 0; i < roots.length; i++) {
30+
const root = store.getElementByID(roots[i]);
31+
if (root === null) {
32+
continue;
33+
}
34+
const suspense = store.getSuspenseByID(root.id);
35+
if (suspense !== null) {
36+
const stack = [suspense];
37+
while (stack.length > 0) {
38+
const current = stack.pop();
39+
if (current === undefined) {
40+
continue;
41+
}
42+
suspenseTreeList.push(current);
43+
// Add children in reverse order to maintain document order
44+
for (let j = current.children.length - 1; j >= 0; j--) {
45+
const childSuspense = store.getSuspenseByID(current.children[j]);
46+
if (childSuspense !== null) {
47+
stack.push(childSuspense);
48+
}
49+
}
50+
}
51+
}
52+
}
53+
54+
return suspenseTreeList;
55+
}
56+
57+
export default function SuspenseTimeline(): React$Node {
58+
const bridge = useContext(BridgeContext);
59+
const store = useContext(StoreContext);
60+
const dispatch = useContext(TreeDispatcherContext);
61+
const {shells} = useContext(SuspenseTreeStateContext);
62+
63+
const timeline = useMemo(() => {
64+
return getDocumentOrderSuspense(store, shells);
65+
}, [store, shells]);
66+
67+
const {highlightHostInstance, clearHighlightHostInstance} =
68+
useHighlightHostInstance();
69+
70+
const inputRef = useRef<HTMLElement | null>(null);
71+
const inputBBox = useRef<ClientRect | null>(null);
72+
useLayoutEffect(() => {
73+
const input = inputRef.current;
74+
if (input === null) {
75+
throw new Error('Expected an input HTML element to be present.');
76+
}
77+
78+
inputBBox.current = input.getBoundingClientRect();
79+
const observer = new ResizeObserver(entries => {
80+
inputBBox.current = input.getBoundingClientRect();
81+
});
82+
observer.observe(input);
83+
return () => {
84+
inputBBox.current = null;
85+
observer.disconnect();
86+
};
87+
}, []);
88+
89+
const min = 0;
90+
const max = timeline.length - 1;
91+
92+
function handleChange(event: SyntheticEvent<HTMLInputElement>) {
93+
const value = +event.currentTarget.value;
94+
for (let i = 0; i < timeline.length; i++) {
95+
const forceFallback = i > value;
96+
const suspense = timeline[i];
97+
const elementID = suspense.id;
98+
const rendererID = store.getRendererIDForElement(elementID);
99+
if (rendererID === null) {
100+
// TODO: This sounds like a bug.
101+
console.warn(
102+
`No renderer ID found for element ${elementID} in suspense timeline.`,
103+
);
104+
} else {
105+
bridge.send('overrideSuspense', {
106+
id: elementID,
107+
rendererID,
108+
forceFallback,
109+
});
110+
}
111+
}
112+
113+
const suspense = timeline[value];
114+
const elementID = suspense.id;
115+
highlightHostInstance(elementID);
116+
dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: elementID});
117+
}
118+
119+
function handleBlur() {
120+
clearHighlightHostInstance();
121+
}
122+
123+
function handleFocus(event: SyntheticEvent<HTMLInputElement>) {
124+
const value = +event.currentTarget.value;
125+
const suspense = timeline[value];
126+
127+
highlightHostInstance(suspense.id);
128+
}
129+
130+
function handlePointerMove(event: SyntheticPointerEvent<HTMLInputElement>) {
131+
const bbox = inputBBox.current;
132+
if (bbox === null) {
133+
throw new Error('Bounding box of slider is unknown.');
134+
}
135+
136+
const value = Math.max(
137+
min,
138+
Math.min(
139+
Math.round(
140+
min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
141+
),
142+
max,
143+
),
144+
);
145+
const suspense = timeline[value];
146+
if (suspense === undefined) {
147+
throw new Error(
148+
`Suspense node not found for value ${value} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
149+
);
150+
}
151+
highlightHostInstance(suspense.id);
152+
}
153+
154+
return (
155+
<div>
156+
<input
157+
className={styles.SuspenseTimelineSlider}
158+
type="range"
159+
min={min}
160+
max={max}
161+
defaultValue={max}
162+
onBlur={handleBlur}
163+
onChange={handleChange}
164+
onFocus={handleFocus}
165+
onPointerMove={handlePointerMove}
166+
onPointerUp={clearHighlightHostInstance}
167+
ref={inputRef}
168+
/>
169+
</div>
170+
);
171+
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js

Lines changed: 1 addition & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,85 +6,9 @@
66
*
77
* @flow
88
*/
9-
import type {SuspenseNode} from '../../../frontend/types';
10-
import type Store from '../../store';
119

1210
import * as React from 'react';
13-
import {useContext} from 'react';
14-
import {StoreContext} from '../context';
15-
import {SuspenseTreeStateContext} from './SuspenseTreeContext';
16-
import {TreeDispatcherContext} from '../Components/TreeContext';
17-
18-
function getDocumentOrderSuspenseTreeList(store: Store): Array<SuspenseNode> {
19-
const suspenseTreeList: SuspenseNode[] = [];
20-
for (let i = 0; i < store.roots.length; i++) {
21-
const root = store.getElementByID(store.roots[i]);
22-
if (root === null) {
23-
continue;
24-
}
25-
const suspense = store.getSuspenseByID(root.id);
26-
if (suspense !== null) {
27-
const stack = [suspense];
28-
while (stack.length > 0) {
29-
const current = stack.pop();
30-
if (current === undefined) {
31-
continue;
32-
}
33-
suspenseTreeList.push(current);
34-
// Add children in reverse order to maintain document order
35-
for (let j = current.children.length - 1; j >= 0; j--) {
36-
const childSuspense = store.getSuspenseByID(current.children[j]);
37-
if (childSuspense !== null) {
38-
stack.push(childSuspense);
39-
}
40-
}
41-
}
42-
}
43-
}
44-
45-
return suspenseTreeList;
46-
}
4711

4812
export default function SuspenseTreeList(_: {}): React$Node {
49-
const store = useContext(StoreContext);
50-
const treeDispatch = useContext(TreeDispatcherContext);
51-
useContext(SuspenseTreeStateContext);
52-
53-
const suspenseTreeList = getDocumentOrderSuspenseTreeList(store);
54-
55-
return (
56-
<div>
57-
<p>Suspense Tree List</p>
58-
<ul>
59-
{suspenseTreeList.map(suspense => {
60-
const {id, parentID, children, name} = suspense;
61-
return (
62-
<li key={id}>
63-
<div>
64-
<button
65-
onClick={() => {
66-
treeDispatch({
67-
type: 'SELECT_ELEMENT_BY_ID',
68-
payload: id,
69-
});
70-
}}>
71-
inspect {name || 'N/A'} ({id})
72-
</button>
73-
</div>
74-
<div>
75-
<strong>Suspense ID:</strong> {id}
76-
</div>
77-
<div>
78-
<strong>Parent ID:</strong> {parentID}
79-
</div>
80-
<div>
81-
<strong>Children:</strong>{' '}
82-
{children.length === 0 ? '∅' : children.join(', ')}
83-
</div>
84-
</li>
85-
);
86-
})}
87-
</ul>
88-
</div>
89-
);
13+
return <div>Activity slices</div>;
9014
}

0 commit comments

Comments
 (0)