Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/react-devtools-shared/src/backend/fiber/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7455,6 +7455,13 @@ export function attach(
}

function overrideSuspense(id: number, forceFallback: boolean) {
if (!supportsTogglingSuspense) {
// TODO:: Add getter to decide if overrideSuspense is available.
// Currently only available on inspectElement.
// Probably need a different affordance to batch since the timeline
// fallback is not the same as resuspending.
return;
}
if (
typeof setSuspenseHandler !== 'function' ||
typeof scheduleUpdate !== 'function'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
padding: 0.25rem;
display: flex;
flex-direction: row;
align-items: flex-start;
}

.Timeline {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import InspectedElement from '../Components/InspectedElement';
import portaledContent from '../portaledContent';
import styles from './SuspenseTab.css';
import SuspenseRects from './SuspenseRects';
import SuspenseTimeline from './SuspenseTimeline';
import SuspenseTreeList from './SuspenseTreeList';
import Button from '../Button';

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

function SuspenseTimeline() {
return <div className={styles.Timeline}>timeline</div>;
}

function ToggleTreeList({
dispatch,
state,
Expand Down Expand Up @@ -308,7 +305,9 @@ function SuspenseTab(_: {}) {
<div className={styles.TreeView}>
<div className={styles.TimelineWrapper}>
<ToggleTreeList dispatch={dispatch} state={state} />
<SuspenseTimeline />
<div className={styles.Timeline}>
<SuspenseTimeline />
</div>
<ToggleInspectedElement
dispatch={dispatch}
state={state}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.SuspenseTimelineSlider {
width: 100%;
}

.SuspenseTimelineMarkers {
display: flex;
flex-direction: row;
justify-content: space-between;
}

.SuspenseTimelineMarkers > * {
flex: 1 1 0;
overflow: visible;
visibility: hidden;
width: 0
}

.SuspenseTimelineActiveMarker {
visibility: visible;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

import type {Element, SuspenseNode} from '../../../frontend/types';
import type Store from '../../store';

import * as React from 'react';
import {
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {BridgeContext, StoreContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {useHighlightHostInstance} from '../hooks';
import {SuspenseTreeStateContext} from './SuspenseTreeContext';
import styles from './SuspenseTimeline.css';

// TODO: This returns the roots which would mean we attempt to suspend the shell.
// Suspending the shell is currently not supported and we don't have a good view
// for inspecting the root. But we probably should?
function getDocumentOrderSuspense(
store: Store,
roots: $ReadOnlyArray<Element['id']>,
): Array<SuspenseNode> {
const suspenseTreeList: SuspenseNode[] = [];
for (let i = 0; i < roots.length; i++) {
const root = store.getElementByID(roots[i]);
if (root === null) {
continue;
}
const suspense = store.getSuspenseByID(root.id);
if (suspense !== null) {
const stack = [suspense];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) {
continue;
}
suspenseTreeList.push(current);
// Add children in reverse order to maintain document order
for (let j = current.children.length - 1; j >= 0; j--) {
const childSuspense = store.getSuspenseByID(current.children[j]);
if (childSuspense !== null) {
stack.push(childSuspense);
}
}
}
}
}

return suspenseTreeList;
}

export default function SuspenseTimeline(): React$Node {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const dispatch = useContext(TreeDispatcherContext);
const {shells} = useContext(SuspenseTreeStateContext);

const timeline = useMemo(() => {
return getDocumentOrderSuspense(store, shells);
}, [store, shells]);

const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();

const inputRef = useRef<HTMLElement | null>(null);
const inputBBox = useRef<ClientRect | null>(null);
useLayoutEffect(() => {
const input = inputRef.current;
if (input === null) {
throw new Error('Expected an input HTML element to be present.');
}

inputBBox.current = input.getBoundingClientRect();
const observer = new ResizeObserver(entries => {
inputBBox.current = input.getBoundingClientRect();
});
observer.observe(input);
return () => {
inputBBox.current = null;
observer.disconnect();
};
}, []);

const min = 0;
const max = timeline.length > 0 ? timeline.length - 1 : 0;

const [value, setValue] = useState(max);
if (value > max) {
// TODO: Handle timeline changes
setValue(max);
}
Comment on lines +100 to +103
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is not handled in handleChange func?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the change event it can't happen since that's constrained by the DOM. This is dealing with a change in the timeline on re-render e.g. you have 5 nodes, select the last one and then we re-render with only 4 nodes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeline changes need their own consideration. The timeline probably needs to live somewhere different. If we consider a document-order reveal, inserting a node before the milestone should immediately suspend it. But for random reveal, it's probably fine to just append the new node.

I feel like the timeline state is best kept in the renderer and then updates to the tree can be immediately reconciled with the timeline. Otherwise we have to reconcile the suspense tree, update it on the frontend, reconcile the timeline and send back the suspense overrides.


const markersID = useId();
const markers: React.Node[] = useMemo(() => {
return timeline.map((suspense, index) => {
const takesUpSpace =
suspense.rects !== null &&
suspense.rects.some(rect => {
return rect.width > 0 && rect.height > 0;
});

return takesUpSpace ? (
<option
key={suspense.id}
className={
index === value ? styles.SuspenseTimelineActiveMarker : undefined
}
value={index}>
#{index + 1}
</option>
) : (
<option key={suspense.id} />
);
});
}, [timeline, value]);

function handleChange(event: SyntheticEvent<HTMLInputElement>) {
const pendingValue = +event.currentTarget.value;
for (let i = 0; i < timeline.length; i++) {
const forceFallback = i > pendingValue;
const suspense = timeline[i];
const elementID = suspense.id;
const rendererID = store.getRendererIDForElement(elementID);
if (rendererID === null) {
// TODO: Handle disconnected elements.
console.warn(
`No renderer ID found for element ${elementID} in suspense timeline.`,
);
} else {
bridge.send('overrideSuspense', {
id: elementID,
rendererID,
forceFallback,
});
}
}

const suspense = timeline[pendingValue];
const elementID = suspense.id;
highlightHostInstance(elementID);
dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: elementID});
setValue(pendingValue);
}

function handleBlur() {
clearHighlightHostInstance();
}

function handleFocus() {
const suspense = timeline[value];

highlightHostInstance(suspense.id);
}

function handlePointerMove(event: SyntheticPointerEvent<HTMLInputElement>) {
const bbox = inputBBox.current;
if (bbox === null) {
throw new Error('Bounding box of slider is unknown.');
}

const hoveredValue = Math.max(
min,
Math.min(
Math.round(
min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
),
max,
),
);
const suspense = timeline[hoveredValue];
if (suspense === undefined) {
throw new Error(
`Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
);
}
highlightHostInstance(suspense.id);
}

return (
<div>
<input
className={styles.SuspenseTimelineSlider}
type="range"
min={min}
max={max}
list={markersID}
value={value}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onPointerMove={handlePointerMove}
onPointerUp={clearHighlightHostInstance}
ref={inputRef}
/>
<datalist id={markersID} className={styles.SuspenseTimelineMarkers}>
{markers}
</datalist>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,85 +6,9 @@
*
* @flow
*/
import type {SuspenseNode} from '../../../frontend/types';
import type Store from '../../store';

import * as React from 'react';
import {useContext} from 'react';
import {StoreContext} from '../context';
import {SuspenseTreeStateContext} from './SuspenseTreeContext';
import {TreeDispatcherContext} from '../Components/TreeContext';

function getDocumentOrderSuspenseTreeList(store: Store): Array<SuspenseNode> {
const suspenseTreeList: SuspenseNode[] = [];
for (let i = 0; i < store.roots.length; i++) {
const root = store.getElementByID(store.roots[i]);
if (root === null) {
continue;
}
const suspense = store.getSuspenseByID(root.id);
if (suspense !== null) {
const stack = [suspense];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) {
continue;
}
suspenseTreeList.push(current);
// Add children in reverse order to maintain document order
for (let j = current.children.length - 1; j >= 0; j--) {
const childSuspense = store.getSuspenseByID(current.children[j]);
if (childSuspense !== null) {
stack.push(childSuspense);
}
}
}
}
}

return suspenseTreeList;
}

export default function SuspenseTreeList(_: {}): React$Node {
const store = useContext(StoreContext);
const treeDispatch = useContext(TreeDispatcherContext);
useContext(SuspenseTreeStateContext);

const suspenseTreeList = getDocumentOrderSuspenseTreeList(store);

return (
<div>
<p>Suspense Tree List</p>
<ul>
{suspenseTreeList.map(suspense => {
const {id, parentID, children, name} = suspense;
return (
<li key={id}>
<div>
<button
onClick={() => {
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: id,
});
}}>
inspect {name || 'N/A'} ({id})
</button>
</div>
<div>
<strong>Suspense ID:</strong> {id}
</div>
<div>
<strong>Parent ID:</strong> {parentID}
</div>
<div>
<strong>Children:</strong>{' '}
{children.length === 0 ? '∅' : children.join(', ')}
</div>
</li>
);
})}
</ul>
</div>
);
return <div>Activity slices</div>;
}
Loading