Skip to content

Commit eb07797

Browse files
fix(hub-client): tolerate empty render-components list entries
Empty strings in the YAML render-components list crashed the parser. Skip empty entries silently rather than erroring.
1 parent 915f1a3 commit eb07797

3 files changed

Lines changed: 101 additions & 1 deletion

File tree

hub-client/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ be in reverse chronological order (latest first).
1717

1818
- [`979982bd`](https://github.com/quarto-dev/q2/commits/979982bd): Custom syntax highlighting now works in Quarto Hub projects (bd-izfv) — user-supplied tree-sitter grammars under `_quarto/grammars/<lang>/` now apply to code blocks even when the qmd file lives under a `_quarto.yml` ancestor, matching the single-file render path.
1919
- [`c8a684bd`](https://github.com/quarto-dev/q2/commits/c8a684bd): Fix bd-3gtn: WASM artifact-flush loop in `wasm-quarto-hub-client/src/lib.rs` was overwriting user-uploaded image bytes with empty content on every render. `ResourceCollectorTransform` produces manifest-only entries via `Artifact::from_path` whose absolute path collapsed via `Path::join`'s absolute-replace rule onto the user's VFS upload location. Both flush loops now skip empty-content artifacts, preserving manifest semantics for downstream consumers while keeping produced bytes (theme CSS, fonts, future engine-generated images) flowing. Discovered during Plan 2A review (2026-05-07) and routed-around by Plan 2B until now; the test in `assetManifestProject.wasm.test.ts` was reverted to its natural lifecycle (binary upload BEFORE render) for a bug-then-fix walk-through.
20+
- [`02da39a5`](https://github.com/quarto-dev/q2/commits/02da39a5): Fix iframe-host crash when typing `render-components:\n -` (empty list bullet) into a document's YAML frontmatter. The path-extraction in `ReactRenderer.tsx`'s `componentPathsKey` useMemo yielded `undefined` for the null entry, which then crashed `resolveComponentPath(undefined, …)` with `Cannot read properties of undefined (reading 'startsWith')` — past the local ErrorBoundary (a descendant, not an ancestor of `ReactRenderer`), so the page went blank and the user couldn't finish typing. Now filters extracted paths to keep only non-empty strings; empty entries are skipped silently. Two regression tests cover the `MetaList c: [null]` and `MetaList c: [{t: 'MetaInlines', c: []}]` mid-type shapes.
2021
- [`3b064478`](https://github.com/quarto-dev/q2/commits/3b064478): Plan 2D q2-preview body container + title block. `PreviewDocument.tsx` now emits the `<div id="quarto-content"><main class="content">` wrapper mirroring `template.rs:185-247`, applies `body-classes` imperatively to `document.body`, and re-implements the Rust `title-block` transform's minimal-mode `<h1>` synthesis React-side (the transform itself stays in `Q2_PREVIEW_TRANSFORM_EXCLUDED`). Iframe `document.title` now tracks `meta.pagetitle ?? meta.title`. New `q2-preview/custom/PreviewTitleBlock.tsx` mirrors `template.rs:211-240` byte-for-byte and is registered under the synthetic `__title_block__` registry key; user TSX overrides can full-replace or compose via the `__Q2_PREVIEW_RENDERER__.PreviewTitleBlock` exposure. Phase 6.0 framework extraction lifts `inlinesToPlainText` / `blocksToPlainText` to `framework/plainText.ts` and adds `framework/meta.ts` (`extractMetaString` / `extractMetaBool` / `extractMetaStringList`); `FormatRegistry` gains typed optional entries for both synthetic keys. The slide renderer's private `extractMetaString` is replaced by the framework helper (deliberate behavior change for inline-markup in slide titles, locked by a regression test); `meta.format` extraction in `ReactRenderer.tsx` and `getQ2Format.ts` consolidates onto the same helper. Tests: 27 framework unit, 18 PreviewDocument integration, 17 PreviewTitleBlock integration (incl. composition idiom), explicit synthetic-key assertion closing a pre-existing `__fallback__` gap; 11 smoke-all q2-preview fixtures (body-container + body-classes + title-block).
2122
- [`c8a684bd`](https://github.com/quarto-dev/q2/commits/c8a684bd): Fix bd-3gtn: WASM artifact-flush loop in `wasm-quarto-hub-client/src/lib.rs` was overwriting user-uploaded image bytes with empty content on every render. `ResourceCollectorTransform` produces manifest-only entries via `Artifact::from_path` whose absolute path collapsed via the absolute-replace rule of `Path::join` onto the user's VFS upload location. Both flush loops now skip empty-content artifacts, preserving manifest semantics for downstream consumers while keeping produced bytes (theme CSS, fonts, future engine-generated images) flowing. Discovered during Plan 2A review (2026-05-07) and routed-around by Plan 2B until now; the test in `assetManifestProject.wasm.test.ts` was reverted to its natural lifecycle (binary upload BEFORE render) for a bug-then-fix walk-through.
2223
- [`d91150c4`](https://github.com/quarto-dev/q2/commits/d91150c4): Plan 2B Phase 5.2 and 5.3: smoke-all `q2-preview/image-with-attrs.qmd` fixture (with a 1x1 PNG sibling) and the `assetManifestProject.wasm.test.ts` WASM-bridge safety net. The smoke fixture asserts the parent-side asset walker mints a blob URL, propagates the width attribute, and round-trips alt text. The WASM test mirrors `themeFingerprint.wasm.test.ts` and renders a `_quarto.yml`-rooted project doc with a project-relative Image, then exercises the walker against the parsed AST and project-relative `currentFilePath`. Catches default-project resolution bugs analogous to the Plan 2A theme path mismatch (commit e6381abd). 82/82 WASM tests pass.

hub-client/src/components/render/ReactRenderer.integration.test.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,4 +264,90 @@ describe('ReactRenderer render-components gate (Plan 2A item 13)', () => {
264264
'/elliot/simple.tsx': 'JS:export const Para = () => null;',
265265
});
266266
});
267+
268+
it('skips empty `render-components` list entries without crashing', () => {
269+
// Reproduces the bug observed while typing in the YAML frontmatter:
270+
//
271+
// render-components:
272+
// -
273+
//
274+
// The parser produces a MetaList with a single empty / null entry
275+
// (no MetaInlines, no Str). Before the fix, the path extraction
276+
// emitted `[undefined]`, which then crashed `resolveComponentPath`
277+
// mid-type with `Cannot read properties of undefined (reading
278+
// 'startsWith')`. No upstream ErrorBoundary, so the iframe-host
279+
// page went blank and the user couldn't finish typing.
280+
//
281+
// The renderer should tolerate empty entries by skipping them,
282+
// leaving the customComponentsCode map empty.
283+
const malformedAst = JSON.stringify({
284+
'pandoc-api-version': [1, 23, 0],
285+
meta: {
286+
'render-components': {
287+
t: 'MetaList',
288+
// single empty bullet: the parser commonly produces `null`,
289+
// sometimes `{t: 'MetaString', c: ''}`, occasionally an
290+
// empty MetaInlines. All three shapes must not crash.
291+
c: [null],
292+
},
293+
},
294+
blocks: [],
295+
});
296+
297+
expect(() => {
298+
render(
299+
<ReactRenderer
300+
astJson={malformedAst}
301+
currentFilePath="elliot/index.qmd"
302+
files={[]}
303+
fileContents={new Map()}
304+
onNavigateToDocument={() => {}}
305+
setAst={() => {}}
306+
format="q2-preview"
307+
/>,
308+
);
309+
}).not.toThrow();
310+
expect(lastCapturedPreviewCode()).toEqual({});
311+
});
312+
313+
it('skips `render-components` entries that resolve to empty strings', () => {
314+
// Same defensive coverage for the case where the YAML entry parsed
315+
// as a MetaInlines with no children (or a Str of empty text). The
316+
// path extraction yields '' which would also crash downstream
317+
// (well, technically empty string would skip resolveComponentPath
318+
// but still attempt a `fileContents.get('')` lookup and a noisy
319+
// console.warn). Treating empty paths as absent keeps the warn
320+
// surface clean while the user is mid-typing.
321+
const partiallyTypedAst = JSON.stringify({
322+
'pandoc-api-version': [1, 23, 0],
323+
meta: {
324+
'render-components': {
325+
t: 'MetaList',
326+
c: [{ t: 'MetaInlines', c: [] }],
327+
},
328+
},
329+
blocks: [],
330+
});
331+
332+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
333+
expect(() => {
334+
render(
335+
<ReactRenderer
336+
astJson={partiallyTypedAst}
337+
currentFilePath="elliot/index.qmd"
338+
files={[]}
339+
fileContents={new Map()}
340+
onNavigateToDocument={() => {}}
341+
setAst={() => {}}
342+
format="q2-preview"
343+
/>,
344+
);
345+
}).not.toThrow();
346+
expect(lastCapturedPreviewCode()).toEqual({});
347+
// Empty-string paths should be skipped without warning — they
348+
// arise from mid-typing the YAML and aren't a user error worth
349+
// surfacing.
350+
expect(warnSpy).not.toHaveBeenCalled();
351+
warnSpy.mockRestore();
352+
});
267353
});

hub-client/src/components/render/ReactRenderer.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,20 @@ function ReactRenderer({
116116
}
117117

118118
const ast = JSON.parse(astJson);
119-
const componentPaths = ast?.meta?.['render-components']?.c?.map?.((o: any) => o?.c?.[0]?.c) ?? [];
119+
// Walk the MetaList → MetaInlines → Str(c) chain. Entries that
120+
// don't resolve to a non-empty string are dropped: this includes
121+
// (a) `render-components:\n -` mid-typing, where the bullet has
122+
// no value and parses to `null`, and (b) an empty MetaInlines
123+
// (the user typed the path-string-open delimiter but no content
124+
// yet). Without this filter, `resolveComponentPath(undefined …)`
125+
// throws inside this useMemo and the iframe-host page goes blank
126+
// with no upstream ErrorBoundary to catch it.
127+
const rawPaths: unknown[] =
128+
ast?.meta?.['render-components']?.c?.map?.((o: any) => o?.c?.[0]?.c) ??
129+
[];
130+
const componentPaths = rawPaths.filter(
131+
(p): p is string => typeof p === 'string' && p.length > 0,
132+
);
120133

121134
return JSON.stringify(componentPaths);
122135
}, [format, astJson]);

0 commit comments

Comments
 (0)