diff --git a/claude-notes/plans/2026-08-25-preview-spa-render-components.md b/claude-notes/plans/2026-08-25-preview-spa-render-components.md new file mode 100644 index 000000000..661de31c8 --- /dev/null +++ b/claude-notes/plans/2026-08-25-preview-spa-render-components.md @@ -0,0 +1,325 @@ +# `q2 preview` support for `render-components:` (GH #402 / bd-ue80chl0) + +**Status:** APPROVED (2026-08-25) — executing on branch +`braid/bd-ue80chl0-preview-spa-render-components`. + +### Settled decisions (review round, 2026-08-25) + +- **Q1 (re-transpile cadence):** do NOT re-transpile on every + `contentTick`. Re-transpile only when (a) the resolved + `render-components` path list changes (as seen in the rendered AST + meta — a stable `componentPathsKey` string, so `.qmd` keystrokes that + don't touch the list are free), or (b) any `.tsx` file was touched + (a dedicated `tsxTick` bumped from `onFileContent` for `.tsx` paths — + no content hashing needed). Rationale: per-keystroke `.qmd` edits must + not accumulate babel runs; `.tsx` touches and list edits legitimately + invalidate the compiled components. +- **Q2 (single-file mode):** in scope for this series (Phase 3). +- **Q3 (warning lane):** merge component warnings into `render.warnings` + — "compiling a component" is part of "rendering". +- **Q4 (hub-client cadence):** leave hub-client's paths-only re-transpile + as is. +- **D1 confirmed:** reuse hub-client's `@babel/standalone` transpiler via + a shared module; the SPA imports that module dynamically (lazy chunk). + This is parity with hub-client, not new infrastructure — the dynamic + `import()` only changes when the browser fetches/parses the chunk. + +## Overview + +A document with `render-components:` in its front matter loads user TSX +component overrides in hub-client, but `q2 preview` silently drops them — +the built-in components render instead, with no warning. GH #402 offers two +resolutions; the decision is to **implement the feature in the CLI preview +(resolution #1)**, accepting the SPA bundle cost. + +The custom-components pipeline is a two-process design: + +- **Iframe half** (shared): `ts-packages/preview-renderer/src/q2-preview/entry.tsx` + handles `LOAD_CUSTOM_COMPONENTS`, imports compiled JS as blob ESM modules, + and layers exports over the built-in registry via `buildCustomRegistry`. + Fully format-agnostic and already shipped in both surfaces. +- **Parent half** (hub-client only today): + `hub-client/src/components/render/ReactRenderer.tsx` walks + `ast.meta['render-components']`, resolves each entry with + `resolveComponentPath` (shared, `preview-renderer/utils/componentPath.ts`), + transpiles with `@babel/standalone` + (`hub-client/src/services/tsxTranspiler.ts`), and passes + `customComponentsCode` to `Q2PreviewIframe`, which posts + `LOAD_CUSTOM_COMPONENTS`. + +The gap: `q2-preview-spa/src/PreviewApp.tsx` never builds +`customComponentsCode` — `Q2PreviewIframe` already accepts the prop; the SPA +just doesn't pass it. + +### Facts established by source study (2026-08-25) + +- `.tsx` files **already sync** into the preview session: hub discovery + collects them as text files (`crates/quarto-hub/src/discovery.rs:142`), + the `WatchFilter::PreviewBroad` watcher accepts `.tsx` edits + (`crates/quarto-hub/src/watch.rs`), and the SPA can read their content + synchronously via `getFileContent(path)` from `@quarto/preview-runtime`. + No new server endpoint is needed to fetch TSX sources in project mode. +- `shouldRerenderForTextChange` in `PreviewApp.tsx` already passes `.tsx` + edits through (non-`.qmd`/`.md` always bump `contentTick`), so the + re-render trigger plumbing exists. +- **Single-file mode is a real gap**: `config::resolve_single_file_deps` + (`crates/quarto-preview/src/config.rs`) resolves the include closure + + images but does *not* read `render-components`, so + `q2 preview deck.qmd` would not sync the TSX files at all. It already has + the parsed `DocumentAst` in hand, so the meta is available for free. +- `@babel/standalone` (the transpiler hub-client uses): 3.0 MB minified, + 0.6 MB gzipped. The SPA `dist/` (embedded into the `q2` binary via + `include_dir!`, with precompressed `.gz` siblings) is ~45 MB today, of + which the WASM is 26 MB. A lazy-loaded babel chunk adds ≈3.7 MB to the + binary embed (raw + gz sibling), ~8% growth. +- Precedent for heavy optional deps: the built-in `MermaidCodeBlock` + dynamic-imports mermaid **from the CDN at runtime** (nothing bundled; + diagram-free docs pay nothing; offline preview loses diagrams). +- A smoke fixture already exists with real assertions: + `crates/quarto/tests/smoke-all/q2-preview/with-render-components/` + (`requires_js: true`, so the CLI smoke runner skips it; it runs under + hub-client's playwright smoke-all harness, i.e. it currently exercises + only the hub-client parent). +- q2-preview-spa has a real-binary playwright e2e harness + (`q2-preview-spa/e2e/`, spawns `target/debug/q2 preview`) — the right + place for the end-to-end proof. (Note: `test:e2e` tiers are outside the + CI merge gate today — bd-lkercidb.) + +## Design decisions + +### D1 — Where transpilation happens + +| Option | Cost | Notes | +| --- | --- | --- | +| **A. `@babel/standalone` in the SPA, lazy-loaded (recommended)** | +≈3.7 MB binary embed; zero runtime cost for docs without `render-components` | Exact transpiler parity with hub-client (same hoisted package/version). Works offline. Laziness via dynamic `import()` of the transpiler module → Vite emits a separate chunk fetched only when the meta key is present. | +| B. Babel from CDN at runtime (mermaid precedent) | zero binary cost | Breaks offline preview for a *core-path* feature (mermaid degrades a diagram; this would drop the whole feature), and adds a supply-chain surface. Rejected. | +| C. Server-side transpile in Rust (swc/oxc) via a new `/api/preview/component` endpoint | zero SPA cost; heavy new Rust dep, slower builds | Two transpilers for one semantic contract (hub-client keeps babel) — output divergence between surfaces is exactly the class of bug #402 is about. Rejected. | +| D. Server-side via `deno` (already used for TS extension engines) | zero bundle cost | `deno` is optional-on-PATH; the feature would silently vanish without it — recreating the silent-divergence problem. Rejected. | + +**Recommendation: A.** The user has explicitly accepted the size cost, and A +is the only option that keeps *one* transpiler implementation across both +surfaces and works offline. + +### D2 — Code sharing between the two parents + +Today the parent-half logic (meta walk + transpile) lives only in +hub-client. To prevent drift, extract it into the shared package: + +1. **`@quarto/preview-renderer/utils/renderComponents.ts`** (new): + `extractRenderComponentPaths(ast): string[]` — the + MetaList → MetaInlines → Str walk, including hub-client's mid-typing + guards (null bullet, empty MetaInlines). Unit-tested in + preview-renderer. Hub-client's inline walk in `ReactRenderer.tsx` + is replaced by a call to it. +2. **`@quarto/preview-renderer/utils/tsxTranspiler.ts`** (moved from + `hub-client/src/services/tsxTranspiler.ts`, verbatim; adds + `@babel/standalone` to preview-renderer's deps): sync `transpileTSX`. + - Hub-client keeps its **static** import (unchanged sync `useMemo`). + - The SPA imports the *module itself* dynamically + (`await import('@quarto/preview-renderer/utils/tsxTranspiler')`), so + babel lands in a lazy chunk of the SPA build without any changes to + the module. Laziness comes from how the importer imports, not from + the module. + - Care: nothing in the iframe entry graph may import this module, or + the iframe bundle grows. (Only parents import it — same rule as + today.) + +### D3 — SPA wiring and reactivity + +New module `q2-preview-spa/src/customComponents.ts`: + +```ts +buildCustomComponentsCode( + astJson: string, + currentFilePath: string, + getContent: (path: string) => string | null, // getFileContent +): Promise> // {} when no render-components +``` + +- Parses meta via `extractRenderComponentPaths`; returns `{}` (and never + loads babel) when the list is empty — the common path stays free. +- Resolves entries with the shared `resolveComponentPath` (leading `/` = + project root; otherwise relative to the declaring document — matches + the path-resolution contract in + `claude-notes/designs/path-resolution-model.md`; note this feature + resolves *document-relative*, like hub-client, since the key is + front-matter-only). +- Missing file / transpile failure: `console.warn`/`console.error` parity + with hub-client, plus surfaced in the diagnostics overlay (D5). + +`PreviewApp.tsx` wiring: + +- New state `customComponentsCode: Record` (default `{}`). +- An effect keyed on `[state.astJson, state.activeFile, state.contentTick]` + calls `buildCustomComponentsCode` (async, cancellation-guarded like the + sibling effects) and stores the result. Keying on `contentTick` means a + `.tsx` edit re-transpiles — slightly *better* than hub-client, which + deliberately re-transpiles only when the path list changes. Cheap because + the recompute is skipped entirely when the doc has no `render-components`. +- Pass `customComponentsCode` to `` (prop already exists; + pass `undefined` when empty so the iframe post is skipped — preserves + today's behavior for component-free docs). + +**Iframe re-render after component (re)load** — shared fix in +`entry.tsx`: today `loadCustomComponents` rebuilds `customRegistry` but the +new registry only takes effect on the *next* `UPDATE_AST`. Cache the last +`UpdateAstPayload` at module level and re-run `updateAst(lastPayload)` after +a `LOAD_CUSTOM_COMPONENTS` completes (when a payload exists). This makes +live `.tsx` editing actually repaint in the SPA, and fixes the same latent +ordering gap for hub-client. Ordering safety: the existing +`componentsLoading` gate already serializes LOAD vs UPDATE_AST. + +### D4 — Single-file mode (`q2 preview deck.qmd`) + +Extend `resolve_single_file_deps` in `crates/quarto-preview/src/config.rs`: +after include expansion it already holds the parsed `DocumentAst`; read the +`render-components` meta list, resolve entries against the deck's directory +(leading `/` → the synthetic project root = deck dir), and append existing, +in-tree `.tsx` files to the *text* deps (`single_file_text_deps`), which +also enrolls them in the watcher (`single_file_deps` plumbing already +exists). Out-of-tree or missing entries are dropped, same as includes. + +This can be **phase 3** (project mode ships without it), but it should land +in the same PR series — otherwise we re-create #402 one mode over. + +### D5 — Error visibility + +Silent divergence is the core complaint, so failures must be loud: + +- Transpile error / missing component file → entry in the SPA's existing + diagnostics overlay (warning lane), not just console. Simplest shape: the + `buildCustomComponentsCode` helper returns + `{ code, warnings: Diagnostic[] }` and PreviewApp merges the warnings + into `render.warnings` for `computeOverlayInputs` — no new overlay + surface needed. +- No warning for the happy path or for docs without the key. + +### D6 — Out of scope + +- `q2 render` ignoring `render-components` (native HTML render has no React + runtime; expected, not part of #402). +- hub-client behavior changes beyond the shared-code extraction and the + entry.tsx re-render fix (its sync `useMemo` transpile flow is untouched). +- `_extensions/**` watching (Q-B1 stands). +- CDN/offline story for mermaid — unrelated. + +## Test plan (TDD — tests first in each phase) + +1. **preview-renderer unit** (`utils/renderComponents.test.ts`, new): + `extractRenderComponentPaths` — happy path, mid-typing null bullet, + empty MetaInlines, absent key, non-list shapes. Port the transpiler's + existing implicit coverage: `tsxTranspiler.test.ts` with a trivial TSX + → asserts JS output contains `React.createElement` and preserves + `export`s (mocking-free; babel is a dev dep of the package tests). +2. **preview-renderer iframe test**: `LOAD_CUSTOM_COMPONENTS` after an + `UPDATE_AST` triggers a re-render with the new registry (the D3 entry.tsx + fix) — RED first against current entry.tsx. +3. **SPA integration** (`q2-preview-spa/src/customComponents.integration.test.tsx`, + vitest/jsdom, `@quarto/preview-runtime` mocked as in + `PreviewApp.integration.test.tsx`; transpiler mocked as + `code => 'JS:' + code` like hub-client's `ReactRenderer.integration.test.tsx`): + - doc with `render-components` → iframe receives the transpiled map + (assert on the posted `LOAD_CUSTOM_COMPONENTS` / captured prop); + - doc without the key → no post, transpiler module never imported; + - missing `.tsx` → warning surfaced in overlay inputs. +4. **Rust unit/integration** (`crates/quarto-preview/src/config.rs` tests): + single-file deck with `render-components: [overrides.tsx]` → the `.tsx` + appears in `single_file_text_deps`; missing file → dropped, no error. +5. **e2e** (`q2-preview-spa/e2e/render-components.spec.ts`, new): real + `q2 preview` on a project fixture (reuse + `crates/quarto/tests/smoke-all/q2-preview/with-render-components/`): + assert `p.my-para` and `div.my-callout` visible, `div.callout` absent — + the same assertions the fixture already declares for the hub-client + smoke harness. A second test for single-file mode once D4 lands. +6. **hub-client regression**: existing `ReactRenderer.integration.test.tsx` + and `e2e/q2-debug-render-components.spec.ts` stay green after the + shared-code extraction (imports move; behavior identical). + +## Work items + +### Phase 1 — shared extraction (no behavior change) + +- [x] Add `@babel/standalone` dep to `ts-packages/preview-renderer`; + move `tsxTranspiler.ts` there; hub-client re-imports (delete its copy). + (commit `1809256e6`) +- [x] New `utils/renderComponents.ts` + unit tests; refactor hub-client's + `componentPathsKey` memo to use it. (commit `1809256e6`) +- [ ] Verify: hub-client `npm run build:all` + `test:ci` (deferred to the + Phase 4 full verification); SPA-side chunk check done in Phase 2: + iframe chunk did NOT grow (see measurement below). + +### Phase 2 — SPA parent half (project mode) + +- [x] Iframe re-render-after-load fix in `entry.tsx` + test (RED→GREEN): + cached `lastAstPayload`, repaint after `LOAD_CUSTOM_COMPONENTS`; + boot-order LOAD (no prior AST) does not render. +- [x] `q2-preview-spa/src/customComponents.ts` + unit tests (RED→GREEN): + `extractComponentPathsKey` (stable effect key) + + `buildCustomComponentsCode` (lazy transpiler import, warnings for + missing file / transpile error). +- [x] Wire into `PreviewApp.tsx` (RED→GREEN, 5 integration tests): + `tsxTick` (only `.tsx` touches re-transpile — Q1), stable + `EMPTY_CUSTOM_COMPONENTS` identity, warnings merged into + `render.warnings` (Q3), `customComponentsCode` prop. +- [x] e2e spec on the existing fixture; full rebuild chain + (`npm run build:wasm` → `cargo xtask build-q2-preview-spa` → + `cargo build --bin q2`) and run it. **End-to-end record + (2026-08-25):** `npx playwright test render-components.spec.ts` + drives the freshly-built `target/debug/q2 preview` against the + `with-render-components` fixture in real Chromium; observed + `p.my-para` and `div.my-callout` present, built-in `div.callout` + absent, and a disk edit of `overrides.tsx` (class renamed to + `my-para-v2`) live-repainted the preview with the new class and + zero `p.my-para` remnants. 2 passed. Existing + `basic-preview.spec.ts` (4 tests) still green. +- [x] Measure and record the actual dist growth. **Measured 2026-08-25:** + babel lazy chunk `tsxTranspiler-*.js` = 2.9 MB raw / 664 KB gz; + SPA `dist/` 45 MB → 49 MB; iframe chunk `q2-preview-*.js` + unchanged at 1148 KB (babel did not leak into the iframe graph); + `main-*.js` 68 KB → 72 KB (wiring + shared meta walk only). + +### Phase 3 — single-file mode + +- [x] Rust test for `resolve_single_file_deps` picking up + `render-components` TSX (RED first; also a drops-test for + missing / `../`-escaping / non-`.tsx` entries). +- [x] Implement meta read + text-dep append (GREEN; entries resolve + deck-dir-relative, leading `/` = synthetic project root; land in + the text-dep channel → synced as text + enrolled in the + closure-scoped watcher via `all_files()`). +- [x] Single-file e2e proof: new `[single-file]` test in + `render-components.spec.ts` (harness gained a `targetFile` + option) — overrides fire under `q2 preview index.qmd` AND a disk + `.tsx` edit live-repaints (watcher enrollment). 3/3 e2e green. + +### Phase 4 — wrap-up + +- [x] End-to-end verification per CLAUDE.md: recorded under Phase 2/3 + (playwright drives the real `target/debug/q2 preview` binary; DOM + inspected in Chromium; project + single-file modes; live `.tsx` + edits). +- [x] `cargo xtask verify` (full, WASM leg affected) — PASSED + 2026-08-25 (13,426 Rust tests; hub-client, preview-renderer, SPA + and all ts-package suites; hub-client `build:all` incl. WASM; SPA + build; exit 0). `cargo xtask lint` — clean. +- [x] Docs: checked — `render-components` is not documented anywhere + under `docs/` (still EXPERIMENTAL, per the transpiler header), so + there is no user-facing page to update. Skipped per plan. +- [ ] Update bd-ue80chl0 (comment with PR link; close on merge), + PR body carries `Fixes #402`. + +## Open questions for review + +1. **Q1 (D3 cadence):** OK with the SPA re-transpiling on every + `contentTick` bump for docs that carry `render-components` (i.e. any + watched-file change, not only `.tsx` edits)? Alternative: key the + effect on a hash of the resolved TSX contents to skip no-op recomputes. + Babel on a few small files is fast; simplicity favored. +2. **Q2 (D4 scope):** confirm single-file mode is in scope for this series + (recommended), vs. filing a follow-up strand. +3. **Q3 (D5 shape):** merging component warnings into `render.warnings` + reuses the overlay verbatim but slightly blurs "render warning" vs + "component warning". Acceptable, or do we want a distinct lane? +4. **Q4:** should hub-client also adopt content-driven re-transpile (its + comment says paths-only was deliberate)? Default: leave hub-client + as-is; file a follow-up if we want parity in the other direction. diff --git a/crates/quarto-preview/src/config.rs b/crates/quarto-preview/src/config.rs index c3cedfd17..db2910a5e 100644 --- a/crates/quarto-preview/src/config.rs +++ b/crates/quarto-preview/src/config.rs @@ -225,8 +225,11 @@ pub fn resolve_project_resource_files( /// *inside* them. #[derive(Debug, Default, PartialEq, Eq)] pub struct SingleFileDeps { - /// Included `.qmd` files (project-root-relative). Synced as **text**, kept - /// out of `qmd_files` so they are invisible VFS-only dependencies. + /// Text dependencies (project-root-relative), synced as **text** without + /// surfacing in the render list: included `.qmd` files, plus declared + /// `render-components:` `.tsx` overrides (GH #402 / bd-ue80chl0). The + /// field name predates the `.tsx` addition; both ride the same + /// `single_file_text_deps` channel into discovery's `text_dep_files`. pub qmd_files: Vec, /// Referenced image assets (project-root-relative). Synced as **binary**. pub binary_files: Vec, @@ -407,6 +410,51 @@ pub fn resolve_single_file_deps( } } + // `render-components:` TSX overrides (GH #402 / bd-ue80chl0 Phase 3). + // Project-mode preview syncs `.tsx` via hub discovery's dir walk; + // single-file mode has no walk, so the declared entries must join the + // closure here or the SPA's parent half has nothing to read — exactly + // the silent hub-client/CLI divergence GH #402 is about. Entries + // resolve like the TS side's `resolveComponentPath`: relative to the + // declaring document's directory; a leading `/` means the (synthetic, + // deck-dir) project root, never the filesystem root — see + // `claude-notes/designs/path-resolution-model.md`. Missing, escaping, + // and non-`.tsx` entries are dropped fail-soft: the SPA surfaces a + // missing component as a render warning; the closure just declines to + // sync it. They land in `qmd_files` (the text-dep channel) so they + // sync as text AND enroll in the watcher (`all_files()`), matching + // project mode's live `.tsx` editing. + if let Some(arr) = doc + .ast + .meta + .get("render-components") + .and_then(|v| v.as_array()) + { + for entry in arr { + let Some(raw) = entry.as_plain_text() else { + continue; + }; + let is_tsx = Path::new(&raw) + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("tsx")); + if !is_tsx { + continue; + } + let joined = match raw.strip_prefix('/') { + Some(rest) => project_root.join(rest), + None => project_root.join(deck_dir).join(&raw), + }; + let Ok(canon) = joined.canonicalize() else { + continue; + }; + if let Some(rel) = to_in_tree_rel(canon) + && seen_qmd.insert(rel.clone()) + { + qmd_files.push(rel); + } + } + } + qmd_files.sort(); binary_files.sort(); SingleFileDeps { @@ -568,6 +616,69 @@ mod tests { ); } + /// `render-components:` TSX entries are part of the single-file closure + /// (GH #402 / bd-ue80chl0 Phase 3): they sync as **text** deps so the + /// SPA's parent half can read + transpile them, and they enroll in the + /// watcher. Relative entries anchor at the deck dir; a leading `/` means + /// the (synthetic, deck-dir) project root — never the filesystem root + /// (path-resolution contract, `path-resolution-model.md`). + #[test] + fn single_file_deps_render_components_tsx() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + std::fs::create_dir(root.join("components")).unwrap(); + std::fs::write(root.join("overrides.tsx"), "export const Para = 1;\n").unwrap(); + std::fs::write( + root.join("components/extra.tsx"), + "export const Callout = 1;\n", + ) + .unwrap(); + std::fs::write( + root.join("main.qmd"), + "---\ntitle: T\nrender-components:\n - overrides.tsx\n - /components/extra.tsx\n---\n\n# Hi\n", + ) + .unwrap(); + + let deps = resolve_single_file_deps(root, std::path::Path::new("main.qmd"), native_arc()); + assert_eq!( + deps.qmd_files, + vec![ + std::path::PathBuf::from("components/extra.tsx"), + std::path::PathBuf::from("overrides.tsx"), + ], + "render-components TSX must sync as text deps (sorted); got {:?}", + deps.qmd_files, + ); + } + + /// Missing, escaping (`../`), and non-`.tsx` `render-components` entries + /// are dropped without error — same fail-soft posture as includes/images. + /// The parent half reports the missing file as a render warning; the + /// closure just declines to sync it. + #[test] + fn single_file_deps_render_components_drops_missing_escaping_and_non_tsx() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + // A real file OUTSIDE the deck dir that must NOT be pulled in. + std::fs::write(root.join("secret.tsx"), "export const X = 1;\n").unwrap(); + let deck_dir = root.join("deck"); + std::fs::create_dir(&deck_dir).unwrap(); + std::fs::write(deck_dir.join("notes.txt"), "not a component\n").unwrap(); + std::fs::write( + deck_dir.join("main.qmd"), + "---\ntitle: T\nrender-components:\n - missing.tsx\n - ../secret.tsx\n - notes.txt\n---\n\n# Hi\n", + ) + .unwrap(); + + let deps = + resolve_single_file_deps(&deck_dir, std::path::Path::new("main.qmd"), native_arc()); + assert!( + deps.qmd_files.is_empty(), + "missing/escaping/non-tsx entries must all be dropped; got {:?}", + deps.qmd_files, + ); + } + /// A self-referential include terminates (the stage's own cycle detection) /// and records the included file exactly once. #[test] diff --git a/crates/wasm-quarto-hub-client/Cargo.lock b/crates/wasm-quarto-hub-client/Cargo.lock index 6cb8a3947..c9bc21405 100644 --- a/crates/wasm-quarto-hub-client/Cargo.lock +++ b/crates/wasm-quarto-hub-client/Cargo.lock @@ -2302,6 +2302,7 @@ dependencies = [ "quarto-config", "quarto-pandoc-types", "quarto-source-map", + "quarto-util", "yaml-rust2", ] diff --git a/hub-client/package.json b/hub-client/package.json index 39c19afc4..9ca341568 100644 --- a/hub-client/package.json +++ b/hub-client/package.json @@ -42,7 +42,6 @@ "@automerge/automerge-repo-network-websocket": "^2.5.6", "@automerge/automerge-repo-react-hooks": "2.5.6", "@automerge/automerge-repo-storage-indexeddb": "2.5.6", - "@babel/standalone": "^7.29.2", "@monaco-editor/react": "^4.7.0", "@quarto/quarto-automerge-schema": "*", "@quarto/quarto-sync-client": "*", diff --git a/hub-client/src/components/render/ReactRenderer.integration.test.tsx b/hub-client/src/components/render/ReactRenderer.integration.test.tsx index 448ebde9f..3c2bf1560 100644 --- a/hub-client/src/components/render/ReactRenderer.integration.test.tsx +++ b/hub-client/src/components/render/ReactRenderer.integration.test.tsx @@ -55,7 +55,9 @@ vi.mock('@quarto/preview-renderer/iframe/Q2PreviewIframe', () => ({ }, })); -vi.mock('../../services/tsxTranspiler', () => ({ +// tsxTranspiler moved to @quarto/preview-renderer/utils/ in GH #402 +// Phase 1 (shared with the q2-preview SPA). Mock the new location. +vi.mock('@quarto/preview-renderer/utils/tsxTranspiler', () => ({ transpileTSX: (code: string) => `JS:${code}`, })); diff --git a/hub-client/src/components/render/ReactRenderer.tsx b/hub-client/src/components/render/ReactRenderer.tsx index cca2c4a40..9732acf05 100644 --- a/hub-client/src/components/render/ReactRenderer.tsx +++ b/hub-client/src/components/render/ReactRenderer.tsx @@ -5,8 +5,9 @@ import { Q2DebugIframe } from './q2-debug/Q2DebugIframe'; import { Q2PreviewIframe, type Q2PreviewIframeHandle } from '@quarto/preview-renderer/iframe/Q2PreviewIframe'; import { Q2SandboxedPreviewIframe } from './q2-sandboxed-preview/Q2SandboxedPreviewIframe'; import { SlideAst } from './ReactAstSlideRenderer'; -import { transpileTSX } from '../../services/tsxTranspiler'; +import { transpileTSX } from '@quarto/preview-renderer/utils/tsxTranspiler'; import { resolveComponentPath } from '@quarto/preview-renderer/utils/componentPath'; +import { extractRenderComponentPaths } from '@quarto/preview-renderer/utils/renderComponents'; import type { PandocAST } from '@quarto/preview-renderer/framework'; // Simple error boundary to catch errors in custom components @@ -206,21 +207,11 @@ function ReactRenderer({ return ''; } - const ast = JSON.parse(astJson); - // Walk the MetaList → MetaInlines → Str(c) chain. Entries that - // don't resolve to a non-empty string are dropped: this includes - // (a) `render-components:\n -` mid-typing, where the bullet has - // no value and parses to `null`, and (b) an empty MetaInlines - // (the user typed the path-string-open delimiter but no content - // yet). Without this filter, `resolveComponentPath(undefined …)` - // throws inside this useMemo and the iframe-host page goes blank - // with no upstream ErrorBoundary to catch it. - const rawPaths: unknown[] = - ast?.meta?.['render-components']?.c?.map?.((o: any) => o?.c?.[0]?.c) ?? - []; - const componentPaths = rawPaths.filter( - (p): p is string => typeof p === 'string' && p.length > 0, - ); + // Shared MetaList → MetaInlines → Str(c) walk (GH #402 Phase 1). + // Drops mid-typing entries (null bullet, empty MetaInlines) so + // `resolveComponentPath(undefined …)` can never throw inside this + // useMemo and blank the iframe-host page. + const componentPaths = extractRenderComponentPaths(JSON.parse(astJson)); return JSON.stringify(componentPaths); }, [format, astJson]); diff --git a/package-lock.json b/package-lock.json index 2289dd68a..4ec72a6f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,6 @@ "@automerge/automerge-repo-network-websocket": "^2.5.6", "@automerge/automerge-repo-react-hooks": "2.5.6", "@automerge/automerge-repo-storage-indexeddb": "2.5.6", - "@babel/standalone": "^7.29.2", "@monaco-editor/react": "^4.7.0", "@quarto/quarto-automerge-schema": "*", "@quarto/quarto-sync-client": "*", @@ -2059,7 +2058,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -2077,7 +2075,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2095,7 +2092,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2113,7 +2109,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2131,7 +2126,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2149,7 +2143,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2167,7 +2160,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2185,7 +2177,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2203,7 +2194,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2221,7 +2211,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2239,7 +2228,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2257,7 +2245,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2275,7 +2262,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2293,7 +2279,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2311,7 +2296,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2329,7 +2313,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2347,7 +2330,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2365,7 +2347,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2383,7 +2364,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2401,7 +2381,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2419,7 +2398,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2437,7 +2415,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -2455,7 +2432,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -2473,7 +2449,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2491,7 +2466,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2509,7 +2483,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -13409,6 +13382,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@babel/standalone": "^7.29.2", "@quarto/preview-runtime": "*", "@quarto/quarto-automerge-schema": "*", "@revealjs/react": "0.2.0", diff --git a/q2-preview-spa/e2e/helpers/previewServer.ts b/q2-preview-spa/e2e/helpers/previewServer.ts index 58cdbb823..6538184c1 100644 --- a/q2-preview-spa/e2e/helpers/previewServer.ts +++ b/q2-preview-spa/e2e/helpers/previewServer.ts @@ -178,6 +178,15 @@ export interface StartOptions { * Used by the nesting-cursor e2e (P3.5), which must open an editor. */ allowEdit?: boolean; + /** + * GH #402 / bd-ue80chl0: launch in SINGLE-FILE mode — the spawned + * command targets `/` (a file path) instead + * of the project directory, exercising the CLI's single-file branch + * (`resolve_single_file_deps` closure, non-recursive watcher). + * Relative to the temp project dir; the file must be among + * `fixtureFiles` / the copied dir. + */ + targetFile?: string; /** * Extra environment variables to inject into the spawned `q2 preview` * process, merged over `process.env` (and the default `RUST_LOG`). Used by @@ -253,7 +262,9 @@ export async function startPreviewServer(opts: StartOptions): Promise { + const innerDoc = document.querySelector('iframe')?.contentDocument; + return { + myPara: innerDoc?.querySelectorAll('p.my-para').length ?? 0, + myParaV2: innerDoc?.querySelectorAll('p.my-para-v2').length ?? 0, + myCallout: innerDoc?.querySelectorAll('div.my-callout').length ?? 0, + builtinCallout: innerDoc?.querySelectorAll('div.callout').length ?? 0, + }; + }); +} + +let server: PreviewServerHandle; + +test.afterEach(async () => { + await server?.stop(); +}); + +/** Wait until the given selector appears in the inner iframe document. */ +async function waitForInnerSelector(page: Page, selector: string) { + await page.waitForFunction( + (sel) => { + const innerDoc = document.querySelector('iframe')?.contentDocument; + return (innerDoc?.querySelectorAll(sel).length ?? 0) > 0; + }, + selector, + { timeout: 30_000 }, + ); +} + +test.beforeEach(async ({}, testInfo) => { + if (testInfo.title.includes('[single-file]')) { + // Single-file mode: `q2 preview index.qmd` — no `_quarto.yml`, the + // deck's directory becomes the synthetic project root, and the + // `.tsx` reaches the VFS only through `resolve_single_file_deps`'s + // render-components closure (Phase 3). + server = await startPreviewServer({ + fixtureFiles: [ + { path: 'index.qmd', content: qmdContent }, + { path: 'overrides.tsx', content: tsxContent }, + ], + targetFile: 'index.qmd', + }); + } else { + server = await startPreviewServer({ + fixtureFiles: [ + { path: '_quarto.yml', content: quartoYml }, + { path: 'index.qmd', content: qmdContent }, + { path: 'overrides.tsx', content: tsxContent }, + ], + }); + } +}); + +test('user render-components overrides shadow the built-ins', async ({ page }) => { + await page.goto(server.url); + + await page.waitForFunction( + () => { + const innerDoc = document.querySelector('iframe')?.contentDocument; + return ( + (innerDoc?.querySelectorAll('p.my-para').length ?? 0) > 0 && + (innerDoc?.querySelectorAll('div.my-callout').length ?? 0) > 0 + ); + }, + undefined, + { timeout: 30_000 }, + ); + + const markers = await readMarkers(page); + expect(markers.myPara).toBeGreaterThan(0); + expect(markers.myCallout).toBeGreaterThan(0); + // The user Callout override replaces the built-in wholesale — the + // built-in's `div.callout` chrome must not appear anywhere. + expect(markers.builtinCallout).toBe(0); +}); + +test('editing the .tsx on disk live-repaints with the new component', async ({ page }) => { + await page.goto(server.url); + await page.waitForFunction( + () => { + const innerDoc = document.querySelector('iframe')?.contentDocument; + return (innerDoc?.querySelectorAll('p.my-para').length ?? 0) > 0; + }, + undefined, + { timeout: 30_000 }, + ); + + // Rename the Para override's class on disk. The watcher accepts + // `.tsx` (WatchFilter::PreviewBroad), the change syncs into the SPA, + // tsxTick re-transpiles, and the iframe repaints the *cached* AST — + // no .qmd edit happens in this test. + await writeFile( + path.join(server.projectDir, 'overrides.tsx'), + tsxContent.replace(`className: 'my-para'`, `className: 'my-para-v2'`), + ); + + await page.waitForFunction( + () => { + const innerDoc = document.querySelector('iframe')?.contentDocument; + return (innerDoc?.querySelectorAll('p.my-para-v2').length ?? 0) > 0; + }, + undefined, + { timeout: 30_000 }, + ); + + const markers = await readMarkers(page); + expect(markers.myParaV2).toBeGreaterThan(0); + expect(markers.myPara).toBe(0); +}); + +test('[single-file] overrides load and a .tsx edit live-repaints', async ({ page }) => { + await page.goto(server.url); + + // Phase 3 core claim: in single-file mode the `.tsx` reaches the VFS + // via the render-components closure, so both overrides fire. + await waitForInnerSelector(page, 'p.my-para'); + await waitForInnerSelector(page, 'div.my-callout'); + expect((await readMarkers(page)).builtinCallout).toBe(0); + + // Watcher-enrollment claim: the closure also enrolls the `.tsx` in + // the (non-recursive, closure-scoped) single-file watcher, so a disk + // edit re-syncs, re-transpiles, and repaints. + await writeFile( + path.join(server.projectDir, 'overrides.tsx'), + tsxContent.replace(`className: 'my-para'`, `className: 'my-para-v2'`), + ); + await waitForInnerSelector(page, 'p.my-para-v2'); + expect((await readMarkers(page)).myPara).toBe(0); +}); diff --git a/q2-preview-spa/src/PreviewApp.tsx b/q2-preview-spa/src/PreviewApp.tsx index f521178c6..c86ef48e5 100644 --- a/q2-preview-spa/src/PreviewApp.tsx +++ b/q2-preview-spa/src/PreviewApp.tsx @@ -59,6 +59,12 @@ import { extractMetaString } from '@quarto/preview-renderer/framework'; import type { Diagnostic, Pass1Failure, PreviewNodeEditPayload } from '@quarto/preview-renderer/types/diagnostic'; import type { CaptureRef, FileEntry } from '@quarto/quarto-automerge-schema'; import { bootWithRetry, superviseReconnect } from './bootController'; +import { + buildCustomComponentsCode, + extractComponentPathsKey, + EMPTY_CUSTOM_COMPONENTS, + type CustomComponentsResult, +} from './customComponents'; import { BootLoadingScreen } from './components/BootLoadingScreen'; import { ForceRefreshButton } from './components/ForceRefreshButton'; import { PreviewDiagnosticsOverlay } from './components/PreviewDiagnosticsOverlay'; @@ -184,6 +190,24 @@ interface PreviewAppState { serverDiagnostics: Diagnostic[]; /** Bumps on every onFileContent callback so the render effect re-fires. */ contentTick: number; + /** + * GH #402 / bd-ue80chl0: bumps only when a `.tsx` file changes. + * Dedicated tick so the custom-components effect re-transpiles on + * component edits without riding `contentTick` (which bumps on every + * `.qmd` keystroke — the plan's Q1 decision: per-keystroke babel runs + * must not accumulate). + */ + tsxTick: number; + /** + * GH #402 / bd-ue80chl0: transpiled `render-components` overrides for + * the active document (+ any compile warnings, merged into the + * render-warnings overlay lane per the plan's Q3 decision). Stays the + * referentially-stable `EMPTY_CUSTOM_COMPONENTS` for documents + * without the key, so the iframe's `customComponentsCode` prop never + * churns and `LOAD_CUSTOM_COMPONENTS` is never re-posted on ordinary + * edits. + */ + customComponents: CustomComponentsResult; /** * Whether this preview session may edit documents (bd-ov4gqk3m). * Fetched once at boot from `GET /api/preview/config`; mirrors the @@ -370,6 +394,8 @@ function buildInitialState(): PreviewAppState { render: EMPTY_RENDER_STATUS, serverDiagnostics: [], contentTick: 0, + tsxTick: 0, + customComponents: EMPTY_CUSTOM_COMPONENTS, captures: {}, allowEdit: false, nestingCursor: typeof window !== 'undefined' @@ -745,7 +771,15 @@ export default function PreviewApp() { if (!shouldRerenderForTextChange(path, s.activeFile, s.deps)) { return s; } - return { ...s, contentTick: s.contentTick + 1 }; + // GH #402: `.tsx` touches additionally bump tsxTick so the + // custom-components effect re-transpiles. Ordinary source + // edits only bump contentTick (Q1 cadence decision). + const isTsx = path.toLowerCase().endsWith('.tsx'); + return { + ...s, + contentTick: s.contentTick + 1, + tsxTick: isTsx ? s.tsxTick + 1 : s.tsxTick, + }; }); }, // Phase D.3 (bd-kw93.9): binary docs (images, SVGs, @@ -1032,6 +1066,61 @@ export default function PreviewApp() { }; }, [state.activeFile, state.contentTick]); + // GH #402 / bd-ue80chl0: stable key for the active document's + // `render-components` path list, derived from the rendered AST meta. + // One JSON.parse per successful render (same cost the tab-title + // effect already pays); string-stable across edits that don't touch + // the list. + const componentPathsKey = useMemo( + () => extractComponentPathsKey(state.astJson), + [state.astJson], + ); + + // GH #402 / bd-ue80chl0: build the transpiled custom-components map + // for the iframe. Re-runs only when the path list changes, the active + // document changes, or a `.tsx` file was touched (tsxTick) — NOT on + // every contentTick (Q1 cadence decision). The shared transpiler + // (babel) is lazy-loaded inside buildCustomComponentsCode, so + // documents without the key never fetch it. + useEffect(() => { + if (!state.activeFile) return; + if (!componentPathsKey) { + // Identity-guarded reset so the common no-components path never + // churns state (and the iframe prop keeps its stable identity). + setState((s) => + s.customComponents === EMPTY_CUSTOM_COMPONENTS + ? s + : { ...s, customComponents: EMPTY_CUSTOM_COMPONENTS }, + ); + return; + } + let cancelled = false; + const activePath = state.activeFile; + void (async () => { + try { + const result = await buildCustomComponentsCode( + componentPathsKey, + activePath, + getFileContent, + ); + if (cancelled) return; + setState((s) => + s.activeFile === activePath ? { ...s, customComponents: result } : s, + ); + } catch (e) { + // Unexpected (per-component failures are captured as warnings + // inside the builder) — e.g. the lazy transpiler chunk failed + // to load. Log and leave the previous components in place. + console.error( + `custom-components build threw for ${activePath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + })(); + return () => { + cancelled = true; + }; + }, [componentPathsKey, state.activeFile, state.tsxTick]); + // Render the active page whenever it (or its content) changes. useEffect(() => { if (!state.activeFile) return; @@ -1202,7 +1291,22 @@ export default function PreviewApp() { // status + server-diagnostics feed. Both feeds share the same // overlay; the overlay decides how to lay them out via `severity` // and `serverDiagnostics` props. - const overlayInputs = computeOverlayInputs(state.render, state.serverDiagnostics); + // + // GH #402 (Q3 decision): custom-component compile warnings (missing + // file, transpile error) are part of "rendering" and merge into the + // render-warnings lane. Kept in their own state slot so a subsequent + // successful render doesn't wipe them (and vice versa). + const renderStatusWithComponentWarnings = + state.customComponents.warnings.length === 0 + ? state.render + : { + ...state.render, + warnings: [...state.render.warnings, ...state.customComponents.warnings], + }; + const overlayInputs = computeOverlayInputs( + renderStatusWithComponentWarnings, + state.serverDiagnostics, + ); if (state.boot === 'error' && state.error) { return ( @@ -1288,6 +1392,9 @@ export default function PreviewApp() { // Rich-text editor (bd-sjb4pzx8): on by default; `?richText=0` opts out. richText={state.richText} nestedEditBuffers={nestedEditBuffers} + // GH #402 / bd-ue80chl0: transpiled render-components overrides. + // Stable `{}` identity for documents without the key. + customComponentsCode={state.customComponents.code} /> {showStaleOverlay && ( . + * + * Same seam-pinning approach as `PreviewApp.integration.test.tsx` + * (runtime + iframe + transpiler mocked). The cadence tests pin the + * plan's Q1 decision: + * - `.qmd` keystrokes must NOT re-transpile (per-keystroke babel runs + * would accumulate); + * - `.tsx` touches and path-list changes MUST re-transpile; + * - documents without the key never load the transpiler and keep a + * referentially-stable empty `customComponentsCode` (so the iframe + * never re-posts LOAD_CUSTOM_COMPONENTS). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, act } from '@testing-library/react'; +import type { FileEntry } from '@quarto/quarto-automerge-schema'; + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +const capturedIframeProps: Array> = []; +vi.mock('@quarto/preview-renderer/iframe/Q2PreviewIframe', () => ({ + Q2PreviewIframe: (props: Record) => { + capturedIframeProps.push(props); + return
; + }, +})); + +const transpileSpy = vi.hoisted(() => + vi.fn((code: string) => `JS:${code}`), +); +vi.mock('@quarto/preview-renderer/utils/tsxTranspiler', () => ({ + transpileTSX: transpileSpy, +})); + +type RuntimeMockState = { + files: FileEntry[]; + renderResult: Record; + /** Text-file contents served by getFileContent (mutable per test). */ + textContents: Map; +}; +let runtimeMockState: RuntimeMockState; + +vi.mock('@quarto/preview-runtime', () => ({ + initWasm: vi.fn().mockResolvedValue(undefined), + isWasmReady: vi.fn(() => true), + connect: vi.fn(async () => runtimeMockState.files), + disconnect: vi.fn(async () => undefined), + setSyncHandlers: vi.fn(), + renderPageForPreview: vi.fn(async () => runtimeMockState.renderResult), + getBinaryDocById: vi.fn(async () => null), + getFilePaths: vi.fn(() => runtimeMockState.files.map((f) => f.path)), + getFileContent: vi.fn( + (path: string) => runtimeMockState.textContents.get(path) ?? null, + ), + vfsReadFile: vi.fn(() => ({ success: true, content: 'test qmd content\n' })), + vfsAddFile: vi.fn(() => ({ success: true })), + parseQmdContentSync: vi.fn(() => ({ success: true, ast: '{"blocks":[]}' })), + applyNodeEdit: vi.fn(() => 'updated qmd content\n'), + regenerateNestedBuffers: vi.fn(() => ({})), +})); + +import PreviewApp from './PreviewApp'; +import { EMPTY_CUSTOM_COMPONENTS } from './customComponents'; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +function astJsonWith(paths: string[] | null): string { + const meta = + paths === null + ? {} + : { + 'render-components': { + t: 'MetaList', + c: paths.map((p) => ({ + t: 'MetaInlines', + c: [{ t: 'Str', c: p }], + })), + }, + }; + return JSON.stringify({ 'pandoc-api-version': [1, 23, 0], meta, blocks: [] }); +} + +async function lastSyncHandlers() { + const runtime = await import('@quarto/preview-runtime'); + const calls = (runtime.setSyncHandlers as ReturnType).mock + .calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][0]; +} + +function lastCapturedCode(): Record | undefined { + return capturedIframeProps.at(-1)?.customComponentsCode as + | Record + | undefined; +} + +beforeEach(() => { + vi.clearAllMocks(); + capturedIframeProps.length = 0; + runtimeMockState = { + files: [ + { path: 'index.qmd', docId: 'automerge:doc-index' }, + { path: 'overrides.tsx', docId: 'automerge:doc-tsx' }, + ], + renderResult: { + success: true, + ast_json: astJsonWith(['overrides.tsx']), + }, + textContents: new Map([['overrides.tsx', 'export const Para = 1;']]), + }; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.endsWith('/health')) { + return new Response( + JSON.stringify({ + status: 'ok', + index_document_id: 'automerge:test-index-doc', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + return new Response('not found', { status: 404 }); + }), + ); +}); + +async function bootAndWaitForComponents(): Promise { + render(); + await waitFor(() => { + expect(lastCapturedCode()).toEqual({ + 'overrides.tsx': 'JS:export const Para = 1;', + }); + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('PreviewApp render-components wiring', () => { + it('transpiles listed components and passes them to the iframe', async () => { + await bootAndWaitForComponents(); + expect(transpileSpy).toHaveBeenCalledTimes(1); + }); + + it('does NOT re-transpile on a .qmd content change (Q1 cadence)', async () => { + await bootAndWaitForComponents(); + const codeBefore = lastCapturedCode(); + const handlers = await lastSyncHandlers(); + + // A .qmd keystroke: contentTick bumps, a re-render happens, but the + // path list is unchanged and no .tsx was touched — babel must not run. + await act(async () => { + handlers.onFileContent('index.qmd'); + }); + await waitFor(() => { + // The re-render reached the iframe (a fresh props capture)… + expect(capturedIframeProps.length).toBeGreaterThan(0); + }); + expect(transpileSpy).toHaveBeenCalledTimes(1); + // …and customComponentsCode kept its identity, so the iframe never + // re-posts LOAD_CUSTOM_COMPONENTS. + expect(lastCapturedCode()).toBe(codeBefore); + }); + + it('re-transpiles when a .tsx file is touched', async () => { + await bootAndWaitForComponents(); + runtimeMockState.textContents.set( + 'overrides.tsx', + 'export const Para = 2;', + ); + const handlers = await lastSyncHandlers(); + await act(async () => { + handlers.onFileContent('overrides.tsx'); + }); + await waitFor(() => { + expect(lastCapturedCode()).toEqual({ + 'overrides.tsx': 'JS:export const Para = 2;', + }); + }); + expect(transpileSpy).toHaveBeenCalledTimes(2); + }); + + it('keeps a referentially-stable empty code map for documents without the key', async () => { + runtimeMockState.renderResult = { + success: true, + ast_json: astJsonWith(null), + }; + render(); + await waitFor(() => { + expect(lastCapturedCode()).toBeDefined(); + }); + expect(lastCapturedCode()).toBe(EMPTY_CUSTOM_COMPONENTS.code); + expect(transpileSpy).not.toHaveBeenCalled(); + + // A .qmd edit re-renders; the empty map must keep its identity. + const handlers = await lastSyncHandlers(); + const before = capturedIframeProps.length; + await act(async () => { + handlers.onFileContent('index.qmd'); + }); + await waitFor(() => { + expect(capturedIframeProps.length).toBeGreaterThan(before); + }); + expect(lastCapturedCode()).toBe(EMPTY_CUSTOM_COMPONENTS.code); + expect(transpileSpy).not.toHaveBeenCalled(); + }); + + it('surfaces a missing component file in the diagnostics overlay', async () => { + runtimeMockState.renderResult = { + success: true, + ast_json: astJsonWith(['nope.tsx']), + }; + const { container } = render(); + await waitFor(() => { + expect(container.querySelector('.preview-error-overlay')).not.toBeNull(); + }); + // The component never loads; the built-ins render. + expect(lastCapturedCode()).toEqual({}); + }); +}); diff --git a/q2-preview-spa/src/customComponents.test.ts b/q2-preview-spa/src/customComponents.test.ts new file mode 100644 index 000000000..04088ca69 --- /dev/null +++ b/q2-preview-spa/src/customComponents.test.ts @@ -0,0 +1,125 @@ +/** + * Unit tests for the SPA's render-components parent half (GH #402 / + * bd-ue80chl0 Phase 2): `extractComponentPathsKey` (the cheap, + * stable-string effect key) and `buildCustomComponentsCode` (path + * resolution → content lookup → lazy transpile → warnings). + * + * The shared transpiler module is mocked so these tests don't pay for + * `@babel/standalone`; a hoisted flag additionally proves the lazy + * import is NOT taken for documents without `render-components` — + * that's the "common path stays free" guarantee. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + extractComponentPathsKey, + buildCustomComponentsCode, + EMPTY_CUSTOM_COMPONENTS, +} from './customComponents'; + +const hoisted = vi.hoisted(() => ({ transpilerImported: false })); + +vi.mock('@quarto/preview-renderer/utils/tsxTranspiler', () => { + hoisted.transpilerImported = true; + return { + transpileTSX: (code: string) => { + if (code.includes('SYNTAX ERROR')) { + throw new Error('Failed to transpile TSX: unexpected token'); + } + return `JS:${code}`; + }, + }; +}); + +function astJsonWith(paths: string[] | null): string { + const meta = + paths === null + ? {} + : { + 'render-components': { + t: 'MetaList', + c: paths.map((p) => ({ + t: 'MetaInlines', + c: [{ t: 'Str', c: p }], + })), + }, + }; + return JSON.stringify({ 'pandoc-api-version': [1, 23, 0], meta, blocks: [] }); +} + +beforeEach(() => { + hoisted.transpilerImported = false; + vi.resetModules(); +}); + +describe('extractComponentPathsKey', () => { + it('returns a stable JSON key for the path list', () => { + expect(extractComponentPathsKey(astJsonWith(['overrides.tsx', '/c/x.tsx']))).toBe( + JSON.stringify(['overrides.tsx', '/c/x.tsx']), + ); + }); + + it('returns "" when the key is absent, astJson is null, or JSON is invalid', () => { + expect(extractComponentPathsKey(astJsonWith(null))).toBe(''); + expect(extractComponentPathsKey(null)).toBe(''); + expect(extractComponentPathsKey('not json')).toBe(''); + }); +}); + +describe('buildCustomComponentsCode', () => { + it('returns the stable empty result — without importing the transpiler — for an empty key', async () => { + const getContent = vi.fn(); + const result = await buildCustomComponentsCode('', 'index.qmd', getContent); + expect(result).toBe(EMPTY_CUSTOM_COMPONENTS); + expect(getContent).not.toHaveBeenCalled(); + expect(hoisted.transpilerImported).toBe(false); + }); + + it('transpiles each resolved component, keyed by the original path', async () => { + const contents = new Map([ + ['docs/overrides.tsx', 'export const Para = 1;'], + ['components/x.tsx', 'export const Callout = 2;'], + ]); + const key = JSON.stringify(['overrides.tsx', '/components/x.tsx']); + const result = await buildCustomComponentsCode( + key, + 'docs/index.qmd', + (p) => contents.get(p) ?? null, + ); + // Relative entry resolves against the document's directory; leading + // `/` resolves against the project root. Keys stay the ORIGINAL + // meta strings (hub-client parity — the iframe logs them verbatim). + expect(result.code).toEqual({ + 'overrides.tsx': 'JS:export const Para = 1;', + '/components/x.tsx': 'JS:export const Callout = 2;', + }); + expect(result.warnings).toEqual([]); + }); + + it('warns and skips a component whose file is missing', async () => { + const key = JSON.stringify(['missing.tsx', 'present.tsx']); + const result = await buildCustomComponentsCode( + key, + 'index.qmd', + (p) => (p === 'present.tsx' ? 'export const A = 1;' : null), + ); + expect(result.code).toEqual({ 'present.tsx': 'JS:export const A = 1;' }); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].kind).toBe('warning'); + expect(result.warnings[0].title).toContain('missing.tsx'); + expect(result.warnings[0].title.toLowerCase()).toContain('not found'); + }); + + it('warns and skips a component that fails to transpile', async () => { + const key = JSON.stringify(['bad.tsx']); + const result = await buildCustomComponentsCode( + key, + 'index.qmd', + () => 'SYNTAX ERROR', + ); + expect(result.code).toEqual({}); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].title).toContain('bad.tsx'); + expect(result.warnings[0].problem).toContain('Failed to transpile'); + }); +}); diff --git a/q2-preview-spa/src/customComponents.ts b/q2-preview-spa/src/customComponents.ts new file mode 100644 index 000000000..e8c050c7a --- /dev/null +++ b/q2-preview-spa/src/customComponents.ts @@ -0,0 +1,130 @@ +/** + * Parent half of the render-components pipeline for the q2-preview SPA + * (GH #402 / bd-ue80chl0). Mirrors hub-client's `ReactRenderer.tsx` + * flow — meta walk → `resolveComponentPath` → content lookup → + * `transpileTSX` — using the same shared helpers, so the two preview + * surfaces cannot drift. + * + * The shared transpiler (`@quarto/preview-renderer/utils/tsxTranspiler`, + * which pulls `@babel/standalone`, ~3 MB min) is imported DYNAMICALLY + * and only when the document actually lists components: Vite splits it + * into a lazy chunk, so documents without `render-components:` never + * fetch or parse babel. Keep every import of that module dynamic — a + * static import anywhere in the SPA graph would fold babel into the + * main chunk. + */ + +import { resolveComponentPath } from '@quarto/preview-renderer/utils/componentPath'; +import { extractRenderComponentPaths } from '@quarto/preview-renderer/utils/renderComponents'; +import type { Diagnostic } from '@quarto/preview-renderer/types/diagnostic'; + +export interface CustomComponentsResult { + /** + * Compiled JS keyed by the ORIGINAL `render-components` entry string + * (hub-client parity — the iframe logs these keys verbatim). Entries + * that fail (missing file, transpile error) are omitted; the failure + * is reported in `warnings` instead. + */ + code: Record; + /** + * Component-pipeline failures, shaped as render diagnostics: per the + * plan's Q3 decision, "compiling a component" is part of "rendering", + * so these merge into the render-warnings overlay lane. + */ + warnings: Diagnostic[]; +} + +/** + * Referentially-stable result for documents without `render-components`. + * PreviewApp keeps this exact object in state for the common case so + * the iframe's `customComponentsCode` prop identity never churns (and + * `LOAD_CUSTOM_COMPONENTS` is never re-posted) across ordinary edits. + */ +export const EMPTY_CUSTOM_COMPONENTS: CustomComponentsResult = { + code: {}, + warnings: [], +}; + +/** + * Cheap, stable effect key for the transpile pipeline: the JSON string + * of the document's resolved `render-components` path list, or `''` + * when the document has none (or `astJson` is absent/unparseable). + * + * String-stable across `.qmd` keystrokes that don't touch the list — + * that's the Q1 cadence decision: per-keystroke edits must not + * accumulate babel runs. Re-transpilation is triggered only by this + * key changing or by a `.tsx` file being touched (the caller's + * `tsxTick`). + */ +export function extractComponentPathsKey(astJson: string | null): string { + if (!astJson) return ''; + let ast: unknown; + try { + ast = JSON.parse(astJson); + } catch { + return ''; + } + const paths = extractRenderComponentPaths(ast); + return paths.length > 0 ? JSON.stringify(paths) : ''; +} + +/** + * Resolve, read, and transpile the document's custom components. + * + * @param componentPathsKey key from {@link extractComponentPathsKey} + * @param currentFilePath project-root-relative path of the document + * (relative entries resolve against its dir) + * @param getContent text-file lookup, keyed by project-root- + * relative path without a leading slash (the + * SPA passes `getFileContent` from + * `@quarto/preview-runtime`) + */ +export async function buildCustomComponentsCode( + componentPathsKey: string, + currentFilePath: string, + getContent: (path: string) => string | null, +): Promise { + if (!componentPathsKey) { + return EMPTY_CUSTOM_COMPONENTS; + } + const componentPaths = JSON.parse(componentPathsKey) as string[]; + + // Lazy chunk: babel is only loaded once a document actually lists + // components. Subsequent calls hit the module cache. + const { transpileTSX } = await import( + '@quarto/preview-renderer/utils/tsxTranspiler' + ); + + const code: Record = {}; + const warnings: Diagnostic[] = []; + for (const path of componentPaths) { + const lookupPath = resolveComponentPath(path, currentFilePath); + const tsxCode = getContent(lookupPath); + if (tsxCode === null || tsxCode === undefined) { + console.warn(`[PreviewApp] Component file not found: ${path}`); + warnings.push({ + kind: 'warning', + title: `render-components: file not found: ${path}`, + problem: `The document lists \`${path}\` under \`render-components\`, but no synced file exists at \`${lookupPath}\`. The built-in component will be used instead.`, + hints: [], + details: [], + }); + continue; + } + try { + code[path] = transpileTSX(tsxCode); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[PreviewApp] Failed to transpile component ${path}:`, err); + warnings.push({ + kind: 'warning', + title: `render-components: transpile error in ${path}`, + problem: message, + hints: [], + details: [], + }); + } + } + + return { code, warnings }; +} diff --git a/ts-packages/preview-renderer/package.json b/ts-packages/preview-renderer/package.json index 04276eb45..67c2801a3 100644 --- a/ts-packages/preview-renderer/package.json +++ b/ts-packages/preview-renderer/package.json @@ -65,6 +65,7 @@ "test:watch": "vitest" }, "dependencies": { + "@babel/standalone": "^7.29.2", "@quarto/preview-runtime": "*", "@quarto/quarto-automerge-schema": "*", "@revealjs/react": "0.2.0", diff --git a/ts-packages/preview-renderer/src/q2-preview/entry-custom-components-reload.integration.test.tsx b/ts-packages/preview-renderer/src/q2-preview/entry-custom-components-reload.integration.test.tsx new file mode 100644 index 000000000..1f5b9fc61 --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/entry-custom-components-reload.integration.test.tsx @@ -0,0 +1,86 @@ +/** + * Iframe-side re-render-after-component-load test (GH #402 / + * bd-ue80chl0 Phase 2). + * + * `LOAD_CUSTOM_COMPONENTS` rebuilds the iframe's `customRegistry`, but + * historically the new registry only took effect on the NEXT + * `UPDATE_AST` — a component (re)load with no AST change repainted + * nothing. The fix caches the last `UPDATE_AST` payload at module top + * and re-runs `updateAst` after a load completes, so a `.tsx` edit + * repaints the live document (SPA and hub-client alike). + * + * Same harness as `entry.integration.test.tsx`: side-effect import of + * `./entry` registers the module-top message listener; `react-dom/client` + * is mocked so `root.render` calls are countable without mounting the + * framework. + */ + +import { describe, test, expect, beforeAll, vi } from 'vitest'; + +const { renderSpy } = vi.hoisted(() => ({ renderSpy: vi.fn() })); + +vi.mock('react-dom/client', () => ({ + createRoot: vi.fn(() => ({ render: renderSpy })), +})); +vi.mock('katex/dist/katex.min.css', () => ({})); + +beforeAll(async () => { + document.body.innerHTML = '
'; + await import('./entry'); +}); + +const EMPTY_AST_JSON = JSON.stringify({ + 'pandoc-api-version': [1, 23, 0], + meta: {}, + blocks: [], +}); + +function dispatchLoadComponents(componentsCode: Record) { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'LOAD_CUSTOM_COMPONENTS', componentsCode }, + }), + ); +} + +function dispatchUpdateAst() { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'UPDATE_AST', + payload: { + astJson: EMPTY_AST_JSON, + currentFilePath: 'index.qmd', + }, + }, + }), + ); +} + +/** Drain the async message handler (LOAD awaits loadCustomComponents). */ +async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('q2-preview/entry LOAD_CUSTOM_COMPONENTS re-render', () => { + test('boot-order LOAD before any UPDATE_AST does not render', async () => { + dispatchLoadComponents({}); + await flush(); + expect(renderSpy).not.toHaveBeenCalled(); + }); + + test('LOAD after UPDATE_AST re-renders the cached payload', async () => { + dispatchUpdateAst(); + await flush(); + expect(renderSpy).toHaveBeenCalledTimes(1); + + // A component (re)load with no AST change must repaint so the + // rebuilt registry takes effect. Empty componentsCode keeps the + // blob-import machinery out of jsdom; the re-render contract is + // the same regardless of module count. + dispatchLoadComponents({}); + await flush(); + expect(renderSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ts-packages/preview-renderer/src/q2-preview/entry.tsx b/ts-packages/preview-renderer/src/q2-preview/entry.tsx index 042c54370..f8200eec1 100644 --- a/ts-packages/preview-renderer/src/q2-preview/entry.tsx +++ b/ts-packages/preview-renderer/src/q2-preview/entry.tsx @@ -150,6 +150,12 @@ import { PreviewRoot } from './PreviewRoot'; let root: ReturnType | null = null; let customRegistry: Record> = {}; let componentsLoading = false; +// Last UPDATE_AST payload, cached so a LOAD_CUSTOM_COMPONENTS that +// arrives with no accompanying AST change (a `.tsx` edit re-transpiled +// by the parent) can repaint the current document with the rebuilt +// registry (GH #402 / bd-ue80chl0). Null until the first UPDATE_AST — +// the boot-order LOAD (posted before the first AST) must not render. +let lastAstPayload: UpdateAstPayload | null = null; // Slide-navigation bridge (bd-mwbsdmel). `RevealDeck`'s `RevealNavSync` // registers an imperative `goTo` here (and clears it on unmount); the @@ -245,7 +251,15 @@ window.addEventListener('message', async (event) => { componentsLoading = true; await loadCustomComponents(event.data.componentsCode); componentsLoading = false; + // Repaint the current document so the rebuilt registry takes + // effect immediately. Without this, a component (re)load only + // showed up on the next UPDATE_AST — a live `.tsx` edit that + // doesn't change the AST would repaint nothing. + if (lastAstPayload) { + updateAst(lastAstPayload); + } } else if (event.data.type === 'UPDATE_AST') { + lastAstPayload = event.data.payload; if (componentsLoading) { await new Promise((resolve) => { const check = setInterval(() => { diff --git a/ts-packages/preview-renderer/src/utils/renderComponents.test.ts b/ts-packages/preview-renderer/src/utils/renderComponents.test.ts new file mode 100644 index 000000000..6f9620fe3 --- /dev/null +++ b/ts-packages/preview-renderer/src/utils/renderComponents.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { extractRenderComponentPaths } from './renderComponents'; + +/** + * Unit tests for the shared `render-components` meta walk (GH #402 / + * bd-ue80chl0 Phase 1). The behavior is a verbatim extraction of + * hub-client's inline walk in `ReactRenderer.tsx`, including its + * mid-typing guards; these tests pin that contract for both parents + * (hub-client and the q2-preview SPA). + */ + +function astWithMeta(renderComponents: unknown): unknown { + return { + 'pandoc-api-version': [1, 23, 0], + meta: + renderComponents === undefined + ? {} + : { 'render-components': renderComponents }, + blocks: [], + }; +} + +function metaList(entries: unknown[]): unknown { + return { t: 'MetaList', c: entries }; +} + +function metaInlinesStr(s: string): unknown { + return { t: 'MetaInlines', c: [{ t: 'Str', c: s }] }; +} + +describe('extractRenderComponentPaths', () => { + it('extracts every path from a well-formed MetaList', () => { + const ast = astWithMeta( + metaList([metaInlinesStr('overrides.tsx'), metaInlinesStr('/components/extra.tsx')]), + ); + expect(extractRenderComponentPaths(ast)).toEqual([ + 'overrides.tsx', + '/components/extra.tsx', + ]); + }); + + it('returns [] when the key is absent', () => { + expect(extractRenderComponentPaths(astWithMeta(undefined))).toEqual([]); + }); + + it('drops a mid-typing null entry (bare `-` bullet parses to null)', () => { + const ast = astWithMeta(metaList([null, metaInlinesStr('overrides.tsx')])); + expect(extractRenderComponentPaths(ast)).toEqual(['overrides.tsx']); + }); + + it('drops an empty MetaInlines entry (delimiter typed, no content yet)', () => { + const ast = astWithMeta( + metaList([{ t: 'MetaInlines', c: [] }, metaInlinesStr('overrides.tsx')]), + ); + expect(extractRenderComponentPaths(ast)).toEqual(['overrides.tsx']); + }); + + it('returns [] for a non-list meta value', () => { + // `render-components: overrides.tsx` (scalar, not a list) parses to + // MetaInlines directly; the walk must not misread inline nodes as + // list entries. + const ast = astWithMeta(metaInlinesStr('overrides.tsx')); + expect(extractRenderComponentPaths(ast)).toEqual([]); + }); + + it('returns [] for null / non-object ASTs', () => { + expect(extractRenderComponentPaths(null)).toEqual([]); + expect(extractRenderComponentPaths(undefined)).toEqual([]); + expect(extractRenderComponentPaths('not an ast')).toEqual([]); + expect(extractRenderComponentPaths({ blocks: [] })).toEqual([]); + }); + + it('drops entries whose first inline is not a Str', () => { + const ast = astWithMeta( + metaList([{ t: 'MetaInlines', c: [{ t: 'Space' }] }, metaInlinesStr('a.tsx')]), + ); + expect(extractRenderComponentPaths(ast)).toEqual(['a.tsx']); + }); +}); diff --git a/ts-packages/preview-renderer/src/utils/renderComponents.ts b/ts-packages/preview-renderer/src/utils/renderComponents.ts new file mode 100644 index 000000000..c041ecd2e --- /dev/null +++ b/ts-packages/preview-renderer/src/utils/renderComponents.ts @@ -0,0 +1,31 @@ +/** + * Shared meta walk for the `render-components:` front-matter key + * (GH #402 / bd-ue80chl0). Extracted verbatim from hub-client's + * `ReactRenderer.tsx` so both parent surfaces (hub-client and the + * q2-preview SPA) read the key identically. + * + * The key parses to a MetaList of MetaInlines; each entry's path is the + * first inline's `Str` content. Entries that don't resolve to a + * non-empty string are dropped. This deliberately tolerates mid-typing + * states: + * - `render-components:\n -` — the bare bullet has no value and + * parses to `null`; + * - an empty MetaInlines — the user typed the path-string-open + * delimiter but no content yet. + * Without the filter, downstream `resolveComponentPath(undefined, …)` + * would throw inside the host's render path. + * + * A non-list value (scalar `render-components: foo.tsx`) yields `[]`: + * mapping over the MetaInlines' inline nodes never produces a string at + * `.c[0].c`, so every entry is filtered out. Same behavior as the + * original hub-client walk. + */ +export function extractRenderComponentPaths(ast: unknown): string[] { + const rawPaths: unknown[] = + (ast as any)?.meta?.['render-components']?.c?.map?.( + (o: any) => o?.c?.[0]?.c, + ) ?? []; + return rawPaths.filter( + (p): p is string => typeof p === 'string' && p.length > 0, + ); +} diff --git a/ts-packages/preview-renderer/src/utils/tsxTranspiler.test.ts b/ts-packages/preview-renderer/src/utils/tsxTranspiler.test.ts new file mode 100644 index 000000000..44bdcb20d --- /dev/null +++ b/ts-packages/preview-renderer/src/utils/tsxTranspiler.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { transpileTSX } from './tsxTranspiler'; + +/** + * Tests for the shared TSX transpiler (moved from + * `hub-client/src/services/tsxTranspiler.ts` in GH #402 / bd-ue80chl0 + * Phase 1). These exercise the real `@babel/standalone` transform — + * consumers that don't want to pay for babel at test time mock this + * module instead (see hub-client's `ReactRenderer.integration.test.tsx` + * and the SPA's customComponents tests). + */ +describe('transpileTSX', () => { + it('strips TypeScript and lowers JSX to React.createElement', () => { + const tsx = [ + `export function Para({ node }: { node: unknown }) {`, + ` return

hi

;`, + `}`, + ].join('\n'); + const js = transpileTSX(tsx); + // JSX lowered to the classic runtime (the iframe provides a global + // `React` before importing the blob module). + expect(js).toContain('React.createElement'); + expect(js).toContain('"my-para"'); + // Type annotations gone. + expect(js).not.toContain(': unknown'); + // ESM export preserved — the iframe imports the code as a blob ES + // module and reads the named exports. + expect(js).toContain('export function Para'); + }); + + it('throws a descriptive error on syntactically invalid input', () => { + expect(() => transpileTSX('const =

;')).toThrow(/Failed to transpile TSX/); + }); +}); diff --git a/hub-client/src/services/tsxTranspiler.ts b/ts-packages/preview-renderer/src/utils/tsxTranspiler.ts similarity index 61% rename from hub-client/src/services/tsxTranspiler.ts rename to ts-packages/preview-renderer/src/utils/tsxTranspiler.ts index 69099bc44..561437d75 100644 --- a/hub-client/src/services/tsxTranspiler.ts +++ b/ts-packages/preview-renderer/src/utils/tsxTranspiler.ts @@ -1,6 +1,14 @@ import { transform } from '@babel/standalone'; // EXPERIMENTAL functionality for custom render components +// +// Moved from `hub-client/src/services/tsxTranspiler.ts` (GH #402 / +// bd-ue80chl0 Phase 1) so hub-client and the q2-preview SPA share one +// transpiler. hub-client imports this module statically; the SPA imports +// it dynamically (`await import(...)`) so `@babel/standalone` lands in a +// lazy chunk that documents without `render-components:` never load. +// Nothing in the iframe entry graph may import this module — it would +// pull babel into the iframe bundle. /** * Transpile TSX code to JavaScript