Skip to content

Commit 75dc002

Browse files
authored
[DevTools] Initial version of Suspense timeline (facebook#34233)
1 parent df10309 commit 75dc002

File tree

6 files changed

+245
-82
lines changed

6 files changed

+245
-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.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
padding: 0.25rem;
111111
display: flex;
112112
flex-direction: row;
113+
align-items: flex-start;
113114
}
114115

115116
.Timeline {

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
import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
@@ -46,10 +47,6 @@ type LayoutState = {
4647
};
4748
type LayoutDispatch = (action: LayoutAction) => void;
4849

49-
function SuspenseTimeline() {
50-
return <div className={styles.Timeline}>timeline</div>;
51-
}
52-
5350
function ToggleTreeList({
5451
dispatch,
5552
state,
@@ -309,7 +306,9 @@ function SuspenseTab(_: {}) {
309306
<div className={styles.TreeView}>
310307
<div className={styles.TimelineWrapper}>
311308
<ToggleTreeList dispatch={dispatch} state={state} />
312-
<SuspenseTimeline />
309+
<div className={styles.Timeline}>
310+
<SuspenseTimeline />
311+
</div>
313312
<ToggleInspectedElement
314313
dispatch={dispatch}
315314
state={state}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.SuspenseTimelineSlider {
2+
width: 100%;
3+
}
4+
5+
.SuspenseTimelineMarkers {
6+
display: flex;
7+
flex-direction: row;
8+
justify-content: space-between;
9+
}
10+
11+
.SuspenseTimelineMarkers > * {
12+
flex: 1 1 0;
13+
overflow: visible;
14+
visibility: hidden;
15+
width: 0
16+
}
17+
18+
.SuspenseTimelineActiveMarker {
19+
visibility: visible;
20+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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 {
15+
useContext,
16+
useId,
17+
useLayoutEffect,
18+
useMemo,
19+
useRef,
20+
useState,
21+
} from 'react';
22+
import {BridgeContext, StoreContext} from '../context';
23+
import {TreeDispatcherContext} from '../Components/TreeContext';
24+
import {useHighlightHostInstance} from '../hooks';
25+
import {SuspenseTreeStateContext} from './SuspenseTreeContext';
26+
import styles from './SuspenseTimeline.css';
27+
28+
// TODO: This returns the roots which would mean we attempt to suspend the shell.
29+
// Suspending the shell is currently not supported and we don't have a good view
30+
// for inspecting the root. But we probably should?
31+
function getDocumentOrderSuspense(
32+
store: Store,
33+
roots: $ReadOnlyArray<Element['id']>,
34+
): Array<SuspenseNode> {
35+
const suspenseTreeList: SuspenseNode[] = [];
36+
for (let i = 0; i < roots.length; i++) {
37+
const root = store.getElementByID(roots[i]);
38+
if (root === null) {
39+
continue;
40+
}
41+
const suspense = store.getSuspenseByID(root.id);
42+
if (suspense !== null) {
43+
const stack = [suspense];
44+
while (stack.length > 0) {
45+
const current = stack.pop();
46+
if (current === undefined) {
47+
continue;
48+
}
49+
suspenseTreeList.push(current);
50+
// Add children in reverse order to maintain document order
51+
for (let j = current.children.length - 1; j >= 0; j--) {
52+
const childSuspense = store.getSuspenseByID(current.children[j]);
53+
if (childSuspense !== null) {
54+
stack.push(childSuspense);
55+
}
56+
}
57+
}
58+
}
59+
}
60+
61+
return suspenseTreeList;
62+
}
63+
64+
export default function SuspenseTimeline(): React$Node {
65+
const bridge = useContext(BridgeContext);
66+
const store = useContext(StoreContext);
67+
const dispatch = useContext(TreeDispatcherContext);
68+
const {shells} = useContext(SuspenseTreeStateContext);
69+
70+
const timeline = useMemo(() => {
71+
return getDocumentOrderSuspense(store, shells);
72+
}, [store, shells]);
73+
74+
const {highlightHostInstance, clearHighlightHostInstance} =
75+
useHighlightHostInstance();
76+
77+
const inputRef = useRef<HTMLElement | null>(null);
78+
const inputBBox = useRef<ClientRect | null>(null);
79+
useLayoutEffect(() => {
80+
const input = inputRef.current;
81+
if (input === null) {
82+
throw new Error('Expected an input HTML element to be present.');
83+
}
84+
85+
inputBBox.current = input.getBoundingClientRect();
86+
const observer = new ResizeObserver(entries => {
87+
inputBBox.current = input.getBoundingClientRect();
88+
});
89+
observer.observe(input);
90+
return () => {
91+
inputBBox.current = null;
92+
observer.disconnect();
93+
};
94+
}, []);
95+
96+
const min = 0;
97+
const max = timeline.length > 0 ? timeline.length - 1 : 0;
98+
99+
const [value, setValue] = useState(max);
100+
if (value > max) {
101+
// TODO: Handle timeline changes
102+
setValue(max);
103+
}
104+
105+
const markersID = useId();
106+
const markers: React.Node[] = useMemo(() => {
107+
return timeline.map((suspense, index) => {
108+
const takesUpSpace =
109+
suspense.rects !== null &&
110+
suspense.rects.some(rect => {
111+
return rect.width > 0 && rect.height > 0;
112+
});
113+
114+
return takesUpSpace ? (
115+
<option
116+
key={suspense.id}
117+
className={
118+
index === value ? styles.SuspenseTimelineActiveMarker : undefined
119+
}
120+
value={index}>
121+
#{index + 1}
122+
</option>
123+
) : (
124+
<option key={suspense.id} />
125+
);
126+
});
127+
}, [timeline, value]);
128+
129+
function handleChange(event: SyntheticEvent<HTMLInputElement>) {
130+
const pendingValue = +event.currentTarget.value;
131+
for (let i = 0; i < timeline.length; i++) {
132+
const forceFallback = i > pendingValue;
133+
const suspense = timeline[i];
134+
const elementID = suspense.id;
135+
const rendererID = store.getRendererIDForElement(elementID);
136+
if (rendererID === null) {
137+
// TODO: Handle disconnected elements.
138+
console.warn(
139+
`No renderer ID found for element ${elementID} in suspense timeline.`,
140+
);
141+
} else {
142+
bridge.send('overrideSuspense', {
143+
id: elementID,
144+
rendererID,
145+
forceFallback,
146+
});
147+
}
148+
}
149+
150+
const suspense = timeline[pendingValue];
151+
const elementID = suspense.id;
152+
highlightHostInstance(elementID);
153+
dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: elementID});
154+
setValue(pendingValue);
155+
}
156+
157+
function handleBlur() {
158+
clearHighlightHostInstance();
159+
}
160+
161+
function handleFocus() {
162+
const suspense = timeline[value];
163+
164+
highlightHostInstance(suspense.id);
165+
}
166+
167+
function handlePointerMove(event: SyntheticPointerEvent<HTMLInputElement>) {
168+
const bbox = inputBBox.current;
169+
if (bbox === null) {
170+
throw new Error('Bounding box of slider is unknown.');
171+
}
172+
173+
const hoveredValue = Math.max(
174+
min,
175+
Math.min(
176+
Math.round(
177+
min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
178+
),
179+
max,
180+
),
181+
);
182+
const suspense = timeline[hoveredValue];
183+
if (suspense === undefined) {
184+
throw new Error(
185+
`Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
186+
);
187+
}
188+
highlightHostInstance(suspense.id);
189+
}
190+
191+
return (
192+
<div>
193+
<input
194+
className={styles.SuspenseTimelineSlider}
195+
type="range"
196+
min={min}
197+
max={max}
198+
list={markersID}
199+
value={value}
200+
onBlur={handleBlur}
201+
onChange={handleChange}
202+
onFocus={handleFocus}
203+
onPointerMove={handlePointerMove}
204+
onPointerUp={clearHighlightHostInstance}
205+
ref={inputRef}
206+
/>
207+
<datalist id={markersID} className={styles.SuspenseTimelineMarkers}>
208+
{markers}
209+
</datalist>
210+
</div>
211+
);
212+
}

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)