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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
"prettier": "3.9.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-live": "^4.0.0",
"react-native": "0.86.2",
"react-native-safe-area-context": "^5.0.0",
"react-native-screens": "^4.1.0",
Expand Down
69 changes: 69 additions & 0 deletions website/src/components/Playground/ErrorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React, { type ReactNode } from 'react';

import styles from './styles.module.css';

export type ErrorKind = 'compile' | 'runtime' | 'network';

/** Each kind's label and icon paths (24×24, stroked) */
const KINDS: Record<ErrorKind, { label: string; icon: string[] }> = {
// warning triangle
compile: {
label: 'Compile error',
icon: ['M12 3.5 2.5 20h19L12 3.5Z', 'M12 10v4.5', 'M12 17.5h.01'],
},
// octagon with ×
runtime: {
label: 'Runtime error',
icon: [
'M8 2.5h8l5.5 5.5v8L16 21.5H8L2.5 16V8L8 2.5Z',
'm9 9 6 6M15 9l-6 6',
],
},
// broken link
network: {
label: 'Network error',
icon: [
'M10.5 13.5a4 4 0 0 0 5.66 0l2.84-2.84a4 4 0 0 0-5.66-5.66L12 6.34',
'M13.5 10.5a4 4 0 0 0-5.66 0L5 13.34A4 4 0 0 0 10.66 19L12 17.66',
'M4 4l16 16',
],
},
};

/** Card framing a preview failure: what stage failed (compile, runtime, network) and the message */
export default function ErrorPanel({
kind,
children,
action,
}: {
kind: ErrorKind;
children: ReactNode;
action?: ReactNode;
}) {
const { label, icon } = KINDS[kind];
return (
<div className={styles.previewError} data-kind={kind}>
<div className={styles.errorKind}>
<svg
className={styles.errorIcon}
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{icon.map(d => (
<path key={d} d={d} />
))}
</svg>
{label}
</div>
{children}
{action}
</div>
);
}
15 changes: 14 additions & 1 deletion website/src/components/Playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,23 @@ DesignSystem/ components injected into preview scope
stays hidden if that ever stops working. `website/profiling-loader.js` pins
the build's DevTools check off, so only `<Profiler>` subtrees are ever timed
(otherwise DevTools users would profile every page).
- Reset: the header icon and the button under a render error remount the
preview with a fresh store (`usePreviewReset`). When a render error follows an
edit (e.g. a changed `Entity.key` leaves old data unreadable), the preview
retries once with a fresh store and restores the old store if the error
persists. It retries again only after the preview has rendered cleanly for a
second, so typing through a typo costs at most one retry. Compile and
evaluation errors never reset.
- Errors show in one `ErrorPanel` card (`Playground/ErrorPanel.tsx`) labeled by the stage that failed: amber
"Compile error" for react-live's `SyntaxError`s (the code never ran), red
"Runtime error" for anything thrown while running, and "Network error" for
`ResetableErrorBoundary`.
- Third-party console noise is demoted only while previews are mounted
(`usePlaygroundConsoleDemotion`); never add first-party matchers.

## Tests

`yarn test --selectProjects ReactDOM --testPathPatterns website/src/components/Playground`
(CI persists only this directory of the website for these tests.)
(CI persists only this directory of the website for these tests, without the
website's dependencies. `react-live` is also a root devDependency so the
`PreviewError` test can pin the react-live behavior it relies on.)
27 changes: 24 additions & 3 deletions website/src/components/Playground/preview/LivePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useMemo } from 'react';
import { LiveProvider } from 'react-live';

import Preview from './Preview';
import { ResetButton } from './PreviewError';
import PreviewWrapper from './PreviewWrapper';
import { useRenderCount } from './RenderCount';
import { previewScope } from './scope';
import transformCode from './transformCode';
import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion';
import { usePreviewReset } from './usePreviewReset';
import type { PreviewProps } from '../types';

