Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
325 changes: 325 additions & 0 deletions claude-notes/plans/2026-08-25-preview-spa-render-components.md

Large diffs are not rendered by default.

115 changes: 113 additions & 2 deletions crates/quarto-preview/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf>,
/// Referenced image assets (project-root-relative). Synced as **binary**.
pub binary_files: Vec<std::path::PathBuf>,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/wasm-quarto-hub-client/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion hub-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
}));

Expand Down
23 changes: 7 additions & 16 deletions hub-client/src/components/render/ReactRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]);
Expand Down
Loading
Loading