diff --git a/website/raw-plugin.js b/website/raw-plugin.js index 8c6b49bc6240..7ad401083f64 100644 --- a/website/raw-plugin.js +++ b/website/raw-plugin.js @@ -5,10 +5,11 @@ module.exports = function (context, options) { configureWebpack(config, isServer, utils) { return { ignoreWarnings: [ - // Suppress warning about dynamic import expressions in monaco-init.ts - // This is expected behavior for dynamic TypeScript definition loading + // Suppress warning about dynamic import expressions in + // Playground/monaco/typeLibs.ts. This is expected behavior for + // dynamic TypeScript definition loading { - module: /monaco-init\.ts/, + module: /Playground[\\/]monaco[\\/]typeLibs\.ts/, message: /Critical dependency: the request of a dependency is an expression/, }, diff --git a/website/scripts/generateMonacoPreloads.cjs b/website/scripts/generateMonacoPreloads.cjs index bfe383786d5c..22908bc9993a 100644 --- a/website/scripts/generateMonacoPreloads.cjs +++ b/website/scripts/generateMonacoPreloads.cjs @@ -22,7 +22,7 @@ const VS_ROOT = path.dirname( const MONACO_ROOT = path.resolve(VS_ROOT, '../..'); const MANIFEST_PATH = path.join( WEBSITE_ROOT, - 'src/components/Playground/monacoPreloadManifest.ts', + 'src/components/Playground/monaco/preloadManifest.ts', ); /** @returns {{ monacoVersion: string, preloadPaths: string[], prefetchPaths: string[] }} */ diff --git a/website/src/components/DiffEditorMonaco.tsx b/website/src/components/DiffEditorMonaco.tsx index 878a5e43e9d4..a88cf26abc91 100644 --- a/website/src/components/DiffEditorMonaco.tsx +++ b/website/src/components/DiffEditorMonaco.tsx @@ -5,12 +5,13 @@ import { type editor } from 'monaco-editor'; import { useMemo } from 'react'; import type { CodeDocument } from './Playground/editor/codeModel'; -import { extensionToMonacoLanguage } from './Playground/extensionToMonacoLanguage'; -import './Playground/monaco-init'; -import { isMobileOrBot } from './Playground/isMobileOrBot'; -import { options } from './Playground/monacoOptions'; +import { extensionToMonacoLanguage } from './Playground/monaco/language'; +import { options } from './Playground/monaco/options'; +import './Playground/monaco/setup'; +import { MONACO_THEME } from './Playground/monaco/theme'; +import useAutoHeight from './Playground/monaco/useAutoHeight'; import styles from './Playground/styles.module.css'; -import useAutoHeight from './Playground/useAutoHeight'; +import { isMobileOrBot } from './Playground/userAgent'; export default function DiffEditor({ documents, fallback }: DiffMonacoProps) { const [original, modified] = useMemo( @@ -48,7 +49,7 @@ export default function DiffEditor({ documents, fallback }: DiffMonacoProps) { options={DIFF_OPTIONS} onMount={handleMount} height={height} - theme="prism" + theme={MONACO_THEME} loading={fallback} // Prevent "TextModel got disposed before DiffEditorWidget model got reset" error // by not letting @monaco-editor/react manage model disposal diff --git a/website/src/components/HooksPlayground.tsx b/website/src/components/HooksPlayground.tsx index 756ac53125cf..35ba60b2d77f 100644 --- a/website/src/components/HooksPlayground.tsx +++ b/website/src/components/HooksPlayground.tsx @@ -1,8 +1,8 @@ -import type { Fixture, Interceptor } from '@data-client/test'; import React, { memo } from 'react'; -import Playground from './Playground'; +import Playground, { type PlaygroundProps } from './Playground'; +/** MDX entry point: wraps one or more code fences in a live Playground. */ const HooksPlayground = ({ children, groupId, @@ -24,6 +24,7 @@ const HooksPlayground = ({ defaultTab={defaultTab} headerControls={headerControls} > + {/* A single fence arrives as one
element; unwrap it to its */}
{typeof children === 'string' ?
children
: Array.isArray(children) ?
@@ -34,15 +35,3 @@ const HooksPlayground = ({
);
export default memo(HooksPlayground);
-
-interface PlaygroundProps {
- groupId: string;
- defaultOpen?: 'y' | 'n';
- row: boolean;
- hidden?: boolean;
- fixtures?: (Fixture | Interceptor)[];
- getInitialInterceptorData?: () => T;
- children: React.ReactNode;
- defaultTab?: string;
- headerControls?: React.ReactNode;
-}
diff --git a/website/src/components/Playground/PlaygroundEditor.tsx b/website/src/components/Playground/PlaygroundEditor.tsx
index c9a299b96c2a..e9796fbb16bd 100644
--- a/website/src/components/Playground/PlaygroundEditor.tsx
+++ b/website/src/components/Playground/PlaygroundEditor.tsx
@@ -1,9 +1,9 @@
+import InteractiveEditor from './editor/InteractiveEditor';
import PlaygroundLiveEditor from './PlaygroundLiveEditor';
-import PlaygroundMonacoEditor from './PlaygroundMonacoEditor';
// Always use Monaco editor wrapper - mobile/bot check happens inside
// This avoids hydration mismatch from module-level navigator checks
-const PlaygroundEditor = PlaygroundMonacoEditor;
+const PlaygroundEditor = InteractiveEditor;
export default PlaygroundEditor;
// Re-export for use as fallback inside Monaco editor
diff --git a/website/src/components/Playground/PlaygroundMonacoEditor.tsx b/website/src/components/Playground/PlaygroundMonacoEditor.tsx
deleted file mode 100644
index 574b765c7e93..000000000000
--- a/website/src/components/Playground/PlaygroundMonacoEditor.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import Editor from '@monaco-editor/react';
-import type { ISelection } from 'monaco-editor';
-import type * as Monaco from 'monaco-editor';
-import rangeParser from 'parse-numeric-range';
-import { memo, useCallback, useMemo } from 'react';
-import { LiveEditor } from 'react-live';
-import './monaco-init';
-
-import { extensionToMonacoLanguage } from './extensionToMonacoLanguage';
-import { isMobileOrBot } from './isMobileOrBot';
-import { options } from './monacoOptions';
-import PlaygroundLiveEditor from './PlaygroundLiveEditor';
-import useAutoHeight from './useAutoHeight';
-
-// TODO: consider using the ts worker's getEmitOutput to compile rather than babel
-
-function PlaygroundMonacoEditor({
- onChange,
- code,
- path = '',
- onFocus,
- tabIndex,
- highlights,
- autoFocus = false,
- isFocused = false,
- language = 'tsx',
- readOnly = false,
-}: {
- onChange: (value: string | undefined) => void;
- code: string;
- path?: string;
- onFocus: (tabIndex: number) => void;
- tabIndex: number;
- highlights?: string;
- autoFocus?: boolean;
- isFocused?: boolean;
- language?: string;
- readOnly?: boolean;
-}) {
- const editorOptions = useMemo(() => ({ ...options, readOnly }), [readOnly]);
- const { height, handleMount: handleAutoMount } = useAutoHeight({
- initialContentHeight: code.split('\n').length * editorOptions.lineHeight,
- isFocused,
- });
-
- const handleMount = useCallback((editor: Monaco.editor.ICodeEditor) => {
- // autofocus
- if (autoFocus) editor.focus();
- // autohighlight
- const myhighlights = highlights ? rangeParser(highlights) : undefined;
-
- if (myhighlights) {
- let selectionStartLineNumber = myhighlights[0];
- let positionLineNumber = selectionStartLineNumber;
- const selections: ISelection[] = [];
- myhighlights.forEach(lineNumber => {
- // more of same selection
- if (lineNumber === positionLineNumber) {
- positionLineNumber++;
- } else {
- selections.push({
- selectionStartLineNumber,
- selectionStartColumn: 0,
- positionLineNumber,
- positionColumn: 0,
- });
- selectionStartLineNumber = lineNumber;
- positionLineNumber = lineNumber + 1;
- }
- });
- selections.push({
- selectionStartLineNumber,
- selectionStartColumn: 0,
- positionLineNumber,
- positionColumn: 0,
- });
- editor.setSelections(selections);
- }
-
- // go to definition
- editor.onDidFocusEditorText(() => {
- onFocus(tabIndex);
- });
-
- handleAutoMount(editor);
- }, []);
-
- // loading only shows the initial snapshot, so it need not track code changes
- const loading = useMemo(
- () => ,
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [language],
- );
-
- // Use lightweight editor for mobile/bots
- // This runs inside BrowserOnly, so navigator is always defined
- // Check must be after hooks to satisfy React's rules of hooks
- if (isMobileOrBot()) {
- return ;
- }
-
- return (
-
- );
-}
-export default memo(PlaygroundMonacoEditor);
diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md
new file mode 100644
index 000000000000..abc0ff64da35
--- /dev/null
+++ b/website/src/components/Playground/README.md
@@ -0,0 +1,131 @@
+# Playground
+
+Live, editable code examples for the docs, blog and homepage. Read this before
+changing anything here: most of the complexity exists to keep pages fast,
+indexable and hydration-safe, and those behaviors are easy to drop silently.
+
+## Entry points
+
+| Component | Used by | What it renders |
+| ------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------- |
+| `HooksPlayground` (`../HooksPlayground.tsx`) → `Playground` (`index.tsx`) | MDX docs/blog, homepage `Demo` | Editor + live preview + Store inspector |
+| `TypeScriptEditor` (`../TypeScriptEditor.tsx`) | MDX | Editor only (`variant="standalone"`) |
+| `EndpointPlayground` (`../HTTP/EndpointPlayground.tsx`) | MDX | Editor + static HTTP request/response |
+| `DiffEditor` (`../DiffEditor.tsx` → `../DiffEditorChooser.tsx` → `../DiffEditorMonaco.tsx`) | MDX | Read-only Monaco diff of two fences |
+| `Demo/CodeEditor` (`../Demo/CodeEditor.tsx`) | Homepage | One `HooksPlayground` per protocol tab |
+
+## Layout
+
+```
+index.tsx Playground: code model, editor/preview ordering, hidden handling
+userAgent.ts isBot / isMobileOrBot gates
+PlaygroundEditor.tsx editor entry used by EditorSurface (aliases editor/InteractiveEditor)
+PlaygroundLiveEditor.tsx editable react-live editor (mobile/bot fallback)
+resources/ sample resources (currently unreferenced; kept for future demos)
+Boundary.tsx BrowserOnly + Suspense with one fallback for SSR and loading
+Header.tsx, TabList.tsx, styles.module.css shared chrome (also used by HTTP/, Demo/)
+editor/ code model + editor UI
+ codeModel.ts parse MDX fences/strings into documents; edits
+ EditorShell.tsx react-live LiveProvider supplying the Prism theme
+ EditorSurface.tsx headers, file tabs, collapse state, per-file TextEditTab
+ StaticEditor.tsx SSR/crawler/loading markup (Prism, not editable)
+ InteractiveEditor.tsx client-only: Monaco, or editable react-live on mobile/bots
+monaco/ everything Monaco-specific
+ setup.ts side-effect init: CDN loader, hints, compiler options, theme, types
+ typeLibs.ts raw .d.ts chunks from editor-types/ → addExtraLib
+ navigation.ts cross-tab go-to-definition + import completions
+ modelPath.ts `//` model URI scheme
+ preloadManifest.ts GENERATED by website/scripts/generateMonacoPreloads.cjs
+ resourceHints.ts, theme.ts, options.ts, language.ts, useAutoHeight.ts, highlightSelections.ts
+preview/ live execution (loaded lazily, never on the server)
+ LivePreview.tsx react-live LiveProvider with scope + transformCode
+ Preview.tsx DataProvider + MockResolver(fixtures) + Store inspector
+ ...
+editor-types/ .d.ts bundles fed to Monaco (raw-loader)
+DesignSystem/ components injected into preview scope
+```
+
+## Invariants
+
+### SSR / SSG and crawlers
+
+- Open files' source is in the static HTML: `TextEditTab` renders
+ `StaticEditor` on the server and during hydration (BrowserOnly fallback).
+ Collapsed/unselected files render nothing until opened.
+- The preview renders `previewLoading` (empty frame + Store toggle) on the
+ server, while loading, for bots, and while `hidden`.
+- Fixtures render server-side as JSON `CodeBlock`s; function responses are
+ BrowserOnly (a stringified function differs between server and client
+ bundles).
+- Never branch on `navigator`/user agent during render outside `BrowserOnly`
+ (hydration mismatch). `isBot` is module-level and only read in the
+ `lazy()` factory (and in StackBlitz after intersection); `isMobileOrBot()`
+ is only called inside BrowserOnly or in `monaco/setup.ts`.
+
+### Incremental JS delivery
+
+- `monaco/setup.ts` runs once when the editor chunk evaluates: injects
+ preload/prefetch ``s for the version-matched CDN files, starts
+ `loader.init()` and the type-lib chunk downloads in parallel.
+ `preloadManifest.ts` is regenerated on every docusaurus config load.
+- Type libs: one webpack chunk per third-party `.d.ts` (`reactDTS`, …) and a
+ single `lazy-once` chunk for `editor-types/@data-client/*`. Failed fetches
+ degrade to empty libs. `website/raw-plugin.js` silences the expected
+ "expression in import" warning for `monaco/typeLibs.ts` by path.
+- Preview code is a lazy chunk (`PreviewWithScope`, prefetched) and
+ `PreviewBlock` a nested lazy chunk (preloaded with its parent).
+- Mobile and bots download none of Monaco or the type libs.
+
+### Mobile and bots
+
+- Mobile: editable react-live editor instead of Monaco; live preview still runs.
+- Bots: same editor fallback; preview stays `previewLoading` (never loads
+ `LivePreview`); StackBlitz embeds never load.
+- `DiffEditor` shows a two-`CodeBlock` grid instead of Monaco.
+
+### Hidden playgrounds (homepage Demo protocol tabs)
+
+- All protocols stay mounted so every protocol's source is in the HTML.
+- `hidden` keeps `StaticEditor` (no Monaco) until first shown, then latches
+ interactive so undo history and go-to-definition survive tab switches.
+- The live preview unmounts while hidden (its store resets).
+
+### Editor
+
+- Fence metastring: `title="…"`, `path="…"` (quotes optional), `collapsed`, `column`, `{1-3}`
+ highlight ranges (pre-selected in Monaco); `language-*` class. Element props
+ override metastring values (Demo passes them directly). `defaultTab`
+ overrides `collapsed`.
+- `row` layout: non-`column` files are tabs (focus switches tab); `column`
+ files stack with collapsible headers. Stacked layout: every titled file gets a header,
+ collapsible when there are several files or fixtures.
+- Collapsed/unselected files stay mounted (`display: none`), never unmounted:
+ their Monaco models must exist for cross-file types, go-to-definition and
+ to keep edits. Monaco is uncontrolled (`defaultValue`), so re-renders never
+ reset the buffer.
+- Fixtures are listed above the editor in stacked layout only.
+- Go-to-definition across files: focusing the target editor reveals its tab.
+ Relies on the `//` model URIs (`monaco/modelPath.ts`).
+- Relative import completions list only the other files of the same
+ playground (same model id).
+- Handlers passed to `InteractiveEditor` are referentially stable so unedited
+ tabs skip re-rendering on keystrokes.
+- Height follows Monaco content size and re-measures when a tab is shown.
+
+### Preview
+
+- All documents are concatenated (deferred with `useDeferredValue`),
+ stripped of imports/exports by `transformCode`, and run `noInline`
+ (`render( )`) with `preview/scope.ts` as globals. Keep
+ `monaco/typeLibs.ts` declarations in sync with the scope.
+- Each playground gets its own `DataProvider` store (`MockResolver` serves
+ `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits.
+- Store inspector open state persists per `groupId` via tab storage and
+ avoids scroll jumps; in `row` layout it replaces the result while open.
+- 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.)
diff --git a/website/src/components/Playground/editor/EditorShell.tsx b/website/src/components/Playground/editor/EditorShell.tsx
index 2b634d720d8b..864c9010d353 100644
--- a/website/src/components/Playground/editor/EditorShell.tsx
+++ b/website/src/components/Playground/editor/EditorShell.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { LiveProvider } from 'react-live';
-import { useReactLiveTheme } from '../useReactLiveTheme';
+import { useReactLiveTheme } from './useReactLiveTheme';
export default function EditorShell({
children,
diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx
index 48a7d4a58a6d..4f1a9a46a8cf 100644
--- a/website/src/components/Playground/editor/EditorSurface.tsx
+++ b/website/src/components/Playground/editor/EditorSurface.tsx
@@ -1,20 +1,16 @@
import BrowserOnly from '@docusaurus/BrowserOnly';
import Translate from '@docusaurus/Translate';
import clsx from 'clsx';
-import React, {
- type ComponentProps,
- useCallback,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import { LiveEditor } from 'react-live';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
import Header from '../Header';
+import { modelPath, useModelId } from '../monaco/modelPath';
import Editor from '../PlaygroundEditor';
import styles from '../styles.module.css';
import TabList from '../TabList';
import type { CodeDocument, CodeModel } from './codeModel';
+import type { InteractiveEditorProps } from './InteractiveEditor';
+import StaticEditor from './StaticEditor';
export interface EditorSurfaceProps extends CodeModel {
layout: 'row' | 'stacked';
@@ -34,7 +30,7 @@ export default function EditorSurface({
fixtureContent,
headerControls,
}: EditorSurfaceProps) {
- const id = useNumericId();
+ const id = useModelId();
const row = layout === 'row';
const [closedList, setClosed] = useState(() =>
documents.map(({ collapsed }) => collapsed),
@@ -42,7 +38,7 @@ export default function EditorSurface({
// Document count and col flags are fixed after the initial parse, so
// capturing them once keeps these handlers referentially stable. That
- // stability is what lets memo(PlaygroundMonacoEditor) skip re-rendering
+ // stability is what lets memo(InteractiveEditor) skip re-rendering
// unedited tabs on every keystroke.
const colFlags = useRef(documents.map(({ col }) => col)).current;
const handleTabSwitch = useCallback(
@@ -114,7 +110,7 @@ export default function EditorSurface({
}
onChange={handleChanges[index]}
code={document.value}
- path={`/${id}/${document.path}`}
+ path={modelPath(id, document.path)}
isFocused={!closedList[index]}
language={document.language}
highlights={document.highlights}
@@ -129,32 +125,17 @@ export default function EditorSurface({
function TextEditTab({
hidden,
interactive,
- code,
- language,
- tabIndex,
- ...rest
-}: ComponentProps & {
+ ...editorProps
+}: InteractiveEditorProps & {
hidden: boolean;
interactive: boolean;
}) {
- // Stable across SSR/hydration (do not branch on navigator / isGoogleBot here).
- const staticView = ;
-
- // Deferred protocols: keep open-file source in the DOM for SSG/crawlers;
- // skip Monaco until the sandbox has been shown once.
- if (!interactive) {
- return (
-
- {hidden ? null : staticView}
-
+ // SSR + hydration markup: open tabs' source stays in the HTML for crawlers.
+ // Never branch on navigator / user agent outside BrowserOnly.
+ const staticView =
+ hidden ? null : (
+
);
- }
-
- const fallback = hidden ? <>> : staticView;
return (
-
- {() => (
-
- )}
-
+ {/* Not yet interactive (e.g. never-shown Demo tab): skip Monaco entirely */}
+ {interactive ?
+
+ {() => }
+
+ : staticView}
);
}
@@ -269,11 +246,3 @@ function EditorHeader({
>
);
}
-
-// Monaco model URIs are matched with /\/\d+\// in monaco-init.ts.
-function useNumericId() {
- return useMemo(
- () => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(),
- [],
- );
-}
diff --git a/website/src/components/Playground/editor/InteractiveEditor.tsx b/website/src/components/Playground/editor/InteractiveEditor.tsx
new file mode 100644
index 000000000000..0402ed7e5359
--- /dev/null
+++ b/website/src/components/Playground/editor/InteractiveEditor.tsx
@@ -0,0 +1,99 @@
+import Editor from '@monaco-editor/react';
+import type * as Monaco from 'monaco-editor';
+import rangeParser from 'parse-numeric-range';
+import { memo, useCallback, useMemo } from 'react';
+
+import '../monaco/setup';
+import { highlightSelections } from '../monaco/highlightSelections';
+import { extensionToMonacoLanguage } from '../monaco/language';
+import { options } from '../monaco/options';
+import { MONACO_THEME } from '../monaco/theme';
+import useAutoHeight from '../monaco/useAutoHeight';
+import PlaygroundLiveEditor from '../PlaygroundLiveEditor';
+import { isMobileOrBot } from '../userAgent';
+import StaticEditor from './StaticEditor';
+
+// TODO: consider using the ts worker's getEmitOutput to compile rather than babel
+
+export interface InteractiveEditorProps {
+ onChange: (value: string | undefined) => void;
+ code: string;
+ /** Monaco model path (see ../monaco/modelPath.ts) */
+ path?: string;
+ /** Called with tabIndex whenever this editor gains focus */
+ onFocus: (tabIndex: number) => void;
+ tabIndex: number;
+ /** Code-fence line ranges to pre-select, e.g. `1-3,5` */
+ highlights?: string;
+ autoFocus?: boolean;
+ /** Whether this tab is visible; re-measures height when it becomes so */
+ isFocused?: boolean;
+ language: string;
+ readOnly?: boolean;
+}
+
+/**
+ * Client-only (render inside BrowserOnly): Monaco on desktop, an editable
+ * react-live editor on mobile/bots.
+ */
+function InteractiveEditor({
+ onChange,
+ code,
+ path = '',
+ onFocus,
+ tabIndex,
+ highlights,
+ autoFocus = false,
+ isFocused = false,
+ language,
+ readOnly = false,
+}: InteractiveEditorProps) {
+ const editorOptions = useMemo(() => ({ ...options, readOnly }), [readOnly]);
+ const { height, handleMount: handleAutoMount } = useAutoHeight({
+ initialContentHeight: code.split('\n').length * editorOptions.lineHeight,
+ isFocused,
+ });
+
+ // Mount-time setup only: props read here are fixed for the editor's life
+ const handleMount = useCallback((editor: Monaco.editor.ICodeEditor) => {
+ if (autoFocus) editor.focus();
+ if (highlights) {
+ const selections = highlightSelections(rangeParser(highlights));
+ if (selections.length) editor.setSelections(selections);
+ }
+ // Focus reveals this tab (also how cross-tab go to definition lands)
+ editor.onDidFocusEditorText(() => {
+ onFocus(tabIndex);
+ });
+ handleAutoMount(editor);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // loading only shows the initial snapshot, so it need not track code changes
+ const loading = useMemo(
+ () => ,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [language],
+ );
+
+ // After hooks to satisfy the rules of hooks; safe because we only render
+ // client-side, so navigator is defined and hydration is already done.
+ if (isMobileOrBot()) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+export default memo(InteractiveEditor);
diff --git a/website/src/components/Playground/editor/StaticEditor.tsx b/website/src/components/Playground/editor/StaticEditor.tsx
new file mode 100644
index 000000000000..0a8a100fd8d8
--- /dev/null
+++ b/website/src/components/Playground/editor/StaticEditor.tsx
@@ -0,0 +1,16 @@
+import { LiveEditor } from 'react-live';
+
+/**
+ * Prism-highlighted, non-editable code. Renders identically on server and
+ * client, so it is the SSG/crawler markup and the placeholder until Monaco
+ * mounts. Needs an EditorShell ancestor for its theme.
+ */
+export default function StaticEditor({
+ code,
+ language,
+}: {
+ code: string;
+ language: string;
+}) {
+ return ;
+}
diff --git a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx
index 6039899a5e85..ac28293a1e6c 100644
--- a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx
+++ b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx
@@ -68,6 +68,20 @@ describe('code document model', () => {
]);
});
+ test('accepts unquoted title and path', () => {
+ expect(
+ parseCodeDocuments([
+ code({ metastring: 'path=Todo.ts' }, 'a'),
+ code({ metastring: "title='T' path='src/b.ts' {1}" }, 'b'),
+ code({ metastring: 'title=c.ts' }, 'c'),
+ ]).map(({ path, title }) => [path, title]),
+ ).toEqual([
+ ['Todo.ts', ''],
+ ['src/b.ts', 'T'],
+ ['c.ts', 'c.ts'],
+ ]);
+ });
+
test('updates only the addressed document', () => {
const documents = parseCodeDocuments([
code({ metastring: 'title="One"' }, 'one'),
diff --git a/website/src/components/Playground/editor/codeModel.ts b/website/src/components/Playground/editor/codeModel.ts
index 9e4dae422030..88bae079dc9e 100644
--- a/website/src/components/Playground/editor/codeModel.ts
+++ b/website/src/components/Playground/editor/codeModel.ts
@@ -16,6 +16,14 @@ export interface CodeModel {
update: (index: number, value: string) => void;
}
+/** `key="value"`, `key='value'` or bare `key=value` from a fence metastring */
+function metaValue(metastring: string, key: string): string | undefined {
+ return new RegExp(`\\b${key}=(?:(["'])(.*?)\\1|([^\\s"']+))`)
+ .exec(metastring)
+ ?.slice(2)
+ .find(value => value !== undefined);
+}
+
export function parseCodeDocuments(
children: string | React.ReactNode | React.ReactNode[],
defaultTab?: string,
@@ -42,9 +50,7 @@ export function parseCodeDocuments(
: child.props.children.props,
)
.map(({ children: code, metastring = '', ...rest }) => {
- const title =
- metastring.match(/title=(?["'])(?.*?)\1/)?.groups
- ?.title ?? '';
+ const title = metaValue(metastring, 'title') ?? '';
const language =
/\blanguage-(?[\w-]+)/.exec(rest.className ?? '')?.groups
?.language ?? 'tsx';
@@ -61,7 +67,7 @@ export function parseCodeDocuments(
: /\bcollapsed\b/.test(metastring),
col: /\bcolumn\b/.test(metastring),
path:
- metastring.match(/path=(?["'])(?.*?)\1/)?.groups?.path ||
+ metaValue(metastring, 'path') ||
(fileBase.includes('.') ? fileBase : `${fileBase}.${extension}`),
highlights: /\{([\d\-,.]+)\}/.exec(metastring)?.[1],
language,
diff --git a/website/src/components/Playground/useReactLiveTheme.tsx b/website/src/components/Playground/editor/useReactLiveTheme.ts
similarity index 100%
rename from website/src/components/Playground/useReactLiveTheme.tsx
rename to website/src/components/Playground/editor/useReactLiveTheme.ts
diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx
index 4ec2a4d9cae9..08ff50827bb0 100644
--- a/website/src/components/Playground/index.tsx
+++ b/website/src/components/Playground/index.tsx
@@ -7,22 +7,27 @@ import Boundary from './Boundary';
import { useCodeDocuments } from './editor/codeModel';
import EditorShell from './editor/EditorShell';
import EditorSurface from './editor/EditorSurface';
-import FixturePreview from './FixturePreview';
-import { isGoogleBot } from './isMobileOrBot';
+import FixturePreview from './preview/FixturePreview';
import type LivePreviewType from './preview/LivePreview';
-import PreviewWrapper from './PreviewWrapper';
-import { StoreToggle } from './StoreInspector';
+import PreviewWrapper from './preview/PreviewWrapper';
+import { StoreToggle } from './preview/StoreInspector';
import styles from './styles.module.css';
-import type { FixtureOrInterceptor, PreviewProps } from './types';
+import type { FixtureOrInterceptor } from './types';
+import { isBot } from './userAgent';
export interface PlaygroundProps {
children: React.ReactNode;
+ /** Tab-storage key for the Store inspector's open/closed choice */
groupId?: string;
+ /** Store inspector open by default */
defaultOpen?: 'y' | 'n';
+ /** Tabs for files + editor beside preview (when wide enough) */
row?: boolean;
+ /** Inactive (e.g. unselected Demo tab): no Monaco until first shown, no preview */
hidden?: boolean;
fixtures?: FixtureOrInterceptor[];
getInitialInterceptorData?: () => T;
+ /** Title of the file tab to open; overrides `collapsed` metastrings */
defaultTab?: string;
headerControls?: React.ReactNode;
}
@@ -38,51 +43,10 @@ export default function Playground({
defaultTab,
headerControls,
}: PlaygroundProps) {
- const {
- liveCodeBlock: { playgroundPosition },
- } = useDocusaurusContext().siteConfig.themeConfig as any;
+ const { playgroundPosition } = (
+ useDocusaurusContext().siteConfig.themeConfig as any
+ ).liveCodeBlock;
- return (
-
-
-
- {children}
-
-
-
- );
-}
-
-function PlaygroundContent({
- reverse,
- children,
- row,
- hidden,
- fixtures,
- groupId,
- defaultOpen,
- defaultTab,
- getInitialInterceptorData,
- headerControls,
-}: ContentProps) {
const model = useCodeDocuments(children, defaultTab);
// Defer preview transpilation so editor input remains responsive.
const code = useDeferredValue(
@@ -113,7 +77,7 @@ function PlaygroundContent({
const preview =
hidden ? previewLoading : (
- ({
);
- return <>{reverse ? [preview, editor] : [editor, preview]}>;
-}
-
-interface ContentProps extends PreviewProps {
- children: PlaygroundProps['children'];
- reverse: boolean;
- hidden: boolean;
- defaultTab?: string;
- headerControls?: React.ReactNode;
+ return (
+
+
+ {playgroundPosition === 'top' ? [preview, editor] : [editor, preview]}
+
+
+ );
}
+/** SSR, crawler, hidden and loading state: empty preview frame + Store toggle */
const previewLoading = (
@@ -142,8 +113,8 @@ const previewLoading = (
);
-const PreviewWithScopeLazy = lazy(() =>
- isGoogleBot ?
+const LivePreview = lazy(() =>
+ isBot ?
Promise.resolve({ default: () => previewLoading })
: import(
/* webpackChunkName: 'PreviewWithScope', webpackPrefetch: true */ './preview/LivePreview'
diff --git a/website/src/components/Playground/isMobileOrBot.ts b/website/src/components/Playground/isMobileOrBot.ts
deleted file mode 100644
index dedfedf8eb28..000000000000
--- a/website/src/components/Playground/isMobileOrBot.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/** Shared user-agent patterns for bot/mobile detection. */
-export const BOT_UA_REGEX = /bot|googlebot|crawler|spider|robot|crawling/i;
-export const MOBILE_OR_BOT_UA_REGEX =
- /bot|googlebot|crawler|spider|robot|crawling|Mobile|Android|BlackBerry/i;
-
-/** True if crawler/bot (SSR-safe). Used for skipping Monaco/Preview load. */
-export const isGoogleBot =
- typeof navigator === 'object' &&
- BOT_UA_REGEX.test(navigator?.userAgent ?? '');
-
-/** Check if current browser is mobile or a bot - must be called client-side only */
-export function isMobileOrBot(): boolean {
- return MOBILE_OR_BOT_UA_REGEX.test(navigator.userAgent);
-}
diff --git a/website/src/components/Playground/monaco-init.ts b/website/src/components/Playground/monaco-init.ts
deleted file mode 100644
index 44b8d3b17919..000000000000
--- a/website/src/components/Playground/monaco-init.ts
+++ /dev/null
@@ -1,362 +0,0 @@
-import { loader } from '@monaco-editor/react';
-import type * as Monaco from 'monaco-editor';
-
-import { injectMonacoResourceHints } from './injectMonacoResourceHints';
-import { MOBILE_OR_BOT_UA_REGEX } from './isMobileOrBot';
-import { MONACO_CDN_VS } from './monacoPreloadManifest';
-
-// Mobile and bots render PlaygroundLiveEditor instead, so skip downloading
-// Monaco and the type bundles entirely. This module runs once per page load.
-if (
- typeof window !== 'undefined' &&
- !MOBILE_OR_BOT_UA_REGEX.test(navigator.userAgent)
-) {
- const rhDeps = [
- 'rest',
- 'rest/next',
- 'react/next',
- 'core/next',
- 'core',
- 'react',
- 'endpoint',
- 'normalizr',
- 'graphql',
- ] as const;
-
- const suggestionDependencies = [
- 'react',
- '@data-client/rest',
- '@data-client/react',
- '@data-client/graphql',
- 'bignumber.js',
- ];
-
- injectMonacoResourceHints();
- loader.config({
- paths: {
- vs: MONACO_CDN_VS,
- },
- });
- const monacoPromise = loader.init();
-
- // Fetch type libs in parallel with Monaco CDN bootstrap (no webpackPreload —
- // that raced high-priority against editor.main). Apply them only after init.
- const typeLibsPromise = Promise.allSettled([
- import(
- /* webpackChunkName: 'reactDTS' */ '!!raw-loader?esModule=false!./editor-types/react.d.ts'
- ),
- import(
- /* webpackChunkName: 'bignumberDTS' */ '!!raw-loader?esModule=false!./editor-types/bignumber.d.ts'
- ),
- import(
- /* webpackChunkName: 'numberflowDTS' */ '!!raw-loader?esModule=false!./editor-types/@number-flow/react.d.ts'
- ),
- import(
- /* webpackChunkName: 'temporalDTS' */ '!!raw-loader?esModule=false!./editor-types/temporal.d.ts'
- ),
- import(
- /* webpackChunkName: 'uuidDTS' */ '!!raw-loader?esModule=false!./editor-types/uuid.d.ts'
- ),
- import(
- /* webpackChunkName: 'qsDTS' */ '!!raw-loader?esModule=false!./editor-types/qs.d.ts'
- ),
- import(
- /* webpackChunkName: 'globalsDTS' */ '!!raw-loader?esModule=false!./editor-types/globals.d.ts'
- ),
- ...rhDeps.map(
- dep =>
- import(
- /* webpackChunkName: '[request]', webpackMode: "lazy-once" */ `!!raw-loader?esModule=false!./editor-types/@data-client/${dep}.d.ts`
- ),
- ),
- ]);
-
- monacoPromise.then(async monaco => {
- if (!monaco) return;
- monaco.typescript.typescriptDefaults.setCompilerOptions({
- allowNonTsExtensions: true,
- target: monaco.typescript.ScriptTarget.ES2017,
- jsx: monaco.typescript.JsxEmit.ReactJSX,
- strict: true,
- strictNullChecks: true,
- exactOptionalPropertyTypes: true,
- lib: ['dom', 'esnext'],
- module: monaco.typescript.ModuleKind.ESNext,
- moduleResolution: monaco.typescript.ModuleResolutionKind.NodeJs,
- allowSyntheticDefaultImports: true,
- skipLibCheck: true,
- skipDefaultLibCheck: true,
- noImplicitAny: false,
- });
- // TODO: load theme from docusaurus config so we eliminate DRY violation
- // see https://microsoft.github.io/monaco-editor/playground.html for full options
- monaco.editor.defineTheme('prism', {
- base: 'vs-dark',
- inherit: true,
- rules: [
- { token: 'number', foreground: 'f78c6c' },
- { token: 'string', foreground: 'b6d986' },
- {
- token: 'keyword',
- fontStyle: 'italic',
- foreground: '7da4f6',
- },
- // type definitions variables like const X or class X
- { token: 'type', foreground: 'ffcb6b' },
- { token: 'delimiter', foreground: 'C792EA' },
-
- { token: 'tag', foreground: 'FF5590' },
- ],
- colors: {
- //'editor.background': '#292d3e',
- 'editor.foreground': '#bfc7d5',
- //'editor.lineHighlightBorder': '#33384d',
- 'editor.inactiveSelectionBackground': '#484d5b',
- },
- });
- // go to definition
- monaco.editor.registerEditorOpener({
- openCodeEditor(
- _sourceEditor: Monaco.editor.ICodeEditor,
- resource: Monaco.Uri,
- selectionOrPosition?: Monaco.IRange | Monaco.IPosition,
- ) {
- if (resource.path.startsWith('/')) {
- // alternatively set model directly in the editor if you have your own tab/navigation implementation\
- const model = monaco.editor.getModel(resource);
- const destinationEditor = monaco.editor
- .getEditors()
- .find(
- (editor: Monaco.editor.ICodeEditor) =>
- editor.getModel() === model,
- );
- if (!destinationEditor) return false;
- // focus event is handled by editor to show that tab
- destinationEditor.focus();
- requestIdleCallback(() => {
- if (monaco.Range.isIRange(selectionOrPosition)) {
- destinationEditor.revealRangeInCenterIfOutsideViewport(
- selectionOrPosition,
- );
- destinationEditor.setSelection(selectionOrPosition);
- } else {
- if (selectionOrPosition) {
- destinationEditor.revealPositionInCenterIfOutsideViewport(
- selectionOrPosition,
- );
- destinationEditor.setPosition(selectionOrPosition);
- }
- }
- destinationEditor.focus();
- });
-
- return true;
- }
- return false;
- },
- });
- // autocomplete imports
- monaco.languages.registerCompletionItemProvider('typescript', {
- // These characters should trigger our `provideCompletionItems` function
- triggerCharacters: ["'", '"', '.', '/'],
- // Function which returns a list of autocompletion ietems. If we return an empty array, there won't be any autocompletion.
- provideCompletionItems: (
- model: Monaco.editor.ITextModel,
- position: Monaco.Position,
- ) => {
- // Get all the text content before the cursor
- const textUntilPosition = model.getValueInRange({
- startLineNumber: 1,
- startColumn: 1,
- endLineNumber: position.lineNumber,
- endColumn: position.column,
- });
- // Get the current word
- const word = model.getWordUntilPosition(position);
- const range = {
- startLineNumber: position.lineNumber,
- endLineNumber: position.lineNumber,
- startColumn: word.startColumn,
- endColumn: word.endColumn,
- };
- // Match things like `from "` and `require("`
- if (
- /(([\s|\n]+from\s+)|(\brequire\b\s*\())["|'][^'^"]*$/.test(
- textUntilPosition,
- )
- ) {
- // It's probably a `import` statement or `require` call
- if (
- textUntilPosition.endsWith('.') ||
- textUntilPosition.endsWith('/')
- ) {
- const completions = monaco.editor
- .getModels()
- .map(
- (editorModel: Monaco.editor.ITextModel) => editorModel.uri.path,
- )
- .map((path: string) => {
- const candidateId = /\/\d+\//g.exec(path)?.[0] ?? '';
- const file = path.substring(candidateId.length - 1);
- return {
- // Show the full file path for label
- label: file,
- // Don't keep extension for JS files
- insertText: file.replace(/\.tsx?$/, ''),
- kind: monaco.languages.CompletionItemKind.Module,
- range,
- };
- });
- if (!completions.length) return { suggestions: [] };
- return { suggestions: completions };
- } else {
- // User is trying to import a dependency
- return {
- suggestions: suggestionDependencies.map(name => ({
- label: name,
- //detail: suggestionDependencies[name],
- kind: monaco.languages.CompletionItemKind.Module,
- insertText: name,
- range,
- })),
- };
- }
- }
- return { suggestions: [] };
- },
- });
-
- const settles = await typeLibsPromise;
- const [
- react,
- bignumber,
- numberFlow,
- temporal,
- uuid,
- qs,
- globals,
- ...rhLibs
- ] = settles.map(result =>
- result.status === 'fulfilled' ? result.value.default : '',
- );
-
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare module "react/jsx-runtime" {
- import './';
- }`,
- 'file:///node_modules/@types/react/jsx-runtime.d.ts',
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `import * as React from 'react'
-
- declare global {
- namespace JSX {
- interface IntrinsicElements {
- strike: React.DetailedHTMLProps, HTMLElement>;
- }
- }
- }`,
- 'file:///node_modules/@types/react/more.d.ts',
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare function render(component:JSX.Element):void;
- declare function uuid(): string;
- declare function CurrentTime(props: {}):JSX.Element;
- declare function CancelButton(props: { onClick?: () => void }):JSX.Element;
- declare function Avatar(props: { src: string }):JSX.Element;
- declare function Formatted({ downColor, formatter, formatterFn, timeout, transition, transitionLength, upColor, value, stylePrefix, }: NumberProps):JSX.Element
- declare function ResetableErrorBoundary(props: { children: React.ReactNode }):JSX.Element;
- declare function TextInput(props:Omit, 'size'> & { label?: React.ReactNode; loading?: boolean; size?: 'large' | 'medium' | 'small'; }):JSX.Element;
- declare function TextArea(props:InputHTMLAttributes & { label?: React.ReactNode;}):JSX.Element;
- declare function SearchIcon():JSX.Element;
- declare function Loading():JSX.Element;
- declare function randomFloatInRange(min: number, max: number, decimals?: number): number;
- declare interface NumberProps {
- /**
- * Color value when the component flashes 'down'.
- */
- downColor?: string;
- /**
- * One of the built in formatters.
- */
- formatter?: 'currency' | 'percentage' | 'number';
- /**
- * Pass your own formatter function.
- */
- formatterFn?: Formatter;
- /**
- * Prefix for the CSS selectors in the DOM.
- */
- stylePrefix?: string;
- /**
- * Amount of time the flashed state is visible for, in milliseconds.
- */
- timeout?: number;
- /**
- * Custom CSS transition property.
- */
- transition?: string;
- /**
- * Transition length, in milliseconds.
- */
- transitionLength?: number;
- /**
- * Color value when the component flashes 'up'.
- */
- upColor?: string;
- /**
- * Value to display. The only required prop.
- */
- value: number;
- }`,
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare module "react" { ${react} }`,
- 'file:///node_modules/@types/react/index.d.ts',
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare module "bignumber.js" { ${bignumber} }`,
- 'file:///node_modules/bignumber.js/index.d.ts',
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare module "@number-flow/react" { ${numberFlow} };`,
- 'file:///node_modules/@number-flow/react/index.d.ts',
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare module "temporal-polyfill" { ${temporal} }`,
- 'file:///node_modules/temporal-polyfill/index.d.ts',
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare module "uuid" { ${uuid} }`,
- 'file:///node_modules/@types/uuid/index.d.ts',
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare module "qs" { ${qs} }`,
- 'file:///node_modules/@types/qs/index.d.ts',
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare globals { ${react} }`,
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare globals { export { default as NumberFlow } from '@number-flow/react'; }`,
- );
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare globals { export { Temporal, DateTimeFormat } from 'temporal-polyfill'; }`,
- );
-
- rhLibs.forEach((lib, i) => {
- const dep = rhDeps[i];
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare module "@data-client/${dep}" { ${lib} }`,
- `file:///node_modules/@data-client/${dep}/index.d.ts`,
- );
- });
-
- monaco.typescript.typescriptDefaults.addExtraLib(
- `declare globals { ${globals} }`,
- );
-
- monaco.typescript.typescriptDefaults.setEagerModelSync(true);
- return monaco;
- });
-}
diff --git a/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts
new file mode 100644
index 000000000000..43eb292bf540
--- /dev/null
+++ b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts
@@ -0,0 +1,28 @@
+///
+
+import { highlightSelections } from '../highlightSelections';
+
+const lines = (start: number, end: number) => ({
+ selectionStartLineNumber: start,
+ selectionStartColumn: 0,
+ positionLineNumber: end,
+ positionColumn: 0,
+});
+
+describe('highlightSelections', () => {
+ test('selects nothing without lines', () => {
+ expect(highlightSelections([])).toEqual([]);
+ });
+
+ test('selects a single line through the start of the next', () => {
+ expect(highlightSelections([2])).toEqual([lines(2, 3)]);
+ });
+
+ test('merges consecutive lines and splits gaps', () => {
+ expect(highlightSelections([1, 2, 3, 5, 7, 8])).toEqual([
+ lines(1, 4),
+ lines(5, 6),
+ lines(7, 9),
+ ]);
+ });
+});
diff --git a/website/src/components/Playground/monaco/__tests__/modelPath.test.ts b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts
new file mode 100644
index 000000000000..372a63565b31
--- /dev/null
+++ b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts
@@ -0,0 +1,47 @@
+///
+
+import { renderHook } from '@testing-library/react';
+
+import {
+ modelPath,
+ siblingFilePaths,
+ stripModelId,
+ useModelId,
+} from '../modelPath';
+
+describe('model paths', () => {
+ test('round-trips a namespaced file path', () => {
+ expect(modelPath('123', 'src/api.ts')).toBe('/123/src/api.ts');
+ expect(stripModelId(modelPath('123', 'src/api.ts'))).toBe('/src/api.ts');
+ });
+
+ test('leaves paths without an id segment unchanged', () => {
+ expect(stripModelId('/node_modules/react/index.d.ts')).toBe(
+ '/node_modules/react/index.d.ts',
+ );
+ });
+
+ test('siblingFilePaths lists only other files in the same editor', () => {
+ expect(
+ siblingFilePaths('/1/index.tsx', [
+ '/1/index.tsx',
+ '/1/api.ts',
+ '/2/other.ts',
+ '/12/near-miss.ts',
+ '/node_modules/react/index.d.ts',
+ ]),
+ ).toEqual(['/api.ts']);
+ expect(siblingFilePaths('/plain.ts', ['/plain.ts', '/other.ts'])).toEqual(
+ [],
+ );
+ });
+
+ test('useModelId is numeric and stable across renders', () => {
+ const { result, rerender } = renderHook(() => useModelId());
+ const first = result.current;
+ expect(first).toMatch(/^\d+$/);
+ expect(stripModelId(modelPath(first, 'a.ts'))).toBe('/a.ts');
+ rerender();
+ expect(result.current).toBe(first);
+ });
+});
diff --git a/website/src/components/Playground/monaco/highlightSelections.ts b/website/src/components/Playground/monaco/highlightSelections.ts
new file mode 100644
index 000000000000..c68f00d2a876
--- /dev/null
+++ b/website/src/components/Playground/monaco/highlightSelections.ts
@@ -0,0 +1,34 @@
+import type { ISelection } from 'monaco-editor';
+
+/**
+ * Converts sorted highlighted line numbers (parsed from a code-fence range
+ * like `{1-3,5}`) into Monaco selections, merging consecutive lines into one
+ * whole-line selection each.
+ */
+export function highlightSelections(lines: readonly number[]): ISelection[] {
+ if (!lines.length) return [];
+
+ let selectionStartLineNumber = lines[0];
+ let positionLineNumber = selectionStartLineNumber;
+ const selections: ISelection[] = [];
+ const pushSelection = () =>
+ selections.push({
+ selectionStartLineNumber,
+ selectionStartColumn: 0,
+ positionLineNumber,
+ positionColumn: 0,
+ });
+
+ lines.forEach(lineNumber => {
+ // more of same selection
+ if (lineNumber === positionLineNumber) {
+ positionLineNumber++;
+ } else {
+ pushSelection();
+ selectionStartLineNumber = lineNumber;
+ positionLineNumber = lineNumber + 1;
+ }
+ });
+ pushSelection();
+ return selections;
+}
diff --git a/website/src/components/Playground/extensionToMonacoLanguage.tsx b/website/src/components/Playground/monaco/language.ts
similarity index 100%
rename from website/src/components/Playground/extensionToMonacoLanguage.tsx
rename to website/src/components/Playground/monaco/language.ts
diff --git a/website/src/components/Playground/monaco/modelPath.ts b/website/src/components/Playground/monaco/modelPath.ts
new file mode 100644
index 000000000000..44a8a80752ec
--- /dev/null
+++ b/website/src/components/Playground/monaco/modelPath.ts
@@ -0,0 +1,45 @@
+import { useMemo } from 'react';
+
+/**
+ * Monaco models are shared page-wide, so each editor surface namespaces its
+ * files as `//`. Go-to-definition and import
+ * completions (./navigation.ts) rely on this shape.
+ */
+const MODEL_ID_ROOT = /^\/\d+\//;
+
+export function modelPath(editorId: string, filePath: string) {
+ return `/${editorId}/${filePath}`;
+}
+
+/** `/123/src/api.ts` → `/src/api.ts` (unchanged when there is no id segment) */
+export function stripModelId(path: string) {
+ return path.replace(MODEL_ID_ROOT, '/');
+}
+
+/** `/123/src/api.ts` → `/123/` (undefined when there is no id segment) */
+function modelIdSegment(path: string) {
+ return MODEL_ID_ROOT.exec(path)?.[0];
+}
+
+/**
+ * Other files in the same editor surface as `currentPath`, id stripped:
+ * import completions should not offer files from other playgrounds on the page.
+ */
+export function siblingFilePaths(
+ currentPath: string,
+ modelPaths: readonly string[],
+): string[] {
+ const id = modelIdSegment(currentPath);
+ if (!id) return [];
+ return modelPaths
+ .filter(path => path !== currentPath && modelIdSegment(path) === id)
+ .map(stripModelId);
+}
+
+/** Stable per-mount id. Also runs during SSR, but the id only reaches Monaco (inside BrowserOnly), never markup, so randomness cannot cause hydration mismatches. */
+export function useModelId() {
+ return useMemo(
+ () => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(),
+ [],
+ );
+}
diff --git a/website/src/components/Playground/monaco/navigation.ts b/website/src/components/Playground/monaco/navigation.ts
new file mode 100644
index 000000000000..782059b3f516
--- /dev/null
+++ b/website/src/components/Playground/monaco/navigation.ts
@@ -0,0 +1,119 @@
+import type * as Monaco from 'monaco-editor';
+
+import { siblingFilePaths } from './modelPath';
+
+/** Packages offered when completing a bare `from '…'` specifier. */
+const SUGGESTED_DEPENDENCIES = [
+ 'react',
+ '@data-client/rest',
+ '@data-client/react',
+ '@data-client/graphql',
+ 'bignumber.js',
+];
+
+/**
+ * Cross-tab go-to-definition: focus the editor that owns the target model.
+ * Focusing it makes EditorSurface reveal that tab (see its onFocus handlers).
+ */
+export function registerEditorOpener(monaco: typeof Monaco) {
+ monaco.editor.registerEditorOpener({
+ openCodeEditor(
+ _sourceEditor: Monaco.editor.ICodeEditor,
+ resource: Monaco.Uri,
+ selectionOrPosition?: Monaco.IRange | Monaco.IPosition,
+ ) {
+ if (!resource.path.startsWith('/')) return false;
+
+ const model = monaco.editor.getModel(resource);
+ const destinationEditor = monaco.editor
+ .getEditors()
+ .find(
+ (editor: Monaco.editor.ICodeEditor) => editor.getModel() === model,
+ );
+ if (!destinationEditor) return false;
+ // focus event is handled by editor to show that tab
+ destinationEditor.focus();
+ requestIdleCallback(() => {
+ if (monaco.Range.isIRange(selectionOrPosition)) {
+ destinationEditor.revealRangeInCenterIfOutsideViewport(
+ selectionOrPosition,
+ );
+ destinationEditor.setSelection(selectionOrPosition);
+ } else if (selectionOrPosition) {
+ destinationEditor.revealPositionInCenterIfOutsideViewport(
+ selectionOrPosition,
+ );
+ destinationEditor.setPosition(selectionOrPosition);
+ }
+ destinationEditor.focus();
+ });
+
+ return true;
+ },
+ });
+}
+
+/**
+ * Completes import specifiers: relative paths list the other files in the
+ * same playground, bare specifiers list SUGGESTED_DEPENDENCIES.
+ */
+export function registerImportCompletions(monaco: typeof Monaco) {
+ monaco.languages.registerCompletionItemProvider('typescript', {
+ triggerCharacters: ["'", '"', '.', '/'],
+ provideCompletionItems: (
+ model: Monaco.editor.ITextModel,
+ position: Monaco.Position,
+ ) => {
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column,
+ });
+ const word = model.getWordUntilPosition(position);
+ const range = {
+ startLineNumber: position.lineNumber,
+ endLineNumber: position.lineNumber,
+ startColumn: word.startColumn,
+ endColumn: word.endColumn,
+ };
+ // Match things like `from "` and `require("`
+ if (
+ !/(([\s|\n]+from\s+)|(\brequire\b\s*\())["|'][^'^"]*$/.test(
+ textUntilPosition,
+ )
+ ) {
+ return { suggestions: [] };
+ }
+
+ if (textUntilPosition.endsWith('.') || textUntilPosition.endsWith('/')) {
+ return {
+ suggestions: siblingFilePaths(
+ model.uri.path,
+ monaco.editor
+ .getModels()
+ .map(
+ (editorModel: Monaco.editor.ITextModel) => editorModel.uri.path,
+ ),
+ ).map((file: string) => ({
+ // Show the full file path for label
+ label: file,
+ // Don't keep extension for JS files
+ insertText: file.replace(/\.tsx?$/, ''),
+ kind: monaco.languages.CompletionItemKind.Module,
+ range,
+ })),
+ };
+ }
+ // User is trying to import a dependency
+ return {
+ suggestions: SUGGESTED_DEPENDENCIES.map(name => ({
+ label: name,
+ kind: monaco.languages.CompletionItemKind.Module,
+ insertText: name,
+ range,
+ })),
+ };
+ },
+ });
+}
diff --git a/website/src/components/Playground/monacoOptions.tsx b/website/src/components/Playground/monaco/options.ts
similarity index 100%
rename from website/src/components/Playground/monacoOptions.tsx
rename to website/src/components/Playground/monaco/options.ts
diff --git a/website/src/components/Playground/monacoPreloadManifest.ts b/website/src/components/Playground/monaco/preloadManifest.ts
similarity index 100%
rename from website/src/components/Playground/monacoPreloadManifest.ts
rename to website/src/components/Playground/monaco/preloadManifest.ts
diff --git a/website/src/components/Playground/injectMonacoResourceHints.ts b/website/src/components/Playground/monaco/resourceHints.ts
similarity index 91%
rename from website/src/components/Playground/injectMonacoResourceHints.ts
rename to website/src/components/Playground/monaco/resourceHints.ts
index 363a3c8b2167..c393d725f689 100644
--- a/website/src/components/Playground/injectMonacoResourceHints.ts
+++ b/website/src/components/Playground/monaco/resourceHints.ts
@@ -1,4 +1,4 @@
-import { MONACO_CDN_VS, monacoPreloadManifest } from './monacoPreloadManifest';
+import { MONACO_CDN_VS, monacoPreloadManifest } from './preloadManifest';
const RESOURCE_HINT_ATTRIBUTE = 'data-monaco-resource-hint';
diff --git a/website/src/components/Playground/monaco/setup.ts b/website/src/components/Playground/monaco/setup.ts
new file mode 100644
index 000000000000..f63d68c3d422
--- /dev/null
+++ b/website/src/components/Playground/monaco/setup.ts
@@ -0,0 +1,55 @@
+/**
+ * Side-effect module: import it from any component that renders Monaco.
+ *
+ * It runs once per page load, as soon as the importing chunk evaluates (before
+ * any editor mounts), so Monaco's CDN files and our type libs download while
+ * the page hydrates. Mobile and bots never render Monaco (see ../userAgent.ts),
+ * so they skip all of these downloads.
+ */
+import { loader } from '@monaco-editor/react';
+
+import { isMobileOrBot } from '../userAgent';
+import { registerEditorOpener, registerImportCompletions } from './navigation';
+import { MONACO_CDN_VS } from './preloadManifest';
+import { injectMonacoResourceHints } from './resourceHints';
+import { definePrismTheme } from './theme';
+import { addTypeLibs, fetchTypeLibs } from './typeLibs';
+
+if (typeof window !== 'undefined' && !isMobileOrBot()) {
+ injectMonacoResourceHints();
+ loader.config({
+ paths: {
+ vs: MONACO_CDN_VS,
+ },
+ });
+ const monacoPromise = loader.init();
+
+ // Fetch type libs in parallel with Monaco CDN bootstrap (no webpackPreload —
+ // that raced high-priority against editor.main). Apply them only after init.
+ const typeLibsPromise = fetchTypeLibs();
+
+ monacoPromise.then(async monaco => {
+ if (!monaco) return;
+ monaco.typescript.typescriptDefaults.setCompilerOptions({
+ allowNonTsExtensions: true,
+ target: monaco.typescript.ScriptTarget.ES2017,
+ jsx: monaco.typescript.JsxEmit.ReactJSX,
+ strict: true,
+ strictNullChecks: true,
+ exactOptionalPropertyTypes: true,
+ lib: ['dom', 'esnext'],
+ module: monaco.typescript.ModuleKind.ESNext,
+ moduleResolution: monaco.typescript.ModuleResolutionKind.NodeJs,
+ allowSyntheticDefaultImports: true,
+ skipLibCheck: true,
+ skipDefaultLibCheck: true,
+ noImplicitAny: false,
+ });
+ definePrismTheme(monaco);
+ registerEditorOpener(monaco);
+ registerImportCompletions(monaco);
+
+ addTypeLibs(monaco, await typeLibsPromise);
+ monaco.typescript.typescriptDefaults.setEagerModelSync(true);
+ });
+}
diff --git a/website/src/components/Playground/monaco/theme.ts b/website/src/components/Playground/monaco/theme.ts
new file mode 100644
index 000000000000..5c8a2549d62f
--- /dev/null
+++ b/website/src/components/Playground/monaco/theme.ts
@@ -0,0 +1,33 @@
+import type * as Monaco from 'monaco-editor';
+
+/** Monaco theme name matching the site's Prism (palenight) code blocks. */
+export const MONACO_THEME = 'prism';
+
+// TODO: load theme from docusaurus config so we eliminate DRY violation
+// see https://microsoft.github.io/monaco-editor/playground.html for full options
+export function definePrismTheme(monaco: typeof Monaco) {
+ monaco.editor.defineTheme(MONACO_THEME, {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [
+ { token: 'number', foreground: 'f78c6c' },
+ { token: 'string', foreground: 'b6d986' },
+ {
+ token: 'keyword',
+ fontStyle: 'italic',
+ foreground: '7da4f6',
+ },
+ // type definitions variables like const X or class X
+ { token: 'type', foreground: 'ffcb6b' },
+ { token: 'delimiter', foreground: 'C792EA' },
+
+ { token: 'tag', foreground: 'FF5590' },
+ ],
+ colors: {
+ //'editor.background': '#292d3e',
+ 'editor.foreground': '#bfc7d5',
+ //'editor.lineHighlightBorder': '#33384d',
+ 'editor.inactiveSelectionBackground': '#484d5b',
+ },
+ });
+}
diff --git a/website/src/components/Playground/monaco/typeLibs.ts b/website/src/components/Playground/monaco/typeLibs.ts
new file mode 100644
index 000000000000..e70ba8a9afff
--- /dev/null
+++ b/website/src/components/Playground/monaco/typeLibs.ts
@@ -0,0 +1,232 @@
+import type * as Monaco from 'monaco-editor';
+
+/**
+ * Type definitions for editor intellisense. Each is a separate webpack chunk
+ * (raw text) fetched in parallel with Monaco's CDN bootstrap; a failed fetch
+ * degrades to an empty lib rather than breaking the editor.
+ */
+
+type RawModule = Promise<{ default: string }>;
+
+/**
+ * `declare module ""` libs, mounted at file:///node_modules//index.d.ts.
+ * `global` libs are also declared globally (they are in the preview scope).
+ */
+const MODULE_LIBS: readonly [
+ name: string,
+ dir: string,
+ load: () => RawModule,
+ global?: true,
+][] = [
+ [
+ 'react',
+ '@types/react',
+ () =>
+ import(
+ /* webpackChunkName: 'reactDTS' */ '!!raw-loader?esModule=false!../editor-types/react.d.ts'
+ ),
+ true,
+ ],
+ [
+ 'bignumber.js',
+ 'bignumber.js',
+ () =>
+ import(
+ /* webpackChunkName: 'bignumberDTS' */ '!!raw-loader?esModule=false!../editor-types/bignumber.d.ts'
+ ),
+ ],
+ [
+ '@number-flow/react',
+ '@number-flow/react',
+ () =>
+ import(
+ /* webpackChunkName: 'numberflowDTS' */ '!!raw-loader?esModule=false!../editor-types/@number-flow/react.d.ts'
+ ),
+ ],
+ [
+ 'temporal-polyfill',
+ 'temporal-polyfill',
+ () =>
+ import(
+ /* webpackChunkName: 'temporalDTS' */ '!!raw-loader?esModule=false!../editor-types/temporal.d.ts'
+ ),
+ ],
+ [
+ 'uuid',
+ '@types/uuid',
+ () =>
+ import(
+ /* webpackChunkName: 'uuidDTS' */ '!!raw-loader?esModule=false!../editor-types/uuid.d.ts'
+ ),
+ ],
+ [
+ 'qs',
+ '@types/qs',
+ () =>
+ import(
+ /* webpackChunkName: 'qsDTS' */ '!!raw-loader?esModule=false!../editor-types/qs.d.ts'
+ ),
+ ],
+];
+
+/** `@data-client/` libs; one lazy-once chunk for the whole directory */
+const DATA_CLIENT_ENTRIES = [
+ 'rest',
+ 'rest/next',
+ 'react/next',
+ 'core/next',
+ 'core',
+ 'react',
+ 'endpoint',
+ 'normalizr',
+ 'graphql',
+] as const;
+
+/** Playground-only globals (editor-types/globals.d.ts) */
+const loadGlobals = (): RawModule =>
+ import(
+ /* webpackChunkName: 'globalsDTS' */ '!!raw-loader?esModule=false!../editor-types/globals.d.ts'
+ );
+
+/** Ambient declarations for names the preview injects via react-live scope (see ../preview/scope.ts). */
+const PREVIEW_SCOPE_DECLARATIONS = `declare function render(component:JSX.Element):void;
+ declare function uuid(): string;
+ declare function CurrentTime(props: {}):JSX.Element;
+ declare function CancelButton(props: { onClick?: () => void }):JSX.Element;
+ declare function Avatar(props: { src: string }):JSX.Element;
+ declare function Formatted({ downColor, formatter, formatterFn, timeout, transition, transitionLength, upColor, value, stylePrefix, }: NumberProps):JSX.Element
+ declare function ResetableErrorBoundary(props: { children: React.ReactNode }):JSX.Element;
+ declare function TextInput(props:Omit, 'size'> & { label?: React.ReactNode; loading?: boolean; size?: 'large' | 'medium' | 'small'; }):JSX.Element;
+ declare function TextArea(props:InputHTMLAttributes & { label?: React.ReactNode;}):JSX.Element;
+ declare function SearchIcon():JSX.Element;
+ declare function Loading():JSX.Element;
+ declare function randomFloatInRange(min: number, max: number, decimals?: number): number;
+ declare interface NumberProps {
+ /**
+ * Color value when the component flashes 'down'.
+ */
+ downColor?: string;
+ /**
+ * One of the built in formatters.
+ */
+ formatter?: 'currency' | 'percentage' | 'number';
+ /**
+ * Pass your own formatter function.
+ */
+ formatterFn?: Formatter;
+ /**
+ * Prefix for the CSS selectors in the DOM.
+ */
+ stylePrefix?: string;
+ /**
+ * Amount of time the flashed state is visible for, in milliseconds.
+ */
+ timeout?: number;
+ /**
+ * Custom CSS transition property.
+ */
+ transition?: string;
+ /**
+ * Transition length, in milliseconds.
+ */
+ transitionLength?: number;
+ /**
+ * Color value when the component flashes 'up'.
+ */
+ upColor?: string;
+ /**
+ * Value to display. The only required prop.
+ */
+ value: number;
+ }`;
+
+export interface TypeLibs {
+ modules: string[];
+ dataClient: string[];
+ globals: string;
+}
+
+/** Start every type-lib download now; resolves once all have settled. */
+export function fetchTypeLibs(): Promise {
+ const settled = (promises: RawModule[]) =>
+ Promise.allSettled(promises).then(results =>
+ results.map(result =>
+ result.status === 'fulfilled' ? result.value.default : '',
+ ),
+ );
+
+ return Promise.all([
+ settled(MODULE_LIBS.map(([, , load]) => load())),
+ loadGlobals().then(
+ ({ default: lib }) => lib,
+ () => '',
+ ),
+ settled(
+ DATA_CLIENT_ENTRIES.map(
+ entry =>
+ import(
+ /* webpackChunkName: '[request]', webpackMode: "lazy-once" */ `!!raw-loader?esModule=false!../editor-types/@data-client/${entry}.d.ts`
+ ),
+ ),
+ ),
+ ]).then(([modules, globals, dataClient]) => ({
+ modules,
+ dataClient,
+ globals,
+ }));
+}
+
+export function addTypeLibs(
+ monaco: typeof Monaco,
+ { modules, dataClient, globals }: TypeLibs,
+) {
+ const { typescriptDefaults } = monaco.typescript;
+
+ typescriptDefaults.addExtraLib(
+ `declare module "react/jsx-runtime" {
+ import './';
+ }`,
+ 'file:///node_modules/@types/react/jsx-runtime.d.ts',
+ );
+ typescriptDefaults.addExtraLib(
+ `import * as React from 'react'
+
+ declare global {
+ namespace JSX {
+ interface IntrinsicElements {
+ strike: React.DetailedHTMLProps, HTMLElement>;
+ }
+ }
+ }`,
+ 'file:///node_modules/@types/react/more.d.ts',
+ );
+ typescriptDefaults.addExtraLib(PREVIEW_SCOPE_DECLARATIONS);
+
+ MODULE_LIBS.forEach(([name, dir], i) => {
+ typescriptDefaults.addExtraLib(
+ `declare module "${name}" { ${modules[i]} }`,
+ `file:///node_modules/${dir}/index.d.ts`,
+ );
+ });
+
+ // React, NumberFlow and Temporal are also globals in the preview scope
+ MODULE_LIBS.forEach(([, , , global], i) => {
+ if (global)
+ typescriptDefaults.addExtraLib(`declare globals { ${modules[i]} }`);
+ });
+ typescriptDefaults.addExtraLib(
+ `declare globals { export { default as NumberFlow } from '@number-flow/react'; }`,
+ );
+ typescriptDefaults.addExtraLib(
+ `declare globals { export { Temporal, DateTimeFormat } from 'temporal-polyfill'; }`,
+ );
+
+ DATA_CLIENT_ENTRIES.forEach((entry, i) => {
+ typescriptDefaults.addExtraLib(
+ `declare module "@data-client/${entry}" { ${dataClient[i]} }`,
+ `file:///node_modules/@data-client/${entry}/index.d.ts`,
+ );
+ });
+
+ typescriptDefaults.addExtraLib(`declare globals { ${globals} }`);
+}
diff --git a/website/src/components/Playground/useAutoHeight.tsx b/website/src/components/Playground/monaco/useAutoHeight.ts
similarity index 100%
rename from website/src/components/Playground/useAutoHeight.tsx
rename to website/src/components/Playground/monaco/useAutoHeight.ts
diff --git a/website/src/components/Playground/FixturePreview.tsx b/website/src/components/Playground/preview/FixturePreview.tsx
similarity index 95%
rename from website/src/components/Playground/FixturePreview.tsx
rename to website/src/components/Playground/preview/FixturePreview.tsx
index 2b82e937eea6..e900665a3173 100644
--- a/website/src/components/Playground/FixturePreview.tsx
+++ b/website/src/components/Playground/preview/FixturePreview.tsx
@@ -2,8 +2,8 @@ import BrowserOnly from '@docusaurus/BrowserOnly';
import CodeBlock from '@theme/CodeBlock';
import { memo, type ReactElement } from 'react';
-import styles from './styles.module.css';
-import type { FixtureOrInterceptor } from './types';
+import styles from '../styles.module.css';
+import type { FixtureOrInterceptor } from '../types';
function FixturePreview({ fixtures }: { fixtures: FixtureOrInterceptor[] }) {
return (
diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx
index bee9dcac8a80..7183ad4f19fc 100644
--- a/website/src/components/Playground/preview/LivePreview.tsx
+++ b/website/src/components/Playground/preview/LivePreview.tsx
@@ -1,10 +1,10 @@
import { LiveProvider } from 'react-live';
+import Preview from './Preview';
+import PreviewWrapper from './PreviewWrapper';
import { previewScope } from './scope';
+import transformCode from './transformCode';
import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion';
-import Preview from '../Preview';
-import PreviewWrapper from '../PreviewWrapper';
-import transformCode from '../transformCode';
import type { PreviewProps } from '../types';
export interface LivePreviewProps extends PreviewProps {
diff --git a/website/src/components/Playground/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx
similarity index 91%
rename from website/src/components/Playground/Preview.tsx
rename to website/src/components/Playground/preview/Preview.tsx
index 21a255592d9d..2718fcf25173 100644
--- a/website/src/components/Playground/Preview.tsx
+++ b/website/src/components/Playground/preview/Preview.tsx
@@ -9,11 +9,11 @@ import { useScrollPositionBlocker } from '@docusaurus/theme-common/internal';
import clsx from 'clsx';
import React, { memo, useCallback, useMemo, lazy } from 'react';
-import Boundary from './Boundary';
+import Boundary from '../Boundary';
import StoreInspector from './StoreInspector';
-import styles from './styles.module.css';
-import type { PreviewProps } from './types';
-import { useTabStorage } from '../../utils/tabStorage';
+import { useTabStorage } from '../../../utils/tabStorage';
+import styles from '../styles.module.css';
+import type { PreviewProps } from '../types';
function Preview({
groupId,
diff --git a/website/src/components/Playground/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx
similarity index 69%
rename from website/src/components/Playground/PreviewBlock.tsx
rename to website/src/components/Playground/preview/PreviewBlock.tsx
index 1478dc8f47d8..ddc8417681a6 100644
--- a/website/src/components/Playground/PreviewBlock.tsx
+++ b/website/src/components/Playground/preview/PreviewBlock.tsx
@@ -1,9 +1,9 @@
import React from 'react';
import { LiveError, LivePreview } from 'react-live';
-import Boundary from './Boundary';
-import { Loading } from './DesignSystem/Loading';
-import styles from './styles.module.css';
+import Boundary from '../Boundary';
+import { Loading } from '../DesignSystem/Loading';
+import styles from '../styles.module.css';
export default function PreviewBlock() {
return (
diff --git a/website/src/components/Playground/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx
similarity index 87%
rename from website/src/components/Playground/PreviewWrapper.tsx
rename to website/src/components/Playground/preview/PreviewWrapper.tsx
index db5d2dc189ab..bc3586bf49ec 100644
--- a/website/src/components/Playground/PreviewWrapper.tsx
+++ b/website/src/components/Playground/preview/PreviewWrapper.tsx
@@ -1,8 +1,8 @@
import Translate from '@docusaurus/Translate';
import React from 'react';
-import Header from './Header';
-import styles from './styles.module.css';
+import Header from '../Header';
+import styles from '../styles.module.css';
export default function PreviewWrapper({ children }: Props) {
return (
diff --git a/website/src/components/Playground/StoreInspector.tsx b/website/src/components/Playground/preview/StoreInspector.tsx
similarity index 90%
rename from website/src/components/Playground/StoreInspector.tsx
rename to website/src/components/Playground/preview/StoreInspector.tsx
index 113fb8fb92e6..81d861af8e89 100644
--- a/website/src/components/Playground/StoreInspector.tsx
+++ b/website/src/components/Playground/preview/StoreInspector.tsx
@@ -2,7 +2,7 @@ import { StateContext } from '@data-client/react';
import clsx from 'clsx';
import React, { useContext, memo, useMemo } from 'react';
-import styles from './styles.module.css';
+import styles from '../styles.module.css';
import Tree from './Tree';
function StoreInspector({
@@ -24,7 +24,7 @@ function StoreInspector({
}
export default memo(StoreInspector);
-/** Toggle row shared with the preview loading fallback in ./index.tsx */
+/** Toggle row; also rendered (inert) by the preview loading fallback in ../index.tsx */
export function StoreToggle({
onClick,
open = true,
diff --git a/website/src/components/Playground/Tree.tsx b/website/src/components/Playground/preview/Tree.tsx
similarity index 100%
rename from website/src/components/Playground/Tree.tsx
rename to website/src/components/Playground/preview/Tree.tsx
diff --git a/website/src/components/Playground/__tests__/transformCode.test.ts b/website/src/components/Playground/preview/__tests__/transformCode.test.ts
similarity index 100%
rename from website/src/components/Playground/__tests__/transformCode.test.ts
rename to website/src/components/Playground/preview/__tests__/transformCode.test.ts
diff --git a/website/src/components/Playground/preview/scope.ts b/website/src/components/Playground/preview/scope.ts
index 84a4a8aa6120..2bdc881be02f 100644
--- a/website/src/components/Playground/preview/scope.ts
+++ b/website/src/components/Playground/preview/scope.ts
@@ -8,7 +8,7 @@ import { v4 as uuid } from 'uuid';
import ResetableErrorBoundary from '../../ResettableErrorBoundary';
import * as designSystem from '../DesignSystem';
-import { Temporal, Intl, DateTimeFormat } from '../temporal';
+import { Temporal, Intl, DateTimeFormat } from './temporal';
function randomFloatInRange(min: number, max: number, decimals: number) {
return parseFloat((Math.random() * (max - min) + min).toFixed(decimals));
diff --git a/website/src/components/Playground/temporal.ts b/website/src/components/Playground/preview/temporal.ts
similarity index 100%
rename from website/src/components/Playground/temporal.ts
rename to website/src/components/Playground/preview/temporal.ts
diff --git a/website/src/components/Playground/transformCode.ts b/website/src/components/Playground/preview/transformCode.ts
similarity index 100%
rename from website/src/components/Playground/transformCode.ts
rename to website/src/components/Playground/preview/transformCode.ts
diff --git a/website/src/components/Playground/userAgent.ts b/website/src/components/Playground/userAgent.ts
new file mode 100644
index 000000000000..1bb59fca5c29
--- /dev/null
+++ b/website/src/components/Playground/userAgent.ts
@@ -0,0 +1,30 @@
+/**
+ * User-agent gates for the playground's heavy client code.
+ *
+ * - Bots/crawlers: never load Monaco or the live preview; the SSG markup
+ * (static code + empty preview frame) is what gets indexed.
+ * - Mobile: no Monaco (poor touch UX and download cost); an editable
+ * react-live editor is used instead, but the live preview still runs.
+ */
+const BOT_UA = /bot|googlebot|crawler|spider|robot|crawling/i;
+const MOBILE_UA = /Mobile|Android|BlackBerry/i;
+
+function userAgent(): string {
+ return typeof navigator === 'object' ? (navigator?.userAgent ?? '') : '';
+}
+
+/**
+ * True for crawlers. Evaluated once at module load and false during SSR, so
+ * only use it outside render (e.g. in a `lazy()` factory or an effect) to
+ * avoid hydration mismatches.
+ */
+export const isBot = BOT_UA.test(userAgent());
+
+/**
+ * True for mobile browsers and crawlers. Returns false during SSR, so only
+ * call it client-side after hydration (e.g. inside ``).
+ */
+export function isMobileOrBot(): boolean {
+ const ua = userAgent();
+ return BOT_UA.test(ua) || MOBILE_UA.test(ua);
+}
diff --git a/website/src/components/StackBlitz.tsx b/website/src/components/StackBlitz.tsx
index 369f35c8e2f6..e457e34c3112 100644
--- a/website/src/components/StackBlitz.tsx
+++ b/website/src/components/StackBlitz.tsx
@@ -1,7 +1,7 @@
import Link from '@docusaurus/Link';
import type { ReactElement } from 'react';
-import { isGoogleBot } from './Playground/isMobileOrBot';
+import { isBot } from './Playground/userAgent';
import { useHasIntersected } from './useHasIntersected';
export default function StackBlitz({
@@ -49,7 +49,7 @@ export default function StackBlitz({
const [frameRef, hasIntersected] = useHasIntersected();
let embedElement: ReactElement;
- if (!hasIntersected || isGoogleBot) {
+ if (!hasIntersected || isBot) {
embedElement = (
);