diff --git a/package.json b/package.json index 7117088a2c61..2b1b06376379 100644 --- a/package.json +++ b/package.json @@ -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", 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 1d3b129aee22..1e73c1e9d69e 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -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 `` 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.) diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 36383b041a05..1e5f24e415d3 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -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 extends PreviewProps { @@ -24,24 +27,42 @@ export default function LivePreview({ }: LivePreviewProps) { 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 ( - + + {badge} + + + } + > diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index e81d49e320bb..1b5c6bdc9f7d 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -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'; @@ -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'; @@ -28,10 +30,17 @@ function Preview({ fixtures, getInitialInterceptorData, onCommit, -}: PreviewProps & { - /** Called on every React commit of the live result (enables a ``) */ - onCommit?: ProfilerOnRenderCallback; -}) { + initialState, + onInteract, + ...errorProps +}: PreviewProps & + PreviewErrorProps & { + /** Called on every React commit of the live result (enables a ``) */ + onCommit?: ProfilerOnRenderCallback; + initialState?: State; + /** 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 +66,7 @@ function Preview({ const hiddenResult = row && selectedValue === 'y'; return ( - + ({ className={clsx('playground-preview', styles.playgroundPreview, { [styles.hidden]: hiddenResult, })} + onPointerDownCapture={onInteract} + onKeyDownCapture={onInteract} > - + diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index b58ba5c17b80..65e07e84c6b0 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 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 ( <> }> @@ -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..24536549ed5f --- /dev/null +++ b/website/src/components/Playground/preview/PreviewError.tsx @@ -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, + }); + }, [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'; + return ( + + Reset preview + + : null + } + > + {/* Text content stays exactly `error`; the parts are only styled */} +
+        {name ?
+          {name}
+        : null}
+        {message}
+        {location ?
+          {location}
+        : null}
+      
+
+ ); +} + +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 ( + + ); +} + +function ResetIcon() { + return ( + + ); +} diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index e773b46bec5e..3cbfcf633423 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -16,7 +16,7 @@ export default function PreviewWrapper({ children, headerControls }: Props) { Live Preview - {headerControls} + {headerControls}
{children}
diff --git a/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx b/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx new file mode 100644 index 000000000000..7433a2743c36 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx @@ -0,0 +1,95 @@ +/// +/// + +import { DataProvider } from '@data-client/react'; +import { MockResolver } from '@data-client/test'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { LivePreview, LiveProvider } from 'react-live'; + +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( + + ({ votes: 1 })} + > + + + {}} + onRenderError={onRenderError} + onHealthy={onHealthy} + /> + + + , + ); + return { onRenderError, onHealthy }; +} + +beforeEach(() => jest.spyOn(console, 'error').mockImplementation(() => {})); +afterEach(() => jest.restoreAllMocks()); + +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(() => + expect(onRenderError).toHaveBeenCalledWith(code, { + // in-flight requests die with the store, so their optimistic updates go too + state: expect.objectContaining({ optimistic: [] }), + interceptorData: { votes: 1 }, + }), + ); +}); + +it('never resets compile errors', async () => { + const { onRenderError } = renderPreview('render(
);'); + expect(await screen.findByText(/SyntaxError/)).toBeTruthy(); + expect(screen.getByText('Compile error')).toBeTruthy(); + expect(screen.queryByRole('button')).toBeNull(); + expect(onRenderError).not.toHaveBeenCalled(); +}); + +it('never resets evaluation errors', async () => { + const { onRenderError } = renderPreview( + `throw new Error('eval');\nrender(
);`, + ); + expect(await screen.findByText(/eval/)).toBeTruthy(); + // threw while running, though before anything rendered + expect(screen.getByText('Runtime error')).toBeTruthy(); + 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(); +}); 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..17b9b5f88285 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/usePreviewReset.test.ts @@ -0,0 +1,101 @@ +/// + +import { act, renderHook } from '@testing-library/react'; + +import { usePreviewReset } from '../usePreviewReset'; + +const oldSnapshot = { + state: { entities: { Post: {} } }, + interceptorData: { votes: 1 }, +} as any; + +function setup() { + return renderHook(({ code }) => usePreviewReset(code), { + initialProps: { code: 'v1' }, + }); +} + +it('retries an edit-caused render error once with a fresh store', () => { + const { result, rerender } = setup(); + act(() => result.current.onHealthy('v1')); + rerender({ code: 'v2' }); + act(() => result.current.onRenderError('v2', oldSnapshot)); + expect(result.current.key).toBe(1); + expect(result.current.restored).toBeUndefined(); +}); + +it('restores the old store when the fresh store errors too', () => { + const { result, rerender } = setup(); + act(() => result.current.onHealthy('v1')); + rerender({ code: 'v2' }); + act(() => result.current.onRenderError('v2', oldSnapshot)); + act(() => result.current.onRenderError('v2', oldSnapshot)); + expect(result.current.key).toBe(2); + expect(result.current.restored).toBe(oldSnapshot); + + // further typo keystrokes don't retry until the preview works again + rerender({ code: 'v3' }); + act(() => result.current.onRenderError('v3', oldSnapshot)); + expect(result.current.key).toBe(2); + act(() => result.current.onHealthy('v3fixed')); + rerender({ code: 'v4' }); + act(() => result.current.onRenderError('v4', oldSnapshot)); + expect(result.current.key).toBe(3); +}); + +it('never retries errors of code that already rendered cleanly', () => { + const { result, rerender } = setup(); + act(() => result.current.onHealthy('v1')); + rerender({ code: 'v2' }); + act(() => result.current.onHealthy('v2')); + act(() => result.current.onRenderError('v2', oldSnapshot)); + expect(result.current.key).toBe(0); +}); + +it('drops the old store once other code renders cleanly', () => { + const { result, rerender } = setup(); + act(() => result.current.onHealthy('v1')); + rerender({ code: 'v2' }); + act(() => result.current.onRenderError('v2', oldSnapshot)); + rerender({ code: 'v3' }); + act(() => result.current.onHealthy('v3')); + act(() => result.current.onRenderError('v3', oldSnapshot)); + expect(result.current.key).toBe(1); + expect(result.current.restored).toBeUndefined(); +}); + +it('never retries errors of the code the store was created with', () => { + const { result } = setup(); + act(() => result.current.onHealthy('v1')); + act(() => result.current.onRenderError('v1', oldSnapshot)); + 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('v1')); + rerender({ code: 'v2' }); + act(() => result.current.onRenderError('v2', oldSnapshot)); + act(() => result.current.onInteract()); + act(() => result.current.onRenderError('v2', oldSnapshot)); + expect(result.current.key).toBe(1); + expect(result.current.restored).toBeUndefined(); +}); + +it('reset remounts with a fresh store', () => { + const { result } = setup(); + act(() => result.current.reset()); + expect(result.current.key).toBe(1); +}); + +it('keeps state identity when nothing changes', () => { + const { result } = setup(); + act(() => result.current.onHealthy('v1')); + const before = result.current.onInteract; + act(() => { + result.current.onHealthy('v1'); + result.current.onInteract(); + }); + expect(result.current.key).toBe(0); + expect(result.current.onInteract).toBe(before); +}); diff --git a/website/src/components/Playground/preview/usePreviewReset.ts b/website/src/components/Playground/preview/usePreviewReset.ts new file mode 100644 index 000000000000..6a82ed1c5031 --- /dev/null +++ b/website/src/components/Playground/preview/usePreviewReset.ts @@ -0,0 +1,100 @@ +import type { State } from '@data-client/react'; +import { useCallback, useRef, useState } from 'react'; + +/** What a preview needs to come back as it was: the store and the simulated server */ +export interface PreviewSnapshot { + state: State; + interceptorData: unknown; +} + +interface PreviewStore { + /** Remounts the preview (and its store) when it changes */ + key: number; + /** Code this store last rendered cleanly under (or was created with) */ + code: string; + restored?: PreviewSnapshot; + /** While trying a fresh store: the store it replaced, restored if the error persists */ + replaced?: { snapshot: PreviewSnapshot; 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 [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, snapshot: PreviewSnapshot) => + setStore(s => { + if (s.replaced && s.code === errorCode) + return { + key: s.key + 1, + code: s.replaced.code, + restored: s.replaced.snapshot, + canAutoReset: false, + }; + if (s.code === errorCode || !s.canAutoReset) return s; + return { + key: s.key + 1, + code: errorCode, + replaced: { snapshot, code: s.code }, + canAutoReset: false, + }; + }), + [], + ); + + // A clean render of other code ends any retry: its old store no longer applies. + const onHealthy = useCallback( + (healthyCode: string) => + setStore(s => { + if (s.code !== healthyCode) + return { + ...s, + code: healthyCode, + replaced: undefined, + canAutoReset: true, + }; + return 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, + restored: store.restored, + reset, + onRenderError, + onHealthy, + onInteract, + }; +} diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 0972902b815a..6765705a5dd3 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -184,11 +184,79 @@ div.playgroundTextEdit > .playgroundHeader, flex: 1 4 30%; overflow: auto; } -:global(.col) .playgroundError { - overflow: visible; +/* Preview errors: one card, colored by the stage that failed. Amber means the + code never ran (compile); red means it ran and threw (runtime, network). + --pe-accent paints the edge bar and controls; --pe-text the label and name. */ +.previewError { + --pe-accent: var(--ifm-color-danger); + --pe-text: var(--ifm-color-danger-darkest); + --pe-bg: var(--ifm-color-danger-contrast-background); + padding: 0.6rem 0.75rem 0.7rem 0.95rem; + border: 1px solid color-mix(in srgb, var(--pe-accent) 35%, transparent); + border-radius: var(--ifm-global-radius); + /* the edge bar: inset so the radius clips it */ + box-shadow: inset 3px 0 0 var(--pe-accent); + background: var(--pe-bg); + animation: previewErrorIn 160ms ease-out; +} +.previewError[data-kind='compile'] { + --pe-accent: var(--ifm-color-warning-dark); + /* 5:1 on the amber tint; Infima's warning scale has no text-safe step */ + --pe-text: hsl(40, 100%, 27%); + --pe-bg: var(--ifm-color-warning-contrast-background); +} +/* Dark: tints of the accent over the slate preview, not Infima's solid wells */ +:root[data-theme='dark'] .previewError { + --pe-text: var(--ifm-color-danger-lightest); + --pe-bg: color-mix(in srgb, var(--pe-accent) 11%, transparent); +} +:root[data-theme='dark'] .previewError[data-kind='compile'] { + --pe-accent: var(--ifm-color-warning); + --pe-text: var(--ifm-color-warning-light); + --pe-bg: color-mix(in srgb, var(--pe-accent) 9%, transparent); +} +@keyframes previewErrorIn { + from { + opacity: 0; + transform: translateY(4px); + } +} +@media (prefers-reduced-motion: reduce) { + .previewError { + animation: none; + } +} +.errorKind { + display: flex; + align-items: center; + gap: 0.45em; + margin-bottom: 0.4rem; + font: 700 11px / 1 var(--ifm-font-family-base); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--pe-text); +} +.errorIcon { + flex: none; + font-size: 15px; +} +.previewError .playgroundError { + margin: 0; padding: 0; - color: var(--ifm-color-danger-contrast-foreground); + border-radius: 0; + background: none; + overflow: visible; + font: 13px / 19px var(--ifm-font-family-monospace); + color: var(--ifm-font-color-base); white-space: pre-wrap; + overflow-wrap: anywhere; +} +.errorName { + font-weight: 600; + color: var(--pe-text); +} +.errorLocation { + opacity: 0.6; } .previewWrapper { @@ -620,3 +688,61 @@ 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; + border-radius: var(--ifm-global-radius); + font-size: 1rem; +} +.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); + } +} +/* Error card action (Reset preview, Clear Error): outlined in the card's accent */ +.previewError .errorAction { + display: inline-flex; + align-items: center; + gap: 0.4em; + margin: 0.65rem 0 0; + padding: 0.3rem 0.75rem; + min-height: 2rem; + border: 1px solid color-mix(in srgb, var(--pe-accent) 55%, transparent); + border-radius: var(--ifm-global-radius); + background: transparent; + color: var(--pe-text); + font: 500 0.85rem / 1.3 var(--ifm-font-family-base); + cursor: pointer; + transition: + background-color 120ms ease-out, + border-color 120ms ease-out; +} +.previewError .errorAction:focus-visible { + outline: 2px solid var(--pe-accent); + outline-offset: 2px; +} +@media (hover: hover) { + .previewError .errorAction:hover { + background-color: color-mix(in srgb, var(--pe-accent) 14%, transparent); + border-color: var(--pe-accent); + } +} +/* after hover so a pressed pointer reads darker than a resting one */ +.previewError .errorAction:active { + background-color: color-mix(in srgb, var(--pe-accent) 22%, transparent); + border-color: var(--pe-accent); +} diff --git a/website/src/components/ResettableErrorBoundary.tsx b/website/src/components/ResettableErrorBoundary.tsx index 5f0149bc45c6..f96d7ff254fa 100644 --- a/website/src/components/ResettableErrorBoundary.tsx +++ b/website/src/components/ResettableErrorBoundary.tsx @@ -1,44 +1,56 @@ import { NetworkErrorBoundary, useController } from '@data-client/react'; import React from 'react'; +import ErrorPanel from './Playground/ErrorPanel'; import styles from './Playground/styles.module.css'; interface Props { children: React.ReactNode; } +export default function ResetableErrorBoundary({ children }: Props) { + return ( + + {children} + + ); +} + interface ErrorLike { message?: string; status?: number | string; } -export default function ResetableErrorBoundary({ children }: Props) { - const [i, setI] = React.useState(0); +function NetworkErrorFallback({ + error, + resetErrorBoundary, +}: { + error: ErrorLike; + resetErrorBoundary: () => void; +}) { const { resetEntireStore } = useController(); - return ( - { - const networkError = error as ErrorLike; - return ( - <> -
- {networkError.message} {networkError.status} -
- - - ); - }} + { + resetEntireStore(); + resetErrorBoundary(); + }} + > + Clear Error + + } > - {children} -
+
+ {error.status !== undefined && ( + {error.status} + )} + {error.message} +
+ ); } diff --git a/yarn.lock b/yarn.lock index ff0acfa90acf..fe0b1bc6662e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25289,6 +25289,7 @@ __metadata: prettier: "npm:3.9.5" react: "npm:19.2.3" react-dom: "npm:19.2.3" + react-live: "npm:^4.0.0" react-native: "npm:0.86.2" react-native-safe-area-context: "npm:^5.0.0" react-native-screens: "npm:^4.1.0"