export interface LivePreviewProps<T> extends PreviewProps<T> {
Expand All @@ -24,24 +27,42 @@ export default function LivePreview<T>({
}: LivePreviewProps<T>) {
usePlaygroundConsoleDemotion();
const { onCommit, badge } = useRenderCount(renderCount);
const { key, restored, reset, ...handlers } = usePreviewReset(code);
const getInterceptorData = useMemo(
() =>
restored ?
() => restored.interceptorData as T
: getInitialInterceptorData,
[restored, getInitialInterceptorData],
);

return (
<LiveProvider
key="preview"
key={key}
code={code}
transformCode={transformCode}
enableTypeScript
noInline
scope={previewScope}
>
<PreviewWrapper headerControls={badge}>
<PreviewWrapper
headerControls={
<>
{badge}
<ResetButton onClick={reset} />
</>
}
>
<Preview
groupId={groupId}
defaultOpen={defaultOpen}
row={row}
fixtures={fixtures}
getInitialInterceptorData={getInitialInterceptorData}
getInitialInterceptorData={getInterceptorData}
onCommit={onCommit}
initialState={restored?.state}
onReset={reset}
{...handlers}
/>
</PreviewWrapper>
</LiveProvider>
Expand Down
23 changes: 17 additions & 6 deletions website/src/components/Playground/preview/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
PollingSubscription,
SubscriptionManager,
NetworkManager,
type State,
} from '@data-client/react';
import { MockResolver } from '@data-client/test/browser';
import { useScrollPositionBlocker } from '@docusaurus/theme-common/internal';
Expand All @@ -16,6 +17,7 @@ import React, {
} from 'react';

import Boundary from '../Boundary';
import type { PreviewErrorProps } from './PreviewError';
import StoreInspector from './StoreInspector';
import { useTabStorage } from '../../../utils/tabStorage';
import styles from '../styles.module.css';
Expand All @@ -28,10 +30,17 @@ function Preview<T>({
fixtures,
getInitialInterceptorData,
onCommit,
}: PreviewProps<T> & {
/** Called on every React commit of the live result (enables a `<Profiler>`) */
onCommit?: ProfilerOnRenderCallback;
}) {
initialState,
onInteract,
...errorProps
}: PreviewProps<T> &
PreviewErrorProps & {
/** Called on every React commit of the live result (enables a `<Profiler>`) */
onCommit?: ProfilerOnRenderCallback;
initialState?: State<unknown>;
/** User pointer/keyboard input inside the result */
onInteract: () => void;
}) {
const [choice, setTabGroupChoice] = useTabStorage(groupId);
const selectedValue = choice === 'y' || choice === 'n' ? choice : defaultOpen;
const { blockElementScrollPositionUntilNextRender } =
Expand All @@ -57,7 +66,7 @@ function Preview<T>({

const hiddenResult = row && selectedValue === 'y';
return (
<DataProvider managers={managers}>
<DataProvider managers={managers} initialState={initialState}>
<MockResolver
fixtures={fixtures}
silenceMissing={true}
Expand All @@ -67,9 +76,11 @@ function Preview<T>({
className={clsx('playground-preview', styles.playgroundPreview, {
[styles.hidden]: hiddenResult,
})}
onPointerDownCapture={onInteract}
onKeyDownCapture={onInteract}
>
<Boundary fallback={null}>
<PreviewBlockLazy onCommit={onCommit} />
<PreviewBlockLazy onCommit={onCommit} {...errorProps} />
</Boundary>
</div>
<StoreInspector selectedValue={selectedValue} toggle={toggle} />
Expand Down
11 changes: 7 additions & 4 deletions website/src/components/Playground/preview/PreviewBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import React, { memo, Profiler, type ProfilerOnRenderCallback } from 'react';
import { LiveError, LivePreview } from 'react-live';
import { LivePreview } from 'react-live';

import Boundary from '../Boundary';
import PreviewError, { type PreviewErrorProps } from './PreviewError';
import { Loading } from '../DesignSystem/Loading';
import styles from '../styles.module.css';

function PreviewBlock({ onCommit }: { onCommit?: ProfilerOnRenderCallback }) {
function PreviewBlock({
onCommit,
...errorProps
}: PreviewErrorProps & { onCommit?: ProfilerOnRenderCallback }) {
return (
<>
<Boundary fallback={<Loading />}>
Expand All @@ -15,7 +18,7 @@ function PreviewBlock({ onCommit }: { onCommit?: ProfilerOnRenderCallback }) {
</Profiler>
: <LivePreview />}
</Boundary>
<LiveError className={styles.playgroundError} />
<PreviewError {...errorProps} />
</>
);
}
Expand Down
137 changes: 137 additions & 0 deletions website/src/components/Playground/preview/PreviewError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useController } from '@data-client/react';
import clsx from 'clsx';
import React, { useContext, useEffect } from 'react';
import { LiveContext } from 'react-live';

import type { PreviewSnapshot } from './usePreviewReset';
import ErrorPanel from '../ErrorPanel';
import styles from '../styles.module.css';

/** react-live's error, plus a reset when it came from rendering (a fresh store may fix it).
*
* react-live sets `newCode` only once code reaches render, so compile and
* evaluation errors leave it at older code.
*/
export default function PreviewError({
onReset,
onRenderError,
onHealthy,
}: PreviewErrorProps) {
const { error, code, newCode } = useContext(LiveContext);
const controller = useController();
const rendered = newCode === code;
const isRenderError = !!error && rendered;
const isHealthy = !error && rendered;

useEffect(() => {
if (!isRenderError) return;
onRenderError(code, {
// The old store's in-flight requests die with it, so drop their optimistic updates
state: { ...controller.getState(), optimistic: [] },
// MockResolver's controller holds the simulated server's data
interceptorData: (controller as { interceptorData?: unknown })
.interceptorData,
Comment thread
ntucker marked this conversation as resolved.
});
}, [isRenderError, code, onRenderError, controller]);
// react-live commits a throwing render once before reporting its error, so
// "healthy" means no error for a while.
useEffect(() => {
if (!isHealthy) return;
const timer = setTimeout(() => onHealthy(code), HEALTHY_AFTER_MS);
return () => clearTimeout(timer);
}, [isHealthy, code, onHealthy]);

if (!error) return null;
const { name, message, location } = splitError(error);
// react-live reports transform failures (and a missing `render()`) as SyntaxErrors
const kind = !isRenderError && name === 'SyntaxError' ? 'compile' : 'runtime';
Comment thread
ntucker marked this conversation as resolved.
return (
<ErrorPanel
kind={kind}
action={
isRenderError ?
<button
type="button"
className={styles.errorAction}
onClick={onReset}
>
<ResetIcon /> Reset preview
</button>
: null
}
>
{/* Text content stays exactly `error`; the parts are only styled */}
<pre className={styles.playgroundError}>
{name ?
<strong className={styles.errorName}>{name}</strong>
: null}
{message}
{location ?
<span className={styles.errorLocation}>{location}</span>
: null}
</pre>
</ErrorPanel>
);
}

const HEALTHY_AFTER_MS = 1000;

export interface PreviewErrorProps {
onReset: () => void;
/** Called with the code whose render threw and the preview it threw in */
onRenderError: (code: string, snapshot: PreviewSnapshot) => void;
/** Called once the current code has rendered without error for a while */
onHealthy: (code: string) => void;
}

/** "TypeError: msg (3:12)" → name "TypeError", message ": msg ", location "(3:12)" */
function splitError(error: string) {
const named = /^((?:[A-Z][A-Za-z]*)?Error)((?::[\s\S]*)?)$/.exec(error);
let name = '';
let message = error;
if (named) {
name = named[1];
message = named[2];
}
let location = '';
// sucrase appends the position in the concatenated document
const located = /^([\s\S]*?\s)(\(\d+:\d+\))$/.exec(message);
if (located) {
message = located[1];
location = located[2];
}
return { name, message, location };
}

export function ResetButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
className={clsx('clean-btn', styles.resetButton)}
title="Reset preview"
aria-label="Reset preview"
onClick={onClick}
>
<ResetIcon />
</button>
);
}

function ResetIcon() {
return (
<svg
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 12a9 9 0 1 0 2.64-6.36L3 8.3" />
<path d="M3 3v5.3h5.3" />
</svg>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function PreviewWrapper({ children, headerControls }: Props) {
Live Preview
</Translate>
</span>
{headerControls}
<span className={styles.previewControls}>{headerControls}</span>
</Header>
<div className={styles.playgroundResult}>{children}</div>
</div>
Expand Down
Loading
Loading