From 75364aa37b6230ba024db58ea61ca9c207e2118c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:40:49 +0000 Subject: [PATCH 01/12] website: Reset playground previews after edit-caused render errors An edit can leave store data unreadable by the new code (e.g. changing Entity.key). After such a render error the preview now retries once with a fresh store, restoring the old one if the error persists. Render errors also get a Reset preview button, and the Live Preview header has a reset icon. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XvW9Gn7xGDsdcB7de2HX8a --- website/src/components/Playground/README.md | 7 ++ .../Playground/preview/LivePreview.tsx | 19 +++- .../components/Playground/preview/Preview.tsx | 35 ++++++-- .../Playground/preview/PreviewBlock.tsx | 11 ++- .../Playground/preview/PreviewError.tsx | 87 +++++++++++++++++++ .../Playground/preview/PreviewWrapper.tsx | 2 +- .../preview/__tests__/usePreviewReset.test.ts | 69 +++++++++++++++ .../Playground/preview/usePreviewReset.ts | 84 ++++++++++++++++++ .../components/Playground/styles.module.css | 53 +++++++++++ 9 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 website/src/components/Playground/preview/PreviewError.tsx create mode 100644 website/src/components/Playground/preview/__tests__/usePreviewReset.test.ts create mode 100644 website/src/components/Playground/preview/usePreviewReset.ts diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 1d3b129aee22..533def8d79b6 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -133,6 +133,13 @@ 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 `` 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. - Third-party console noise is demoted only while previews are mounted (`usePlaygroundConsoleDemotion`); never add first-party matchers. diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 36383b041a05..9a16fd2ab0f9 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -1,11 +1,13 @@ 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 extends PreviewProps { @@ -24,17 +26,26 @@ export default function LivePreview({ }: LivePreviewProps) { usePlaygroundConsoleDemotion(); const { onCommit, badge } = useRenderCount(renderCount); + const { key, initialState, controller, reset, ...handlers } = + usePreviewReset(code); return ( - + + {badge} + + + } + > ({ fixtures={fixtures} getInitialInterceptorData={getInitialInterceptorData} onCommit={onCommit} + initialState={initialState} + controller={controller} + onReset={reset} + {...handlers} /> diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index e81d49e320bb..e18413e90781 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -3,6 +3,9 @@ import { PollingSubscription, SubscriptionManager, NetworkManager, + useController, + type Controller, + type State, } from '@data-client/react'; import { MockResolver } from '@data-client/test/browser'; import { useScrollPositionBlocker } from '@docusaurus/theme-common/internal'; @@ -10,12 +13,14 @@ import clsx from 'clsx'; import React, { memo, useCallback, + useImperativeHandle, useMemo, lazy, type ProfilerOnRenderCallback, } 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'; @@ -28,10 +33,19 @@ function Preview({ fixtures, getInitialInterceptorData, onCommit, -}: PreviewProps & { - /** Called on every React commit of the live result (enables a ``) */ - onCommit?: ProfilerOnRenderCallback; -}) { + initialState, + controller, + onInteract, + ...errorProps +}: PreviewProps & + PreviewErrorProps & { + /** Called on every React commit of the live result (enables a ``) */ + onCommit?: ProfilerOnRenderCallback; + initialState?: State; + controller: React.Ref; + /** User pointer/keyboard input inside the result */ + onInteract: () => void; + }) { const [choice, setTabGroupChoice] = useTabStorage(groupId); const selectedValue = choice === 'y' || choice === 'n' ? choice : defaultOpen; const { blockElementScrollPositionUntilNextRender } = @@ -57,7 +71,8 @@ function Preview({ const hiddenResult = row && selectedValue === 'y'; return ( - + + ({ className={clsx('playground-preview', styles.playgroundPreview, { [styles.hidden]: hiddenResult, })} + onPointerDownCapture={onInteract} + onKeyDownCapture={onInteract} > - + @@ -79,6 +96,12 @@ function Preview({ } export default memo(Preview); +function ControllerHandle({ handle }: { handle: React.Ref }) { + const controller = useController(); + useImperativeHandle(handle, () => controller, [controller]); + return null; +} + const PreviewBlockLazy = lazy( () => import( diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index b58ba5c17b80..dfe1b3bee21e 100644 --- a/website/src/components/Playground/preview/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -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 { Loading } from '../DesignSystem/Loading'; -import styles from '../styles.module.css'; +import PreviewError, { type PreviewErrorProps } from './PreviewError'; -function PreviewBlock({ onCommit }: { onCommit?: ProfilerOnRenderCallback }) { +function PreviewBlock({ + onCommit, + ...errorProps +}: PreviewErrorProps & { onCommit?: ProfilerOnRenderCallback }) { return ( <> }> @@ -15,7 +18,7 @@ function PreviewBlock({ onCommit }: { onCommit?: ProfilerOnRenderCallback }) { : } - + ); } diff --git a/website/src/components/Playground/preview/PreviewError.tsx b/website/src/components/Playground/preview/PreviewError.tsx new file mode 100644 index 000000000000..2e9d02fe2036 --- /dev/null +++ b/website/src/components/Playground/preview/PreviewError.tsx @@ -0,0 +1,87 @@ +import React, { useContext, useEffect, useRef } from 'react'; +import { LiveContext } from 'react-live'; + +import styles from '../styles.module.css'; + +/** react-live's error, plus a reset when it came from rendering (a fresh store may fix it). + * + * Compile and evaluation errors never render the new code, so only errors after + * the current code rendered count as render errors. + */ +export default function PreviewError({ + onReset, + onRenderError, + onHealthy, +}: PreviewErrorProps) { + const { error, element, code, newCode } = useContext(LiveContext); + const rendered = useRef(undefined); + if (!error && element && newCode === code) rendered.current = code; + const isRenderError = !!error && rendered.current === code; + + const isHealthy = !error && rendered.current === code; + + useEffect(() => { + if (isRenderError) onRenderError(code); + }, [isRenderError, code, onRenderError]); + // A render that throws commits once before its error arrives; waiting skips that. + useEffect(() => { + if (!isHealthy) return; + const timer = setTimeout(onHealthy, HEALTHY_AFTER_MS); + return () => clearTimeout(timer); + }, [isHealthy, code, onHealthy]); + + if (!error) return null; + return ( +
+
{error}
+ {isRenderError ? + + : null} +
+ ); +} + +const HEALTHY_AFTER_MS = 1000; + +export interface PreviewErrorProps { + onReset: () => void; + /** Called with the code whose render threw */ + onRenderError: (code: string) => void; + /** Called once the current code has rendered without error for a while */ + onHealthy: () => void; +} + +export function ResetButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +function ResetIcon() { + return ( + + ); +} diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index c83b9b02f804..b187fbf66a4b 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -14,7 +14,7 @@ export default function PreviewWrapper({ children, headerControls }: Props) { > 🔴 Live Preview - {headerControls} + {headerControls}
{children}
diff --git a/website/src/components/Playground/preview/__tests__/usePreviewReset.test.ts b/website/src/components/Playground/preview/__tests__/usePreviewReset.test.ts new file mode 100644 index 000000000000..443ecb757681 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/usePreviewReset.test.ts @@ -0,0 +1,69 @@ +/// + +import { act, renderHook } from '@testing-library/react'; + +import { usePreviewReset } from '../usePreviewReset'; + +const oldState = { entities: { Post: {} } } as any; + +function setup() { + const hook = renderHook(({ code }) => usePreviewReset(code), { + initialProps: { code: 'v1' }, + }); + (hook.result.current.controller as any).current = { + getState: () => oldState, + }; + return hook; +} + +it('retries an edit-caused render error once with a fresh store', () => { + const { result, rerender } = setup(); + act(() => result.current.onHealthy()); + rerender({ code: 'v2' }); + act(() => result.current.onRenderError('v2')); + expect(result.current.key).toBe(1); + expect(result.current.initialState).toBeUndefined(); +}); + +it('restores the old store when the fresh store errors too', () => { + const { result, rerender } = setup(); + act(() => result.current.onHealthy()); + rerender({ code: 'v2' }); + act(() => result.current.onRenderError('v2')); + act(() => result.current.onRenderError('v2')); + expect(result.current.key).toBe(2); + expect(result.current.initialState).toBe(oldState); + + // further typo keystrokes don't retry until the preview works again + rerender({ code: 'v3' }); + act(() => result.current.onRenderError('v3')); + expect(result.current.key).toBe(2); + act(() => result.current.onHealthy()); + rerender({ code: 'v4' }); + act(() => result.current.onRenderError('v4')); + expect(result.current.key).toBe(3); +}); + +it('never retries errors of the code the store was created with', () => { + const { result } = setup(); + act(() => result.current.onHealthy()); + act(() => result.current.onRenderError('v1')); + expect(result.current.key).toBe(0); +}); + +it('keeps the fresh store once the user interacts with it', () => { + const { result, rerender } = setup(); + act(() => result.current.onHealthy()); + rerender({ code: 'v2' }); + act(() => result.current.onRenderError('v2')); + act(() => result.current.onInteract()); + act(() => result.current.onRenderError('v2')); + expect(result.current.key).toBe(1); + expect(result.current.initialState).toBeUndefined(); +}); + +it('reset remounts with a fresh store', () => { + const { result } = setup(); + act(() => result.current.reset()); + expect(result.current.key).toBe(1); +}); diff --git a/website/src/components/Playground/preview/usePreviewReset.ts b/website/src/components/Playground/preview/usePreviewReset.ts new file mode 100644 index 000000000000..566b691de2ce --- /dev/null +++ b/website/src/components/Playground/preview/usePreviewReset.ts @@ -0,0 +1,84 @@ +import type { Controller, State } from '@data-client/react'; +import { useCallback, useRef, useState } from 'react'; + +interface PreviewStore { + /** Remounts the preview (and its store) when it changes */ + key: number; + /** Code this store's data was written under */ + code: string; + initialState?: State; + /** While trying a fresh store: the store it replaced, restored if the error persists */ + replaced?: { state: State; code: string }; + /** Set once the preview renders without error; spent by an automatic reset */ + canAutoReset: boolean; +} + +/** Store lifecycle of one live preview. + * + * An edit can leave data the old code wrote unreadable by the new code (e.g. a + * changed `Entity.key`). When a render error follows an edit, the preview + * retries once with a fresh store. If the error persists, the store was not the + * cause, so the old store comes back and no retry happens until the preview + * works again (typing through a typo retries at most once). + */ +export function usePreviewReset(code: string) { + const controller = useRef(null); + const [store, setStore] = useState({ + key: 0, + code, + canAutoReset: false, + }); + const codeRef = useRef(code); + codeRef.current = code; + + const reset = useCallback( + () => + setStore(s => ({ + key: s.key + 1, + code: codeRef.current, + canAutoReset: false, + })), + [], + ); + + const onRenderError = useCallback((errorCode: string) => { + const state = controller.current?.getState(); + setStore(s => { + if (s.replaced && s.code === errorCode) + return { + key: s.key + 1, + code: s.replaced.code, + initialState: s.replaced.state, + canAutoReset: false, + }; + if (s.code === errorCode || !s.canAutoReset || !state) return s; + return { + key: s.key + 1, + code: errorCode, + replaced: { state, code: s.code }, + canAutoReset: false, + }; + }); + }, []); + + const onHealthy = useCallback( + () => setStore(s => (s.canAutoReset ? s : { ...s, canAutoReset: true })), + [], + ); + + // Once the user interacts with the fresh store, its errors are their own. + const onInteract = useCallback( + () => setStore(s => (s.replaced ? { ...s, replaced: undefined } : s)), + [], + ); + + return { + key: store.key, + initialState: store.initialState, + controller, + reset, + onRenderError, + onHealthy, + onInteract, + }; +} diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index bb55a52b6907..d57e71e15f03 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -578,3 +578,56 @@ div.fixtureJson { color: inherit; cursor: pointer; } +.previewControls { + display: flex; + align-items: center; + gap: 0.5rem; +} +/* Negative margin keeps a touch-sized target without growing the header */ +.resetButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin: -0.5rem -0.5rem -0.5rem 0; + padding: 0; + border: none; + border-radius: var(--ifm-global-radius); + background: none; + color: inherit; + font-size: 1rem; + cursor: pointer; +} +.resetButton:focus-visible { + outline: 2px solid var(--ifm-color-primary); +} +@media (hover: hover) { + .resetButton:hover { + background-color: var(--pg-tab-hover-bg); + color: var(--pg-tab-hover); + } +} +.previewError button { + display: inline-flex; + align-items: center; + gap: 0.4em; + margin-top: 0.25rem; + padding: 0.35rem 0.75rem; + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: var(--ifm-global-radius); + background: none; + color: inherit; + font: inherit; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; +} +@media (hover: hover) { + .previewError button:hover { + background-color: var(--ifm-color-emphasis-200); + } +} +.previewError button:focus-visible { + outline: 2px solid var(--ifm-color-primary); +} From 139eecbdb3c7379849381df1e66ab356cc277931 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:47:06 +0000 Subject: [PATCH 02/12] website: Simplify playground reset plumbing Read render-vs-compile errors from react-live's newCode, pass the store snapshot from PreviewError instead of a controller ref, and reuse the preview's base button skin and clean-btn. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XvW9Gn7xGDsdcB7de2HX8a --- .../Playground/preview/LivePreview.tsx | 4 +-- .../components/Playground/preview/Preview.tsx | 12 ------- .../Playground/preview/PreviewBlock.tsx | 2 +- .../Playground/preview/PreviewError.tsx | 32 +++++++++-------- .../preview/__tests__/usePreviewReset.test.ts | 22 +++++------- .../Playground/preview/usePreviewReset.ts | 35 +++++++++---------- .../components/Playground/styles.module.css | 22 ------------ 7 files changed, 45 insertions(+), 84 deletions(-) diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 9a16fd2ab0f9..1143b5abdbaa 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -26,8 +26,7 @@ export default function LivePreview({ }: LivePreviewProps) { usePlaygroundConsoleDemotion(); const { onCommit, badge } = useRenderCount(renderCount); - const { key, initialState, controller, reset, ...handlers } = - usePreviewReset(code); + const { key, initialState, reset, ...handlers } = usePreviewReset(code); return ( ({ getInitialInterceptorData={getInitialInterceptorData} onCommit={onCommit} initialState={initialState} - controller={controller} onReset={reset} {...handlers} /> diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index e18413e90781..1b5c6bdc9f7d 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -3,8 +3,6 @@ import { PollingSubscription, SubscriptionManager, NetworkManager, - useController, - type Controller, type State, } from '@data-client/react'; import { MockResolver } from '@data-client/test/browser'; @@ -13,7 +11,6 @@ import clsx from 'clsx'; import React, { memo, useCallback, - useImperativeHandle, useMemo, lazy, type ProfilerOnRenderCallback, @@ -34,7 +31,6 @@ function Preview({ getInitialInterceptorData, onCommit, initialState, - controller, onInteract, ...errorProps }: PreviewProps & @@ -42,7 +38,6 @@ function Preview({ /** Called on every React commit of the live result (enables a ``) */ onCommit?: ProfilerOnRenderCallback; initialState?: State; - controller: React.Ref; /** User pointer/keyboard input inside the result */ onInteract: () => void; }) { @@ -72,7 +67,6 @@ function Preview({ const hiddenResult = row && selectedValue === 'y'; return ( - ({ } export default memo(Preview); -function ControllerHandle({ handle }: { handle: React.Ref }) { - const controller = useController(); - useImperativeHandle(handle, () => controller, [controller]); - return null; -} - const PreviewBlockLazy = lazy( () => import( diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index dfe1b3bee21e..65e07e84c6b0 100644 --- a/website/src/components/Playground/preview/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -2,8 +2,8 @@ import React, { memo, Profiler, type ProfilerOnRenderCallback } from 'react'; import { LivePreview } from 'react-live'; import Boundary from '../Boundary'; -import { Loading } from '../DesignSystem/Loading'; import PreviewError, { type PreviewErrorProps } from './PreviewError'; +import { Loading } from '../DesignSystem/Loading'; function PreviewBlock({ onCommit, diff --git a/website/src/components/Playground/preview/PreviewError.tsx b/website/src/components/Playground/preview/PreviewError.tsx index 2e9d02fe2036..7d79c408d74b 100644 --- a/website/src/components/Playground/preview/PreviewError.tsx +++ b/website/src/components/Playground/preview/PreviewError.tsx @@ -1,29 +1,31 @@ -import React, { useContext, useEffect, useRef } from 'react'; +import { useController, type State } from '@data-client/react'; +import clsx from 'clsx'; +import React, { useContext, useEffect } from 'react'; import { LiveContext } from 'react-live'; import styles from '../styles.module.css'; /** react-live's error, plus a reset when it came from rendering (a fresh store may fix it). * - * Compile and evaluation errors never render the new code, so only errors after - * the current code rendered count as render errors. + * 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, element, code, newCode } = useContext(LiveContext); - const rendered = useRef(undefined); - if (!error && element && newCode === code) rendered.current = code; - const isRenderError = !!error && rendered.current === code; - - const isHealthy = !error && rendered.current === code; + const { error, code, newCode } = useContext(LiveContext); + const controller = useController(); + const rendered = newCode === code; + const isRenderError = !!error && rendered; + const isHealthy = !error && rendered; useEffect(() => { - if (isRenderError) onRenderError(code); - }, [isRenderError, code, onRenderError]); - // A render that throws commits once before its error arrives; waiting skips that. + if (isRenderError) onRenderError(code, controller.getState()); + }, [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, HEALTHY_AFTER_MS); @@ -47,8 +49,8 @@ const HEALTHY_AFTER_MS = 1000; export interface PreviewErrorProps { onReset: () => void; - /** Called with the code whose render threw */ - onRenderError: (code: string) => void; + /** Called with the code whose render threw and the store it threw with */ + onRenderError: (code: string, state: State) => void; /** Called once the current code has rendered without error for a while */ onHealthy: () => void; } @@ -57,7 +59,7 @@ export function ResetButton({ onClick }: { onClick: () => void }) { return ( - : null} - + + Reset preview + + : null + } + > + {/* Text content stays exactly `error`; the parts are only styled */} +
+        {name ?
+          {name}
+        : null}
+        {message}
+        {location ?
+          {location}
+        : null}
+      
+
); } @@ -63,6 +84,55 @@ export interface PreviewErrorProps { 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 type ErrorKind = 'compile' | 'runtime' | 'network'; + +const LABELS: Record = { + compile: 'Compile error', + runtime: 'Runtime error', + network: 'Network error', +}; + +/** Card framing a preview failure: what stage failed (compile, runtime, network) and the message */ +export function ErrorPanel({ + kind, + children, + action, +}: { + kind: ErrorKind; + children: ReactNode; + action?: ReactNode; +}) { + return ( +
+
+ + {LABELS[kind]} +
+ {children} + {action} +
+ ); +} + export function ResetButton({ onClick }: { onClick: () => void }) { return (
+ } + >
- {networkError.message} {networkError.status} + {networkError.status !== undefined ? + + {networkError.status} + + : null}{' '} + {networkError.message}
- - + ); }} > From e2d926eb1b35ff53ccc61c8fe1b1ab7769fc09b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:24:48 +0000 Subject: [PATCH 10/12] website: Bold the name of plain Error messages in the playground error card Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XvW9Gn7xGDsdcB7de2HX8a --- website/src/components/Playground/preview/PreviewError.tsx | 2 +- .../Playground/preview/__tests__/PreviewError.test.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/website/src/components/Playground/preview/PreviewError.tsx b/website/src/components/Playground/preview/PreviewError.tsx index 00f43ac3879c..1d42934d775f 100644 --- a/website/src/components/Playground/preview/PreviewError.tsx +++ b/website/src/components/Playground/preview/PreviewError.tsx @@ -86,7 +86,7 @@ export interface PreviewErrorProps { /** "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); + const named = /^((?:[A-Z][A-Za-z]*)?Error)(:[\s\S]*)?$/.exec(error); let name = ''; let message = error; if (named) { diff --git a/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx b/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx index cd3f353fc7ae..0f872146ba54 100644 --- a/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx +++ b/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx @@ -39,6 +39,7 @@ it('offers a reset for render errors', async () => { const code = `function A() { throw new Error('boom'); }\nrender(
);`; const onRenderError = renderPreview(code); expect(await screen.findByText(/boom/)).toBeTruthy(); + expect(screen.getByText('Error').tagName).toBe('STRONG'); expect(screen.getByText('Runtime error')).toBeTruthy(); expect(screen.getByRole('button', { name: /Reset preview/ })).toBeTruthy(); await waitFor(() => From 65644efbdd93d7205dd143e11db3b3ffba6f4160 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:33:39 +0000 Subject: [PATCH 11/12] website: Simplify the playground error card Moves ErrorPanel into its own module with one table of labels and icons, classifies errors from the parsed name, and gives the network fallback a stable component so its card isn't remounted on every parent render. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XvW9Gn7xGDsdcB7de2HX8a --- .../src/components/Playground/ErrorPanel.tsx | 69 +++++++++++++++++ website/src/components/Playground/README.md | 2 +- .../Playground/preview/PreviewError.tsx | 74 +------------------ .../components/Playground/styles.module.css | 5 +- .../components/ResettableErrorBoundary.tsx | 74 +++++++++---------- 5 files changed, 112 insertions(+), 112 deletions(-) create mode 100644 website/src/components/Playground/ErrorPanel.tsx diff --git a/website/src/components/Playground/ErrorPanel.tsx b/website/src/components/Playground/ErrorPanel.tsx new file mode 100644 index 000000000000..ac4f9e8dc4fe --- /dev/null +++ b/website/src/components/Playground/ErrorPanel.tsx @@ -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 = { + // 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 ( +
+
+ + {label} +
+ {children} + {action} +
+ ); +} diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 4eaf201fd4d7..1e73c1e9d69e 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -140,7 +140,7 @@ DesignSystem/ components injected into preview scope 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 labeled by the stage that failed: amber +- 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`. diff --git a/website/src/components/Playground/preview/PreviewError.tsx b/website/src/components/Playground/preview/PreviewError.tsx index 1d42934d775f..e712b2fe0025 100644 --- a/website/src/components/Playground/preview/PreviewError.tsx +++ b/website/src/components/Playground/preview/PreviewError.tsx @@ -1,9 +1,10 @@ import { useController } from '@data-client/react'; import clsx from 'clsx'; -import React, { useContext, useEffect, type ReactNode } from 'react'; +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). @@ -41,10 +42,9 @@ export default function PreviewError({ }, [isHealthy, code, onHealthy]); if (!error) return null; - // react-live reports transform failures (and a missing `render()`) as SyntaxErrors - const kind = - !isRenderError && error.startsWith('SyntaxError') ? 'compile' : 'runtime'; const { name, message, location } = splitError(error); + // react-live reports transform failures (and a missing `render()`) as SyntaxErrors + const kind = !isRenderError && name === 'SyntaxError' ? 'compile' : 'runtime'; return ( = { - compile: 'Compile error', - runtime: 'Runtime error', - network: 'Network error', -}; - -/** Card framing a preview failure: what stage failed (compile, runtime, network) and the message */ -export function ErrorPanel({ - kind, - children, - action, -}: { - kind: ErrorKind; - children: ReactNode; - action?: ReactNode; -}) { - return ( -
-
- - {LABELS[kind]} -
- {children} - {action} -
- ); -} - export function ResetButton({ onClick }: { onClick: () => void }) { return ( - } - > -
- {networkError.status !== undefined ? - - {networkError.status} - - : null}{' '} - {networkError.message} -
-
- ); - }} + { + resetEntireStore(); + resetErrorBoundary(); + }} + > + Clear Error + + } > - {children} - +
+ {error.status !== undefined && ( + {error.status} + )} + {error.message} +
+
); } From 78ad1c8553a602118e209ee5c6a9d909032418e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:35:10 +0000 Subject: [PATCH 12/12] website: Cover the playground error card's healthy signal, plain throws and header reset Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XvW9Gn7xGDsdcB7de2HX8a --- .../Playground/preview/PreviewError.tsx | 4 +- .../preview/__tests__/PreviewError.test.tsx | 38 +++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/website/src/components/Playground/preview/PreviewError.tsx b/website/src/components/Playground/preview/PreviewError.tsx index e712b2fe0025..24536549ed5f 100644 --- a/website/src/components/Playground/preview/PreviewError.tsx +++ b/website/src/components/Playground/preview/PreviewError.tsx @@ -86,12 +86,12 @@ export interface PreviewErrorProps { /** "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); + 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] ?? ''; + message = named[2]; } let location = ''; // sucrase appends the position in the concatenated document diff --git a/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx b/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx index 0f872146ba54..7433a2743c36 100644 --- a/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx +++ b/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx @@ -3,15 +3,16 @@ import { DataProvider } from '@data-client/react'; import { MockResolver } from '@data-client/test'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { LivePreview, LiveProvider } from 'react-live'; -import PreviewError from '../PreviewError'; +import PreviewError, { ResetButton } from '../PreviewError'; // Guards PreviewError's reliance on react-live's undocumented `newCode`. function renderPreview(code: string) { const onRenderError = jest.fn(); + const onHealthy = jest.fn(); render( {}} onRenderError={onRenderError} - onHealthy={() => {}} + onHealthy={onHealthy} />
, ); - return onRenderError; + return { onRenderError, onHealthy }; } beforeEach(() => jest.spyOn(console, 'error').mockImplementation(() => {})); @@ -37,7 +38,7 @@ afterEach(() => jest.restoreAllMocks()); it('offers a reset for render errors', async () => { const code = `function A() { throw new Error('boom'); }\nrender();`; - const onRenderError = renderPreview(code); + const { onRenderError } = renderPreview(code); expect(await screen.findByText(/boom/)).toBeTruthy(); expect(screen.getByText('Error').tagName).toBe('STRONG'); expect(screen.getByText('Runtime error')).toBeTruthy(); @@ -52,7 +53,7 @@ it('offers a reset for render errors', async () => { }); it('never resets compile errors', async () => { - const onRenderError = renderPreview('render(
);'); + const { onRenderError } = renderPreview('render(
);'); expect(await screen.findByText(/SyntaxError/)).toBeTruthy(); expect(screen.getByText('Compile error')).toBeTruthy(); expect(screen.queryByRole('button')).toBeNull(); @@ -60,7 +61,7 @@ it('never resets compile errors', async () => { }); it('never resets evaluation errors', async () => { - const onRenderError = renderPreview( + const { onRenderError } = renderPreview( `throw new Error('eval');\nrender(
);`, ); expect(await screen.findByText(/eval/)).toBeTruthy(); @@ -69,3 +70,26 @@ it('never resets evaluation errors', async () => { expect(screen.queryByRole('button')).toBeNull(); expect(onRenderError).not.toHaveBeenCalled(); }); + +it('labels non-Error throws as runtime errors', async () => { + renderPreview(`function A() { throw 'boom'; }\nrender();`); + expect(await screen.findByText('boom')).toBeTruthy(); + expect(screen.getByText('Runtime error')).toBeTruthy(); +}); + +it('reports healthy once the code renders cleanly for a while', async () => { + const code = `render(
ok
);`; + const { onHealthy } = renderPreview(code); + expect(await screen.findByText('ok')).toBeTruthy(); + expect(onHealthy).not.toHaveBeenCalled(); + await waitFor(() => expect(onHealthy).toHaveBeenCalledWith(code), { + timeout: 2000, + }); +}); + +it('resets from the header button', () => { + const onClick = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Reset preview' })); + expect(onClick).toHaveBeenCalled(); +});