From 34c4ad1d3370c8c5109109a3ca5f2b80bf6b1f02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:18:46 +0000 Subject: [PATCH 1/9] internal(website): Reorganize Playground into editor/monaco/preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 362-line monaco-init into focused modules (setup, typeLibs, theme, navigation, modelPath), group preview execution under preview/, and replace forwarding modules with direct imports. Behavior is unchanged; the new README documents the SSR, incremental-loading, mobile/bot and hidden-tab invariants a future change must keep. - monaco/: setup side effect, table-driven type libs (same chunks and order), cross-tab navigation, `//` model URI helpers - editor/: InteractiveEditor (Monaco or mobile react-live) and StaticEditor (SSR/crawler/loading markup) - userAgent.ts: one source for bot/mobile regexes (isGoogleBot → isBot) - Remove PlaygroundEditor, PlaygroundLiveEditor, DiffEditorChooser aliases, the unused readOnly prop, and unreferenced resources/ - Unit tests for highlight selections and model paths Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015H36Yk4WBpytw9L7RRVCTK --- website/raw-plugin.js | 7 +- website/scripts/generateMonacoPreloads.cjs | 2 +- website/src/components/DiffEditor.tsx | 4 +- website/src/components/DiffEditorChooser.tsx | 6 - website/src/components/DiffEditorMonaco.tsx | 13 +- website/src/components/HooksPlayground.tsx | 17 +- .../Playground/PlaygroundEditor.tsx | 10 - .../Playground/PlaygroundLiveEditor.tsx | 14 - .../Playground/PlaygroundMonacoEditor.tsx | 116 ------ website/src/components/Playground/README.md | 126 ++++++ .../Playground/editor/EditorShell.tsx | 2 +- .../Playground/editor/EditorSurface.tsx | 77 ++-- .../Playground/editor/InteractiveEditor.tsx | 97 +++++ .../Playground/editor/StaticEditor.tsx | 16 + .../useReactLiveTheme.ts} | 0 website/src/components/Playground/index.tsx | 93 ++--- .../components/Playground/isMobileOrBot.ts | 14 - .../src/components/Playground/monaco-init.ts | 362 ------------------ .../__tests__/highlightSelections.test.ts | 24 ++ .../monaco/__tests__/modelPath.test.ts | 16 + .../Playground/monaco/highlightSelections.ts | 35 ++ .../language.ts} | 0 .../components/Playground/monaco/modelPath.ts | 26 ++ .../Playground/monaco/navigation.ts | 117 ++++++ .../{monacoOptions.tsx => monaco/options.ts} | 0 .../preloadManifest.ts} | 0 .../resourceHints.ts} | 2 +- .../src/components/Playground/monaco/setup.ts | 56 +++ .../src/components/Playground/monaco/theme.ts | 33 ++ .../components/Playground/monaco/typeLibs.ts | 222 +++++++++++ .../useAutoHeight.ts} | 0 .../{ => preview}/FixturePreview.tsx | 4 +- .../Playground/preview/LivePreview.tsx | 6 +- .../Playground/{ => preview}/Preview.tsx | 8 +- .../Playground/{ => preview}/PreviewBlock.tsx | 6 +- .../{ => preview}/PreviewWrapper.tsx | 4 +- .../{ => preview}/StoreInspector.tsx | 4 +- .../Playground/{ => preview}/Tree.tsx | 0 .../__tests__/transformCode.test.ts | 0 .../components/Playground/preview/scope.ts | 2 +- .../Playground/{ => preview}/temporal.ts | 0 .../Playground/{ => preview}/transformCode.ts | 0 .../resources/PlaceholderBaseResource.ts | 51 --- .../Playground/resources/TodoResource.ts | 73 ---- .../src/components/Playground/userAgent.ts | 30 ++ website/src/components/StackBlitz.tsx | 4 +- 46 files changed, 893 insertions(+), 806 deletions(-) delete mode 100644 website/src/components/DiffEditorChooser.tsx delete mode 100644 website/src/components/Playground/PlaygroundEditor.tsx delete mode 100644 website/src/components/Playground/PlaygroundLiveEditor.tsx delete mode 100644 website/src/components/Playground/PlaygroundMonacoEditor.tsx create mode 100644 website/src/components/Playground/README.md create mode 100644 website/src/components/Playground/editor/InteractiveEditor.tsx create mode 100644 website/src/components/Playground/editor/StaticEditor.tsx rename website/src/components/Playground/{useReactLiveTheme.tsx => editor/useReactLiveTheme.ts} (100%) delete mode 100644 website/src/components/Playground/isMobileOrBot.ts delete mode 100644 website/src/components/Playground/monaco-init.ts create mode 100644 website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts create mode 100644 website/src/components/Playground/monaco/__tests__/modelPath.test.ts create mode 100644 website/src/components/Playground/monaco/highlightSelections.ts rename website/src/components/Playground/{extensionToMonacoLanguage.tsx => monaco/language.ts} (100%) create mode 100644 website/src/components/Playground/monaco/modelPath.ts create mode 100644 website/src/components/Playground/monaco/navigation.ts rename website/src/components/Playground/{monacoOptions.tsx => monaco/options.ts} (100%) rename website/src/components/Playground/{monacoPreloadManifest.ts => monaco/preloadManifest.ts} (100%) rename website/src/components/Playground/{injectMonacoResourceHints.ts => monaco/resourceHints.ts} (91%) create mode 100644 website/src/components/Playground/monaco/setup.ts create mode 100644 website/src/components/Playground/monaco/theme.ts create mode 100644 website/src/components/Playground/monaco/typeLibs.ts rename website/src/components/Playground/{useAutoHeight.tsx => monaco/useAutoHeight.ts} (100%) rename website/src/components/Playground/{ => preview}/FixturePreview.tsx (95%) rename website/src/components/Playground/{ => preview}/Preview.tsx (91%) rename website/src/components/Playground/{ => preview}/PreviewBlock.tsx (69%) rename website/src/components/Playground/{ => preview}/PreviewWrapper.tsx (87%) rename website/src/components/Playground/{ => preview}/StoreInspector.tsx (90%) rename website/src/components/Playground/{ => preview}/Tree.tsx (100%) rename website/src/components/Playground/{ => preview}/__tests__/transformCode.test.ts (100%) rename website/src/components/Playground/{ => preview}/temporal.ts (100%) rename website/src/components/Playground/{ => preview}/transformCode.ts (100%) delete mode 100644 website/src/components/Playground/resources/PlaceholderBaseResource.ts delete mode 100644 website/src/components/Playground/resources/TodoResource.ts create mode 100644 website/src/components/Playground/userAgent.ts 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/DiffEditor.tsx b/website/src/components/DiffEditor.tsx index 149d8b165814..001ff9349e4c 100644 --- a/website/src/components/DiffEditor.tsx +++ b/website/src/components/DiffEditor.tsx @@ -1,7 +1,7 @@ import CodeBlock from '@theme/CodeBlock'; import React, { useMemo } from 'react'; -import DiffEditorChooser from './DiffEditorChooser'; +import DiffEditorMonaco from './DiffEditorMonaco'; import Grid from './Grid'; import { parseCodeDocuments } from './Playground/editor/codeModel'; @@ -23,7 +23,7 @@ export default function DiffEditor({ children }: Props) { ); - return ; + return ; } interface Props { diff --git a/website/src/components/DiffEditorChooser.tsx b/website/src/components/DiffEditorChooser.tsx deleted file mode 100644 index f31a8da400b6..000000000000 --- a/website/src/components/DiffEditorChooser.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import DiffEditorMonaco from './DiffEditorMonaco'; - -// Always use DiffEditorMonaco which handles SSR via -// The mobile/bot detection is handled inside DiffEditorMonaco -const DiffEditorChooser = DiffEditorMonaco; -export default DiffEditorChooser; 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
deleted file mode 100644
index c9a299b96c2a..000000000000
--- a/website/src/components/Playground/PlaygroundEditor.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-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;
-export default PlaygroundEditor;
-
-// Re-export for use as fallback inside Monaco editor
-export { PlaygroundLiveEditor };
diff --git a/website/src/components/Playground/PlaygroundLiveEditor.tsx b/website/src/components/Playground/PlaygroundLiveEditor.tsx
deleted file mode 100644
index 5ecfc3cec133..000000000000
--- a/website/src/components/Playground/PlaygroundLiveEditor.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React, { memo } from 'react';
-import { LiveEditor } from 'react-live';
-
-const MemoEditor = memo(LiveEditor);
-
-export default function PlaygroundLiveEditor({
-  onChange,
-  code,
-}: {
-  onChange: (value: string) => void;
-  code: string;
-}) {
-  return ;
-}
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..3b3ad6dbac29
--- /dev/null
+++ b/website/src/components/Playground/README.md
@@ -0,0 +1,126 @@
+# 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` → `../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
+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="…"`, `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`).
+- 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..beb72e97fb6a 100644
--- a/website/src/components/Playground/editor/EditorSurface.tsx
+++ b/website/src/components/Playground/editor/EditorSurface.tsx
@@ -1,20 +1,17 @@
 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 Editor from '../PlaygroundEditor';
+import { modelPath, useModelId } from '../monaco/modelPath';
 import styles from '../styles.module.css';
 import TabList from '../TabList';
 import type { CodeDocument, CodeModel } from './codeModel';
+import InteractiveEditor, {
+  type InteractiveEditorProps,
+} from './InteractiveEditor';
+import StaticEditor from './StaticEditor';
 
 export interface EditorSurfaceProps extends CodeModel {
   layout: 'row' | 'stacked';
@@ -34,7 +31,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 +39,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 +111,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 +126,20 @@ 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 +250,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..46c268ab1b1e --- /dev/null +++ b/website/src/components/Playground/editor/InteractiveEditor.tsx @@ -0,0 +1,97 @@ +import Editor from '@monaco-editor/react'; +import type * as Monaco from 'monaco-editor'; +import { memo, useCallback, useMemo } from 'react'; +import { LiveEditor } from 'react-live'; + +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 { isMobileOrBot } from '../userAgent'; +import StaticEditor from './StaticEditor'; + +// TODO: consider using the ts worker's getEmitOutput to compile rather than babel + +const MobileEditor = memo(LiveEditor); + +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; +} + +/** + * 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 = 'tsx', +}: InteractiveEditorProps) { + const { height, handleMount: handleAutoMount } = useAutoHeight({ + initialContentHeight: code.split('\n').length * options.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(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/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 ( -
-
- -
-
- ); -} - -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..dd3e53cdf497 --- /dev/null +++ b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts @@ -0,0 +1,24 @@ +/// + +import { highlightSelections } from '../highlightSelections'; + +const lines = (start: number, end: number) => ({ + selectionStartLineNumber: start, + selectionStartColumn: 0, + positionLineNumber: end, + positionColumn: 0, +}); + +describe('highlightSelections', () => { + 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-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..5146668ab481 --- /dev/null +++ b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts @@ -0,0 +1,16 @@ +/// + +import { modelPath, stripModelId } 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', + ); + }); +}); diff --git a/website/src/components/Playground/monaco/highlightSelections.ts b/website/src/components/Playground/monaco/highlightSelections.ts new file mode 100644 index 000000000000..fd19b43ac24b --- /dev/null +++ b/website/src/components/Playground/monaco/highlightSelections.ts @@ -0,0 +1,35 @@ +import type { ISelection } from 'monaco-editor'; +import rangeParser from 'parse-numeric-range'; + +/** + * Converts a code-fence highlight range (`{1-3,5}`) into Monaco selections, + * merging consecutive lines into one whole-line selection each. + */ +export function highlightSelections(highlights: string): ISelection[] { + const lines = rangeParser(highlights); + 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..c349573561c8 --- /dev/null +++ b/website/src/components/Playground/monaco/modelPath.ts @@ -0,0 +1,26 @@ +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_SEGMENT = /\/\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) { + const candidateId = MODEL_ID_SEGMENT.exec(path)?.[0] ?? ''; + return path.substring(candidateId.length - 1); +} + +/** Stable per-mount id. Only used client-side (inside BrowserOnly), 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..20ffdec78888 --- /dev/null +++ b/website/src/components/Playground/monaco/navigation.ts @@ -0,0 +1,117 @@ +import type * as Monaco from 'monaco-editor'; + +import { stripModelId } 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 page's other models + * (playground tabs), 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: monaco.editor + .getModels() + .map((editorModel: Monaco.editor.ITextModel) => + stripModelId(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..11ca8ba50f37 --- /dev/null +++ b/website/src/components/Playground/monaco/setup.ts @@ -0,0 +1,56 @@ +/** + * 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); + return monaco; + }); +} 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..eb9f9f0fa7be --- /dev/null +++ b/website/src/components/Playground/monaco/typeLibs.ts @@ -0,0 +1,222 @@ +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 */ +const MODULE_LIBS: readonly [ + name: string, + dir: string, + load: () => RawModule, +][] = [ + [ + 'react', + '@types/react', + () => + import( + /* webpackChunkName: 'reactDTS' */ '!!raw-loader?esModule=false!../editor-types/react.d.ts' + ), + ], + [ + '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())), + settled([loadGlobals()]), + 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; + const react = modules[0]; + + 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 + typescriptDefaults.addExtraLib(`declare globals { ${react} }`); + 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/resources/PlaceholderBaseResource.ts b/website/src/components/Playground/resources/PlaceholderBaseResource.ts deleted file mode 100644 index a9a048127417..000000000000 --- a/website/src/components/Playground/resources/PlaceholderBaseResource.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Entity, resource, RestEndpoint, Schema } from '@data-client/rest'; - -export abstract class PlaceholderEntity extends Entity { - id = 0; -} - -/** Common patterns in the https://jsonplaceholder.typicode.com API */ -export function createPlaceholderResource({ - path, - schema, - Endpoint = RestEndpoint, -}: { - readonly path: U; - readonly schema: S; - readonly Endpoint?: typeof RestEndpoint; -}) { - const base = resource({ path, schema, Endpoint }); - const partialUpdate = base.partialUpdate.extend({ - fetch: async function ( - this: ThisParameterType, - ...args: Parameters - ) { - // body only contains what we're changing, but we can find the id in params - return { - ...(await base.partialUpdate.call(this, ...args)), - id: (args[0] as unknown as { id: string | number }).id, - }; - } as typeof base.partialUpdate, - }); - return { - ...base, - // Endpoint overrides are to compensate for the jsonplaceholder API not returning - // the correct ID in certain cases - // - // This is sometimes needed when you don't control the server API itself - // More here: https://dataclient.io/docs/guides/network-transform#case-of-the-missing-id - partialUpdate, - create: base.create.extend({ - fetch: async function ( - this: ThisParameterType, - ...args: Parameters - ) { - // body only contains what we're changing, but we can find the id in params - return { - ...(await base.create.call(this, ...args)), - id: (args[args.length - 1] as unknown as { id: string | number }).id, - }; - } as typeof base.create, - }), - } as unknown as typeof base; -} diff --git a/website/src/components/Playground/resources/TodoResource.ts b/website/src/components/Playground/resources/TodoResource.ts deleted file mode 100644 index c7559ad7746c..000000000000 --- a/website/src/components/Playground/resources/TodoResource.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { RestEndpoint, RestGenerics } from '@data-client/rest'; - -import { - createPlaceholderResource, - PlaceholderEntity, -} from './PlaceholderBaseResource'; - -export class Todo extends PlaceholderEntity { - readonly userId: number = 0; - readonly title: string = ''; - readonly completed: boolean = false; - readonly updatedAt: number = 0; - - static key = 'Todo'; - - static shouldUpdate( - existingMeta: { date: number; fetchedAt: number }, - incomingMeta: { date: number; fetchedAt: number }, - existing: { updatedAt: number }, - incoming: { updatedAt: number }, - ) { - return ( - existing.updatedAt === undefined || - existing.updatedAt <= incoming.updatedAt - ); - } -} - -export class TodoEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - getRequestInit(body?: any) { - if (body) { - body = { ...body, updatedAt: Date.now() }; - } - return super.getRequestInit.call(this, body); - } -} - -const TodoResourceBase = createPlaceholderResource({ - path: 'https\\://jsonplaceholder.typicode.com/todos/:id', - schema: Todo, - Endpoint: TodoEndpoint, -}); -export const TodoResource = { - ...TodoResourceBase, - partialUpdate: TodoResourceBase.partialUpdate.extend({ - getOptimisticResponse(snap, params, body) { - return { - id: params.id, - ...snap.get(Todo, { id: params.id }), - ...body, - updatedAt: snap.fetchedAt, - }; - }, - }), - create: TodoResourceBase.create.extend({ - getOptimisticResponse(snap, body) { - return { ...body, updatedAt: snap.fetchedAt }; - }, - update: newResourceId => ({ - [TodoResourceBase.getList.key()]: (resourceIds: string[] = []) => [ - ...resourceIds, - newResourceId, - ], - }), - }), - delete: TodoResourceBase.delete.extend({ - getOptimisticResponse(snap, params) { - return { ...params, updatedAt: snap.fetchedAt }; - }, - }), -}; 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 = ( ); From cd885d850b9bed9d5eeb23a1e8ad2ae9affd7764 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:24:52 +0000 Subject: [PATCH 2/9] internal(website): Keep highlightSelections free of website-only deps CI runs Playground unit tests without website dependencies installed, so parse the highlight range in InteractiveEditor and pass line numbers. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015H36Yk4WBpytw9L7RRVCTK --- .../components/Playground/editor/InteractiveEditor.tsx | 3 ++- .../monaco/__tests__/highlightSelections.test.ts | 4 ++-- .../components/Playground/monaco/highlightSelections.ts | 9 ++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/website/src/components/Playground/editor/InteractiveEditor.tsx b/website/src/components/Playground/editor/InteractiveEditor.tsx index 46c268ab1b1e..fdfd82dedc31 100644 --- a/website/src/components/Playground/editor/InteractiveEditor.tsx +++ b/website/src/components/Playground/editor/InteractiveEditor.tsx @@ -1,5 +1,6 @@ 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 { LiveEditor } from 'react-live'; @@ -56,7 +57,7 @@ function InteractiveEditor({ const handleMount = useCallback((editor: Monaco.editor.ICodeEditor) => { if (autoFocus) editor.focus(); if (highlights) { - const selections = highlightSelections(highlights); + const selections = highlightSelections(rangeParser(highlights)); if (selections.length) editor.setSelections(selections); } // Focus reveals this tab (also how cross-tab go to definition lands) diff --git a/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts index dd3e53cdf497..bd4393594091 100644 --- a/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts +++ b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts @@ -11,11 +11,11 @@ const lines = (start: number, end: number) => ({ describe('highlightSelections', () => { test('selects a single line through the start of the next', () => { - expect(highlightSelections('2')).toEqual([lines(2, 3)]); + expect(highlightSelections([2])).toEqual([lines(2, 3)]); }); test('merges consecutive lines and splits gaps', () => { - expect(highlightSelections('1-3,5,7-8')).toEqual([ + 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/highlightSelections.ts b/website/src/components/Playground/monaco/highlightSelections.ts index fd19b43ac24b..c68f00d2a876 100644 --- a/website/src/components/Playground/monaco/highlightSelections.ts +++ b/website/src/components/Playground/monaco/highlightSelections.ts @@ -1,12 +1,11 @@ import type { ISelection } from 'monaco-editor'; -import rangeParser from 'parse-numeric-range'; /** - * Converts a code-fence highlight range (`{1-3,5}`) into Monaco selections, - * merging consecutive lines into one whole-line selection each. + * 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(highlights: string): ISelection[] { - const lines = rangeParser(highlights); +export function highlightSelections(lines: readonly number[]): ISelection[] { if (!lines.length) return []; let selectionStartLineNumber = lines[0]; From 6ab163c3a8d9b3732d5d0794e8dc8a62d0bb40be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:28:38 +0000 Subject: [PATCH 3/9] internal(website): Cover useModelId and empty highlight ranges Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015H36Yk4WBpytw9L7RRVCTK --- .../monaco/__tests__/highlightSelections.test.ts | 4 ++++ .../Playground/monaco/__tests__/modelPath.test.ts | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts index bd4393594091..43eb292bf540 100644 --- a/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts +++ b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts @@ -10,6 +10,10 @@ const lines = (start: number, end: number) => ({ }); 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)]); }); diff --git a/website/src/components/Playground/monaco/__tests__/modelPath.test.ts b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts index 5146668ab481..8415e7d2ab98 100644 --- a/website/src/components/Playground/monaco/__tests__/modelPath.test.ts +++ b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts @@ -1,6 +1,8 @@ /// -import { modelPath, stripModelId } from '../modelPath'; +import { renderHook } from '@testing-library/react'; + +import { modelPath, stripModelId, useModelId } from '../modelPath'; describe('model paths', () => { test('round-trips a namespaced file path', () => { @@ -13,4 +15,13 @@ describe('model paths', () => { '/node_modules/react/index.d.ts', ); }); + + 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); + }); }); From cf2de88e98b2da9b8b5126dbc6866dbe8ae48d9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:57:16 +0000 Subject: [PATCH 4/9] internal(website): Restore PlaygroundEditor, PlaygroundLiveEditor, DiffEditorChooser, readOnly and resources Keep these entry points and the readOnly option available for future use; they now point at the reorganized editor modules. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015H36Yk4WBpytw9L7RRVCTK --- website/src/components/DiffEditor.tsx | 4 +- website/src/components/DiffEditorChooser.tsx | 6 ++ .../Playground/PlaygroundEditor.tsx | 10 +++ .../Playground/PlaygroundLiveEditor.tsx | 14 ++++ website/src/components/Playground/README.md | 3 + .../Playground/editor/EditorSurface.tsx | 7 +- .../Playground/editor/InteractiveEditor.tsx | 13 ++-- .../resources/PlaceholderBaseResource.ts | 51 +++++++++++++ .../Playground/resources/TodoResource.ts | 73 +++++++++++++++++++ 9 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 website/src/components/DiffEditorChooser.tsx create mode 100644 website/src/components/Playground/PlaygroundEditor.tsx create mode 100644 website/src/components/Playground/PlaygroundLiveEditor.tsx create mode 100644 website/src/components/Playground/resources/PlaceholderBaseResource.ts create mode 100644 website/src/components/Playground/resources/TodoResource.ts diff --git a/website/src/components/DiffEditor.tsx b/website/src/components/DiffEditor.tsx index 001ff9349e4c..149d8b165814 100644 --- a/website/src/components/DiffEditor.tsx +++ b/website/src/components/DiffEditor.tsx @@ -1,7 +1,7 @@ import CodeBlock from '@theme/CodeBlock'; import React, { useMemo } from 'react'; -import DiffEditorMonaco from './DiffEditorMonaco'; +import DiffEditorChooser from './DiffEditorChooser'; import Grid from './Grid'; import { parseCodeDocuments } from './Playground/editor/codeModel'; @@ -23,7 +23,7 @@ export default function DiffEditor({ children }: Props) { ); - return ; + return ; } interface Props { diff --git a/website/src/components/DiffEditorChooser.tsx b/website/src/components/DiffEditorChooser.tsx new file mode 100644 index 000000000000..f31a8da400b6 --- /dev/null +++ b/website/src/components/DiffEditorChooser.tsx @@ -0,0 +1,6 @@ +import DiffEditorMonaco from './DiffEditorMonaco'; + +// Always use DiffEditorMonaco which handles SSR via +// The mobile/bot detection is handled inside DiffEditorMonaco +const DiffEditorChooser = DiffEditorMonaco; +export default DiffEditorChooser; diff --git a/website/src/components/Playground/PlaygroundEditor.tsx b/website/src/components/Playground/PlaygroundEditor.tsx new file mode 100644 index 000000000000..e9796fbb16bd --- /dev/null +++ b/website/src/components/Playground/PlaygroundEditor.tsx @@ -0,0 +1,10 @@ +import InteractiveEditor from './editor/InteractiveEditor'; +import PlaygroundLiveEditor from './PlaygroundLiveEditor'; + +// Always use Monaco editor wrapper - mobile/bot check happens inside +// This avoids hydration mismatch from module-level navigator checks +const PlaygroundEditor = InteractiveEditor; +export default PlaygroundEditor; + +// Re-export for use as fallback inside Monaco editor +export { PlaygroundLiveEditor }; diff --git a/website/src/components/Playground/PlaygroundLiveEditor.tsx b/website/src/components/Playground/PlaygroundLiveEditor.tsx new file mode 100644 index 000000000000..5ecfc3cec133 --- /dev/null +++ b/website/src/components/Playground/PlaygroundLiveEditor.tsx @@ -0,0 +1,14 @@ +import React, { memo } from 'react'; +import { LiveEditor } from 'react-live'; + +const MemoEditor = memo(LiveEditor); + +export default function PlaygroundLiveEditor({ + onChange, + code, +}: { + onChange: (value: string) => void; + code: string; +}) { + return ; +} diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 3b3ad6dbac29..e730f2c790aa 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -19,6 +19,9 @@ indexable and hydration-safe, and those behaviors are easy to drop silently. ``` 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 diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index beb72e97fb6a..92a690d89c75 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -5,12 +5,11 @@ 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 InteractiveEditor, { - type InteractiveEditorProps, -} from './InteractiveEditor'; +import type { InteractiveEditorProps } from './InteractiveEditor'; import StaticEditor from './StaticEditor'; export interface EditorSurfaceProps extends CodeModel { @@ -150,7 +149,7 @@ function TextEditTab({ {/* Not yet interactive (e.g. never-shown Demo tab): skip Monaco entirely */} {interactive ? - {() => } + {() => } : staticView}
diff --git a/website/src/components/Playground/editor/InteractiveEditor.tsx b/website/src/components/Playground/editor/InteractiveEditor.tsx index fdfd82dedc31..df7efb2a6192 100644 --- a/website/src/components/Playground/editor/InteractiveEditor.tsx +++ b/website/src/components/Playground/editor/InteractiveEditor.tsx @@ -2,7 +2,6 @@ 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 { LiveEditor } from 'react-live'; import '../monaco/setup'; import { highlightSelections } from '../monaco/highlightSelections'; @@ -10,13 +9,12 @@ 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 -const MobileEditor = memo(LiveEditor); - export interface InteractiveEditorProps { onChange: (value: string | undefined) => void; code: string; @@ -31,6 +29,7 @@ export interface InteractiveEditorProps { /** Whether this tab is visible; re-measures height when it becomes so */ isFocused?: boolean; language?: string; + readOnly?: boolean; } /** @@ -47,9 +46,11 @@ function InteractiveEditor({ autoFocus = false, isFocused = false, language = 'tsx', + readOnly = false, }: InteractiveEditorProps) { + const editorOptions = useMemo(() => ({ ...options, readOnly }), [readOnly]); const { height, handleMount: handleAutoMount } = useAutoHeight({ - initialContentHeight: code.split('\n').length * options.lineHeight, + initialContentHeight: code.split('\n').length * editorOptions.lineHeight, isFocused, }); @@ -78,7 +79,7 @@ function InteractiveEditor({ // 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 ; } return ( @@ -87,7 +88,7 @@ function InteractiveEditor({ defaultLanguage={extensionToMonacoLanguage(language)} onChange={onChange} defaultValue={code} - options={options} + options={editorOptions} theme={MONACO_THEME} onMount={handleMount} height={height} diff --git a/website/src/components/Playground/resources/PlaceholderBaseResource.ts b/website/src/components/Playground/resources/PlaceholderBaseResource.ts new file mode 100644 index 000000000000..a9a048127417 --- /dev/null +++ b/website/src/components/Playground/resources/PlaceholderBaseResource.ts @@ -0,0 +1,51 @@ +import { Entity, resource, RestEndpoint, Schema } from '@data-client/rest'; + +export abstract class PlaceholderEntity extends Entity { + id = 0; +} + +/** Common patterns in the https://jsonplaceholder.typicode.com API */ +export function createPlaceholderResource({ + path, + schema, + Endpoint = RestEndpoint, +}: { + readonly path: U; + readonly schema: S; + readonly Endpoint?: typeof RestEndpoint; +}) { + const base = resource({ path, schema, Endpoint }); + const partialUpdate = base.partialUpdate.extend({ + fetch: async function ( + this: ThisParameterType, + ...args: Parameters + ) { + // body only contains what we're changing, but we can find the id in params + return { + ...(await base.partialUpdate.call(this, ...args)), + id: (args[0] as unknown as { id: string | number }).id, + }; + } as typeof base.partialUpdate, + }); + return { + ...base, + // Endpoint overrides are to compensate for the jsonplaceholder API not returning + // the correct ID in certain cases + // + // This is sometimes needed when you don't control the server API itself + // More here: https://dataclient.io/docs/guides/network-transform#case-of-the-missing-id + partialUpdate, + create: base.create.extend({ + fetch: async function ( + this: ThisParameterType, + ...args: Parameters + ) { + // body only contains what we're changing, but we can find the id in params + return { + ...(await base.create.call(this, ...args)), + id: (args[args.length - 1] as unknown as { id: string | number }).id, + }; + } as typeof base.create, + }), + } as unknown as typeof base; +} diff --git a/website/src/components/Playground/resources/TodoResource.ts b/website/src/components/Playground/resources/TodoResource.ts new file mode 100644 index 000000000000..c7559ad7746c --- /dev/null +++ b/website/src/components/Playground/resources/TodoResource.ts @@ -0,0 +1,73 @@ +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +import { + createPlaceholderResource, + PlaceholderEntity, +} from './PlaceholderBaseResource'; + +export class Todo extends PlaceholderEntity { + readonly userId: number = 0; + readonly title: string = ''; + readonly completed: boolean = false; + readonly updatedAt: number = 0; + + static key = 'Todo'; + + static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: { updatedAt: number }, + incoming: { updatedAt: number }, + ) { + return ( + existing.updatedAt === undefined || + existing.updatedAt <= incoming.updatedAt + ); + } +} + +export class TodoEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getRequestInit(body?: any) { + if (body) { + body = { ...body, updatedAt: Date.now() }; + } + return super.getRequestInit.call(this, body); + } +} + +const TodoResourceBase = createPlaceholderResource({ + path: 'https\\://jsonplaceholder.typicode.com/todos/:id', + schema: Todo, + Endpoint: TodoEndpoint, +}); +export const TodoResource = { + ...TodoResourceBase, + partialUpdate: TodoResourceBase.partialUpdate.extend({ + getOptimisticResponse(snap, params, body) { + return { + id: params.id, + ...snap.get(Todo, { id: params.id }), + ...body, + updatedAt: snap.fetchedAt, + }; + }, + }), + create: TodoResourceBase.create.extend({ + getOptimisticResponse(snap, body) { + return { ...body, updatedAt: snap.fetchedAt }; + }, + update: newResourceId => ({ + [TodoResourceBase.getList.key()]: (resourceIds: string[] = []) => [ + ...resourceIds, + newResourceId, + ], + }), + }), + delete: TodoResourceBase.delete.extend({ + getOptimisticResponse(snap, params) { + return { ...params, updatedAt: snap.fetchedAt }; + }, + }), +}; From 82bf2d06f9ca91d30e1cb03a130bdb859b7cfe7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 16:11:15 +0000 Subject: [PATCH 5/9] internal(website): Restore deletions Staff already accepted cf2de88e brought the dead files and the unused editor prop back. This commit restores the deletions Staff already accepted. Review: https://github.com/reactive/data-client/pull/4104#pullrequestreview-5401577634 Co-authored-by: Nathaniel Tucker --- website/src/components/DiffEditor.tsx | 4 +- website/src/components/DiffEditorChooser.tsx | 6 -- .../Playground/PlaygroundEditor.tsx | 10 --- website/src/components/Playground/README.md | 2 - .../Playground/editor/EditorSurface.tsx | 7 +- .../Playground/editor/InteractiveEditor.tsx | 7 +- .../resources/PlaceholderBaseResource.ts | 51 ------------- .../Playground/resources/TodoResource.ts | 73 ------------------- 8 files changed, 8 insertions(+), 152 deletions(-) delete mode 100644 website/src/components/DiffEditorChooser.tsx delete mode 100644 website/src/components/Playground/PlaygroundEditor.tsx delete mode 100644 website/src/components/Playground/resources/PlaceholderBaseResource.ts delete mode 100644 website/src/components/Playground/resources/TodoResource.ts diff --git a/website/src/components/DiffEditor.tsx b/website/src/components/DiffEditor.tsx index 149d8b165814..001ff9349e4c 100644 --- a/website/src/components/DiffEditor.tsx +++ b/website/src/components/DiffEditor.tsx @@ -1,7 +1,7 @@ import CodeBlock from '@theme/CodeBlock'; import React, { useMemo } from 'react'; -import DiffEditorChooser from './DiffEditorChooser'; +import DiffEditorMonaco from './DiffEditorMonaco'; import Grid from './Grid'; import { parseCodeDocuments } from './Playground/editor/codeModel'; @@ -23,7 +23,7 @@ export default function DiffEditor({ children }: Props) { ); - return ; + return ; } interface Props { diff --git a/website/src/components/DiffEditorChooser.tsx b/website/src/components/DiffEditorChooser.tsx deleted file mode 100644 index f31a8da400b6..000000000000 --- a/website/src/components/DiffEditorChooser.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import DiffEditorMonaco from './DiffEditorMonaco'; - -// Always use DiffEditorMonaco which handles SSR via -// The mobile/bot detection is handled inside DiffEditorMonaco -const DiffEditorChooser = DiffEditorMonaco; -export default DiffEditorChooser; diff --git a/website/src/components/Playground/PlaygroundEditor.tsx b/website/src/components/Playground/PlaygroundEditor.tsx deleted file mode 100644 index e9796fbb16bd..000000000000 --- a/website/src/components/Playground/PlaygroundEditor.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import InteractiveEditor from './editor/InteractiveEditor'; -import PlaygroundLiveEditor from './PlaygroundLiveEditor'; - -// Always use Monaco editor wrapper - mobile/bot check happens inside -// This avoids hydration mismatch from module-level navigator checks -const PlaygroundEditor = InteractiveEditor; -export default PlaygroundEditor; - -// Re-export for use as fallback inside Monaco editor -export { PlaygroundLiveEditor }; diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index e730f2c790aa..e4894f429226 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -19,9 +19,7 @@ indexable and hydration-safe, and those behaviors are easy to drop silently. ``` 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 diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index 92a690d89c75..beb72e97fb6a 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -5,11 +5,12 @@ 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 InteractiveEditor, { + type InteractiveEditorProps, +} from './InteractiveEditor'; import StaticEditor from './StaticEditor'; export interface EditorSurfaceProps extends CodeModel { @@ -149,7 +150,7 @@ function TextEditTab({ {/* Not yet interactive (e.g. never-shown Demo tab): skip Monaco entirely */} {interactive ? - {() => } + {() => } : staticView} diff --git a/website/src/components/Playground/editor/InteractiveEditor.tsx b/website/src/components/Playground/editor/InteractiveEditor.tsx index df7efb2a6192..5ae9292cc219 100644 --- a/website/src/components/Playground/editor/InteractiveEditor.tsx +++ b/website/src/components/Playground/editor/InteractiveEditor.tsx @@ -29,7 +29,6 @@ export interface InteractiveEditorProps { /** Whether this tab is visible; re-measures height when it becomes so */ isFocused?: boolean; language?: string; - readOnly?: boolean; } /** @@ -46,11 +45,9 @@ function InteractiveEditor({ autoFocus = false, isFocused = false, language = 'tsx', - readOnly = false, }: InteractiveEditorProps) { - const editorOptions = useMemo(() => ({ ...options, readOnly }), [readOnly]); const { height, handleMount: handleAutoMount } = useAutoHeight({ - initialContentHeight: code.split('\n').length * editorOptions.lineHeight, + initialContentHeight: code.split('\n').length * options.lineHeight, isFocused, }); @@ -88,7 +85,7 @@ function InteractiveEditor({ defaultLanguage={extensionToMonacoLanguage(language)} onChange={onChange} defaultValue={code} - options={editorOptions} + options={options} theme={MONACO_THEME} onMount={handleMount} height={height} diff --git a/website/src/components/Playground/resources/PlaceholderBaseResource.ts b/website/src/components/Playground/resources/PlaceholderBaseResource.ts deleted file mode 100644 index a9a048127417..000000000000 --- a/website/src/components/Playground/resources/PlaceholderBaseResource.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Entity, resource, RestEndpoint, Schema } from '@data-client/rest'; - -export abstract class PlaceholderEntity extends Entity { - id = 0; -} - -/** Common patterns in the https://jsonplaceholder.typicode.com API */ -export function createPlaceholderResource({ - path, - schema, - Endpoint = RestEndpoint, -}: { - readonly path: U; - readonly schema: S; - readonly Endpoint?: typeof RestEndpoint; -}) { - const base = resource({ path, schema, Endpoint }); - const partialUpdate = base.partialUpdate.extend({ - fetch: async function ( - this: ThisParameterType, - ...args: Parameters - ) { - // body only contains what we're changing, but we can find the id in params - return { - ...(await base.partialUpdate.call(this, ...args)), - id: (args[0] as unknown as { id: string | number }).id, - }; - } as typeof base.partialUpdate, - }); - return { - ...base, - // Endpoint overrides are to compensate for the jsonplaceholder API not returning - // the correct ID in certain cases - // - // This is sometimes needed when you don't control the server API itself - // More here: https://dataclient.io/docs/guides/network-transform#case-of-the-missing-id - partialUpdate, - create: base.create.extend({ - fetch: async function ( - this: ThisParameterType, - ...args: Parameters - ) { - // body only contains what we're changing, but we can find the id in params - return { - ...(await base.create.call(this, ...args)), - id: (args[args.length - 1] as unknown as { id: string | number }).id, - }; - } as typeof base.create, - }), - } as unknown as typeof base; -} diff --git a/website/src/components/Playground/resources/TodoResource.ts b/website/src/components/Playground/resources/TodoResource.ts deleted file mode 100644 index c7559ad7746c..000000000000 --- a/website/src/components/Playground/resources/TodoResource.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { RestEndpoint, RestGenerics } from '@data-client/rest'; - -import { - createPlaceholderResource, - PlaceholderEntity, -} from './PlaceholderBaseResource'; - -export class Todo extends PlaceholderEntity { - readonly userId: number = 0; - readonly title: string = ''; - readonly completed: boolean = false; - readonly updatedAt: number = 0; - - static key = 'Todo'; - - static shouldUpdate( - existingMeta: { date: number; fetchedAt: number }, - incomingMeta: { date: number; fetchedAt: number }, - existing: { updatedAt: number }, - incoming: { updatedAt: number }, - ) { - return ( - existing.updatedAt === undefined || - existing.updatedAt <= incoming.updatedAt - ); - } -} - -export class TodoEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - getRequestInit(body?: any) { - if (body) { - body = { ...body, updatedAt: Date.now() }; - } - return super.getRequestInit.call(this, body); - } -} - -const TodoResourceBase = createPlaceholderResource({ - path: 'https\\://jsonplaceholder.typicode.com/todos/:id', - schema: Todo, - Endpoint: TodoEndpoint, -}); -export const TodoResource = { - ...TodoResourceBase, - partialUpdate: TodoResourceBase.partialUpdate.extend({ - getOptimisticResponse(snap, params, body) { - return { - id: params.id, - ...snap.get(Todo, { id: params.id }), - ...body, - updatedAt: snap.fetchedAt, - }; - }, - }), - create: TodoResourceBase.create.extend({ - getOptimisticResponse(snap, body) { - return { ...body, updatedAt: snap.fetchedAt }; - }, - update: newResourceId => ({ - [TodoResourceBase.getList.key()]: (resourceIds: string[] = []) => [ - ...resourceIds, - newResourceId, - ], - }), - }), - delete: TodoResourceBase.delete.extend({ - getOptimisticResponse(snap, params) { - return { ...params, updatedAt: snap.fetchedAt }; - }, - }), -}; From c529c24cfe6791076e5028cc9b43fc4138d28e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:50:25 +0000 Subject: [PATCH 6/9] internal(website): Keep restored Playground files and readOnly prop Reverts 82bf2d0 per Nathaniel's choice to keep PlaygroundEditor, DiffEditorChooser, the readOnly prop and Playground/resources for now. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015H36Yk4WBpytw9L7RRVCTK --- website/src/components/DiffEditor.tsx | 4 +- website/src/components/DiffEditorChooser.tsx | 6 ++ .../Playground/PlaygroundEditor.tsx | 10 +++ website/src/components/Playground/README.md | 2 + .../Playground/editor/EditorSurface.tsx | 7 +- .../Playground/editor/InteractiveEditor.tsx | 7 +- .../resources/PlaceholderBaseResource.ts | 51 +++++++++++++ .../Playground/resources/TodoResource.ts | 73 +++++++++++++++++++ 8 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 website/src/components/DiffEditorChooser.tsx create mode 100644 website/src/components/Playground/PlaygroundEditor.tsx create mode 100644 website/src/components/Playground/resources/PlaceholderBaseResource.ts create mode 100644 website/src/components/Playground/resources/TodoResource.ts diff --git a/website/src/components/DiffEditor.tsx b/website/src/components/DiffEditor.tsx index 001ff9349e4c..149d8b165814 100644 --- a/website/src/components/DiffEditor.tsx +++ b/website/src/components/DiffEditor.tsx @@ -1,7 +1,7 @@ import CodeBlock from '@theme/CodeBlock'; import React, { useMemo } from 'react'; -import DiffEditorMonaco from './DiffEditorMonaco'; +import DiffEditorChooser from './DiffEditorChooser'; import Grid from './Grid'; import { parseCodeDocuments } from './Playground/editor/codeModel'; @@ -23,7 +23,7 @@ export default function DiffEditor({ children }: Props) { ); - return ; + return ; } interface Props { diff --git a/website/src/components/DiffEditorChooser.tsx b/website/src/components/DiffEditorChooser.tsx new file mode 100644 index 000000000000..f31a8da400b6 --- /dev/null +++ b/website/src/components/DiffEditorChooser.tsx @@ -0,0 +1,6 @@ +import DiffEditorMonaco from './DiffEditorMonaco'; + +// Always use DiffEditorMonaco which handles SSR via +// The mobile/bot detection is handled inside DiffEditorMonaco +const DiffEditorChooser = DiffEditorMonaco; +export default DiffEditorChooser; diff --git a/website/src/components/Playground/PlaygroundEditor.tsx b/website/src/components/Playground/PlaygroundEditor.tsx new file mode 100644 index 000000000000..e9796fbb16bd --- /dev/null +++ b/website/src/components/Playground/PlaygroundEditor.tsx @@ -0,0 +1,10 @@ +import InteractiveEditor from './editor/InteractiveEditor'; +import PlaygroundLiveEditor from './PlaygroundLiveEditor'; + +// Always use Monaco editor wrapper - mobile/bot check happens inside +// This avoids hydration mismatch from module-level navigator checks +const PlaygroundEditor = InteractiveEditor; +export default PlaygroundEditor; + +// Re-export for use as fallback inside Monaco editor +export { PlaygroundLiveEditor }; diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index e4894f429226..e730f2c790aa 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -19,7 +19,9 @@ indexable and hydration-safe, and those behaviors are easy to drop silently. ``` 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 diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index beb72e97fb6a..92a690d89c75 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -5,12 +5,11 @@ 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 InteractiveEditor, { - type InteractiveEditorProps, -} from './InteractiveEditor'; +import type { InteractiveEditorProps } from './InteractiveEditor'; import StaticEditor from './StaticEditor'; export interface EditorSurfaceProps extends CodeModel { @@ -150,7 +149,7 @@ function TextEditTab({ {/* Not yet interactive (e.g. never-shown Demo tab): skip Monaco entirely */} {interactive ? - {() => } + {() => } : staticView} diff --git a/website/src/components/Playground/editor/InteractiveEditor.tsx b/website/src/components/Playground/editor/InteractiveEditor.tsx index 5ae9292cc219..df7efb2a6192 100644 --- a/website/src/components/Playground/editor/InteractiveEditor.tsx +++ b/website/src/components/Playground/editor/InteractiveEditor.tsx @@ -29,6 +29,7 @@ export interface InteractiveEditorProps { /** Whether this tab is visible; re-measures height when it becomes so */ isFocused?: boolean; language?: string; + readOnly?: boolean; } /** @@ -45,9 +46,11 @@ function InteractiveEditor({ autoFocus = false, isFocused = false, language = 'tsx', + readOnly = false, }: InteractiveEditorProps) { + const editorOptions = useMemo(() => ({ ...options, readOnly }), [readOnly]); const { height, handleMount: handleAutoMount } = useAutoHeight({ - initialContentHeight: code.split('\n').length * options.lineHeight, + initialContentHeight: code.split('\n').length * editorOptions.lineHeight, isFocused, }); @@ -85,7 +88,7 @@ function InteractiveEditor({ defaultLanguage={extensionToMonacoLanguage(language)} onChange={onChange} defaultValue={code} - options={options} + options={editorOptions} theme={MONACO_THEME} onMount={handleMount} height={height} diff --git a/website/src/components/Playground/resources/PlaceholderBaseResource.ts b/website/src/components/Playground/resources/PlaceholderBaseResource.ts new file mode 100644 index 000000000000..a9a048127417 --- /dev/null +++ b/website/src/components/Playground/resources/PlaceholderBaseResource.ts @@ -0,0 +1,51 @@ +import { Entity, resource, RestEndpoint, Schema } from '@data-client/rest'; + +export abstract class PlaceholderEntity extends Entity { + id = 0; +} + +/** Common patterns in the https://jsonplaceholder.typicode.com API */ +export function createPlaceholderResource({ + path, + schema, + Endpoint = RestEndpoint, +}: { + readonly path: U; + readonly schema: S; + readonly Endpoint?: typeof RestEndpoint; +}) { + const base = resource({ path, schema, Endpoint }); + const partialUpdate = base.partialUpdate.extend({ + fetch: async function ( + this: ThisParameterType, + ...args: Parameters + ) { + // body only contains what we're changing, but we can find the id in params + return { + ...(await base.partialUpdate.call(this, ...args)), + id: (args[0] as unknown as { id: string | number }).id, + }; + } as typeof base.partialUpdate, + }); + return { + ...base, + // Endpoint overrides are to compensate for the jsonplaceholder API not returning + // the correct ID in certain cases + // + // This is sometimes needed when you don't control the server API itself + // More here: https://dataclient.io/docs/guides/network-transform#case-of-the-missing-id + partialUpdate, + create: base.create.extend({ + fetch: async function ( + this: ThisParameterType, + ...args: Parameters + ) { + // body only contains what we're changing, but we can find the id in params + return { + ...(await base.create.call(this, ...args)), + id: (args[args.length - 1] as unknown as { id: string | number }).id, + }; + } as typeof base.create, + }), + } as unknown as typeof base; +} diff --git a/website/src/components/Playground/resources/TodoResource.ts b/website/src/components/Playground/resources/TodoResource.ts new file mode 100644 index 000000000000..c7559ad7746c --- /dev/null +++ b/website/src/components/Playground/resources/TodoResource.ts @@ -0,0 +1,73 @@ +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +import { + createPlaceholderResource, + PlaceholderEntity, +} from './PlaceholderBaseResource'; + +export class Todo extends PlaceholderEntity { + readonly userId: number = 0; + readonly title: string = ''; + readonly completed: boolean = false; + readonly updatedAt: number = 0; + + static key = 'Todo'; + + static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: { updatedAt: number }, + incoming: { updatedAt: number }, + ) { + return ( + existing.updatedAt === undefined || + existing.updatedAt <= incoming.updatedAt + ); + } +} + +export class TodoEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getRequestInit(body?: any) { + if (body) { + body = { ...body, updatedAt: Date.now() }; + } + return super.getRequestInit.call(this, body); + } +} + +const TodoResourceBase = createPlaceholderResource({ + path: 'https\\://jsonplaceholder.typicode.com/todos/:id', + schema: Todo, + Endpoint: TodoEndpoint, +}); +export const TodoResource = { + ...TodoResourceBase, + partialUpdate: TodoResourceBase.partialUpdate.extend({ + getOptimisticResponse(snap, params, body) { + return { + id: params.id, + ...snap.get(Todo, { id: params.id }), + ...body, + updatedAt: snap.fetchedAt, + }; + }, + }), + create: TodoResourceBase.create.extend({ + getOptimisticResponse(snap, body) { + return { ...body, updatedAt: snap.fetchedAt }; + }, + update: newResourceId => ({ + [TodoResourceBase.getList.key()]: (resourceIds: string[] = []) => [ + ...resourceIds, + newResourceId, + ], + }), + }), + delete: TodoResourceBase.delete.extend({ + getOptimisticResponse(snap, params) { + return { ...params, updatedAt: snap.fetchedAt }; + }, + }), +}; From 7bf8c88e0fdd19e18445d6a8a999d7fcd88fb9e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:59:52 +0000 Subject: [PATCH 7/9] internal(website): Correct useModelId comment and DiffEditor README chain Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015H36Yk4WBpytw9L7RRVCTK --- website/src/components/Playground/README.md | 2 +- website/src/components/Playground/monaco/modelPath.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index e730f2c790aa..3dc21eaaa338 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -11,7 +11,7 @@ indexable and hydration-safe, and those behaviors are easy to drop silently. | `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` → `../DiffEditorMonaco.tsx`) | MDX | Read-only Monaco diff of two fences | +| `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 diff --git a/website/src/components/Playground/monaco/modelPath.ts b/website/src/components/Playground/monaco/modelPath.ts index c349573561c8..15e53fde23b5 100644 --- a/website/src/components/Playground/monaco/modelPath.ts +++ b/website/src/components/Playground/monaco/modelPath.ts @@ -17,7 +17,7 @@ export function stripModelId(path: string) { return path.substring(candidateId.length - 1); } -/** Stable per-mount id. Only used client-side (inside BrowserOnly), so randomness cannot cause hydration mismatches. */ +/** 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(), From f5d35620903a7e70acba93d49e425398fc04843b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:40:36 +0000 Subject: [PATCH 8/9] internal(website): Scope import completions per playground, accept bare path= metastrings Import completions for relative paths now list only files from the same playground instead of every Monaco model on the page. Fence metastrings accept unquoted path=a.ts (used in docs/rest/api/RestEndpoint.md). Also prettier-formats the Playground README. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015H36Yk4WBpytw9L7RRVCTK --- website/src/components/Playground/README.md | 18 +++++----- .../editor/__tests__/codeModel.test.tsx | 9 +++++ .../components/Playground/editor/codeModel.ts | 6 +++- .../monaco/__tests__/modelPath.test.ts | 19 ++++++++++- .../components/Playground/monaco/modelPath.ts | 20 +++++++++++ .../Playground/monaco/navigation.ts | 34 ++++++++++--------- 6 files changed, 80 insertions(+), 26 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 3dc21eaaa338..babc9166761f 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -6,13 +6,13 @@ 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 | +| 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 @@ -92,7 +92,7 @@ DesignSystem/ components injected into preview scope ### Editor -- Fence metastring: `title="…"`, `path="…"`, `collapsed`, `column`, `{1-3}` +- Fence metastring: `title="…"`, `path="…"` (or bare `path=a.ts`), `collapsed`, `column`, `{1-3}` highlight ranges (pre-selected in Monaco); `language-*` class. Element props override metastring values (Demo passes them directly). `defaultTab` overrides `collapsed`. @@ -106,6 +106,8 @@ DesignSystem/ components injected into preview scope - 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. diff --git a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx index aaa9c33c3fea..8d40fd2b6e9f 100644 --- a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx +++ b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx @@ -50,6 +50,15 @@ describe('code document model', () => { ]); }); + test('accepts an unquoted path', () => { + expect( + parseCodeDocuments([ + code({ metastring: 'path=Todo.ts' }, 'a'), + code({ metastring: "title='T' path='src/b.ts' {1}" }, 'b'), + ]).map(({ path }) => path), + ).toEqual(['Todo.ts', 'src/b.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 e076a2b2cde2..e300f6026a81 100644 --- a/website/src/components/Playground/editor/codeModel.ts +++ b/website/src/components/Playground/editor/codeModel.ts @@ -58,7 +58,11 @@ export function parseCodeDocuments( defaultTab ? title !== defaultTab : /\bcollapsed\b/.test(metastring), col: /\bcolumn\b/.test(metastring), path: - metastring.match(/path=(?["'])(?.*?)\1/)?.groups?.path || + // quoted (path="a.ts") or bare (path=a.ts) + /path=(?:(["'])(.*?)\1|([^\s"']+))/ + .exec(metastring) + ?.slice(2) + .find(Boolean) || (fileBase.includes('.') ? fileBase : `${fileBase}.${extension}`), highlights: /\{([\d\-,.]+)\}/.exec(metastring)?.[1], language, diff --git a/website/src/components/Playground/monaco/__tests__/modelPath.test.ts b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts index 8415e7d2ab98..03d4c3d69774 100644 --- a/website/src/components/Playground/monaco/__tests__/modelPath.test.ts +++ b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts @@ -2,7 +2,12 @@ import { renderHook } from '@testing-library/react'; -import { modelPath, stripModelId, useModelId } from '../modelPath'; +import { + modelPath, + siblingFilePaths, + stripModelId, + useModelId, +} from '../modelPath'; describe('model paths', () => { test('round-trips a namespaced file path', () => { @@ -16,6 +21,18 @@ describe('model paths', () => { ); }); + 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']); + }); + test('useModelId is numeric and stable across renders', () => { const { result, rerender } = renderHook(() => useModelId()); const first = result.current; diff --git a/website/src/components/Playground/monaco/modelPath.ts b/website/src/components/Playground/monaco/modelPath.ts index 15e53fde23b5..a636509c9fa2 100644 --- a/website/src/components/Playground/monaco/modelPath.ts +++ b/website/src/components/Playground/monaco/modelPath.ts @@ -6,6 +6,7 @@ import { useMemo } from 'react'; * completions (./navigation.ts) rely on this shape. */ const MODEL_ID_SEGMENT = /\/\d+\//; +const MODEL_ID_ROOT = /^\/\d+\//; export function modelPath(editorId: string, filePath: string) { return `/${editorId}/${filePath}`; @@ -17,6 +18,25 @@ export function stripModelId(path: string) { return path.substring(candidateId.length - 1); } +/** `/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); + 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( diff --git a/website/src/components/Playground/monaco/navigation.ts b/website/src/components/Playground/monaco/navigation.ts index 20ffdec78888..782059b3f516 100644 --- a/website/src/components/Playground/monaco/navigation.ts +++ b/website/src/components/Playground/monaco/navigation.ts @@ -1,6 +1,6 @@ import type * as Monaco from 'monaco-editor'; -import { stripModelId } from './modelPath'; +import { siblingFilePaths } from './modelPath'; /** Packages offered when completing a bare `from '…'` specifier. */ const SUGGESTED_DEPENDENCIES = [ @@ -54,8 +54,8 @@ export function registerEditorOpener(monaco: typeof Monaco) { } /** - * Completes import specifiers: relative paths list the page's other models - * (playground tabs), bare specifiers list SUGGESTED_DEPENDENCIES. + * 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', { @@ -88,19 +88,21 @@ export function registerImportCompletions(monaco: typeof Monaco) { if (textUntilPosition.endsWith('.') || textUntilPosition.endsWith('/')) { return { - suggestions: monaco.editor - .getModels() - .map((editorModel: Monaco.editor.ITextModel) => - stripModelId(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, - })), + 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 From e22bcff9a804e02ddbe89a9f61646c4039348379 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:14:05 +0000 Subject: [PATCH 9/9] internal(website): Simplify Playground metastring, model path and type-lib helpers - One metaValue() parser for title= and path= (quotes optional for both) - One anchored model-id regex; stripModelId is a replace - Mark preview-scope global libs in MODULE_LIBS instead of modules[0] - language is required on InteractiveEditor (codeModel always sets it) - Drop unused return from Monaco setup Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015H36Yk4WBpytw9L7RRVCTK --- website/src/components/Playground/README.md | 2 +- .../Playground/editor/EditorSurface.tsx | 5 +---- .../Playground/editor/InteractiveEditor.tsx | 4 ++-- .../editor/__tests__/codeModel.test.tsx | 11 +++++++--- .../components/Playground/editor/codeModel.ts | 18 +++++++++-------- .../monaco/__tests__/modelPath.test.ts | 3 +++ .../components/Playground/monaco/modelPath.ts | 5 ++--- .../src/components/Playground/monaco/setup.ts | 1 - .../components/Playground/monaco/typeLibs.ts | 20 ++++++++++++++----- 9 files changed, 42 insertions(+), 27 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index babc9166761f..abc0ff64da35 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -92,7 +92,7 @@ DesignSystem/ components injected into preview scope ### Editor -- Fence metastring: `title="…"`, `path="…"` (or bare `path=a.ts`), `collapsed`, `column`, `{1-3}` +- 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`. diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index 92a690d89c75..4f1a9a46a8cf 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -134,10 +134,7 @@ function TextEditTab({ // Never branch on navigator / user agent outside BrowserOnly. const staticView = hidden ? null : ( - + ); return ( diff --git a/website/src/components/Playground/editor/InteractiveEditor.tsx b/website/src/components/Playground/editor/InteractiveEditor.tsx index df7efb2a6192..0402ed7e5359 100644 --- a/website/src/components/Playground/editor/InteractiveEditor.tsx +++ b/website/src/components/Playground/editor/InteractiveEditor.tsx @@ -28,7 +28,7 @@ export interface InteractiveEditorProps { autoFocus?: boolean; /** Whether this tab is visible; re-measures height when it becomes so */ isFocused?: boolean; - language?: string; + language: string; readOnly?: boolean; } @@ -45,7 +45,7 @@ function InteractiveEditor({ highlights, autoFocus = false, isFocused = false, - language = 'tsx', + language, readOnly = false, }: InteractiveEditorProps) { const editorOptions = useMemo(() => ({ ...options, readOnly }), [readOnly]); diff --git a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx index 548d6fec34ce..ac28293a1e6c 100644 --- a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx +++ b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx @@ -68,13 +68,18 @@ describe('code document model', () => { ]); }); - test('accepts an unquoted path', () => { + test('accepts unquoted title and path', () => { expect( parseCodeDocuments([ code({ metastring: 'path=Todo.ts' }, 'a'), code({ metastring: "title='T' path='src/b.ts' {1}" }, 'b'), - ]).map(({ path }) => path), - ).toEqual(['Todo.ts', 'src/b.ts']); + 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', () => { diff --git a/website/src/components/Playground/editor/codeModel.ts b/website/src/components/Playground/editor/codeModel.ts index 4493981e275e..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-(?<language>[\w-]+)/.exec(rest.className ?? '')?.groups ?.language ?? 'tsx'; @@ -61,11 +67,7 @@ export function parseCodeDocuments( : /\bcollapsed\b/.test(metastring), col: /\bcolumn\b/.test(metastring), path: - // quoted (path="a.ts") or bare (path=a.ts) - /path=(?:(["'])(.*?)\1|([^\s"']+))/ - .exec(metastring) - ?.slice(2) - .find(Boolean) || + metaValue(metastring, 'path') || (fileBase.includes('.') ? fileBase : `${fileBase}.${extension}`), highlights: /\{([\d\-,.]+)\}/.exec(metastring)?.[1], language, diff --git a/website/src/components/Playground/monaco/__tests__/modelPath.test.ts b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts index 03d4c3d69774..372a63565b31 100644 --- a/website/src/components/Playground/monaco/__tests__/modelPath.test.ts +++ b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts @@ -31,6 +31,9 @@ describe('model paths', () => { '/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', () => { diff --git a/website/src/components/Playground/monaco/modelPath.ts b/website/src/components/Playground/monaco/modelPath.ts index a636509c9fa2..44a8a80752ec 100644 --- a/website/src/components/Playground/monaco/modelPath.ts +++ b/website/src/components/Playground/monaco/modelPath.ts @@ -5,7 +5,6 @@ import { useMemo } from 'react'; * files as `/<numeric id>/<file path>`. Go-to-definition and import * completions (./navigation.ts) rely on this shape. */ -const MODEL_ID_SEGMENT = /\/\d+\//; const MODEL_ID_ROOT = /^\/\d+\//; export function modelPath(editorId: string, filePath: string) { @@ -14,8 +13,7 @@ export function modelPath(editorId: string, filePath: string) { /** `/123/src/api.ts` → `/src/api.ts` (unchanged when there is no id segment) */ export function stripModelId(path: string) { - const candidateId = MODEL_ID_SEGMENT.exec(path)?.[0] ?? ''; - return path.substring(candidateId.length - 1); + return path.replace(MODEL_ID_ROOT, '/'); } /** `/123/src/api.ts` → `/123/` (undefined when there is no id segment) */ @@ -32,6 +30,7 @@ export function siblingFilePaths( modelPaths: readonly string[], ): string[] { const id = modelIdSegment(currentPath); + if (!id) return []; return modelPaths .filter(path => path !== currentPath && modelIdSegment(path) === id) .map(stripModelId); diff --git a/website/src/components/Playground/monaco/setup.ts b/website/src/components/Playground/monaco/setup.ts index 11ca8ba50f37..f63d68c3d422 100644 --- a/website/src/components/Playground/monaco/setup.ts +++ b/website/src/components/Playground/monaco/setup.ts @@ -51,6 +51,5 @@ if (typeof window !== 'undefined' && !isMobileOrBot()) { addTypeLibs(monaco, await typeLibsPromise); monaco.typescript.typescriptDefaults.setEagerModelSync(true); - return monaco; }); } diff --git a/website/src/components/Playground/monaco/typeLibs.ts b/website/src/components/Playground/monaco/typeLibs.ts index eb9f9f0fa7be..e70ba8a9afff 100644 --- a/website/src/components/Playground/monaco/typeLibs.ts +++ b/website/src/components/Playground/monaco/typeLibs.ts @@ -8,11 +8,15 @@ import type * as Monaco from 'monaco-editor'; type RawModule = Promise<{ default: string }>; -/** `declare module "<name>"` libs, mounted at file:///node_modules/<dir>/index.d.ts */ +/** + * `declare module "<name>"` libs, mounted at file:///node_modules/<dir>/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', @@ -21,6 +25,7 @@ const MODULE_LIBS: readonly [ import( /* webpackChunkName: 'reactDTS' */ '!!raw-loader?esModule=false!../editor-types/react.d.ts' ), + true, ], [ 'bignumber.js', @@ -152,7 +157,10 @@ export function fetchTypeLibs(): Promise<TypeLibs> { return Promise.all([ settled(MODULE_LIBS.map(([, , load]) => load())), - settled([loadGlobals()]), + loadGlobals().then( + ({ default: lib }) => lib, + () => '', + ), settled( DATA_CLIENT_ENTRIES.map( entry => @@ -161,7 +169,7 @@ export function fetchTypeLibs(): Promise<TypeLibs> { ), ), ), - ]).then(([modules, [globals], dataClient]) => ({ + ]).then(([modules, globals, dataClient]) => ({ modules, dataClient, globals, @@ -173,7 +181,6 @@ export function addTypeLibs( { modules, dataClient, globals }: TypeLibs, ) { const { typescriptDefaults } = monaco.typescript; - const react = modules[0]; typescriptDefaults.addExtraLib( `declare module "react/jsx-runtime" { @@ -203,7 +210,10 @@ export function addTypeLibs( }); // React, NumberFlow and Temporal are also globals in the preview scope - typescriptDefaults.addExtraLib(`declare globals { ${react} }`); + 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'; }`, );