Skip to content

Commit 02da39a

Browse files
fix(hub-client): tolerate empty render-components list entries
Typing render-components: - into a document's YAML frontmatter crashed the iframe-host page. The YAML parser produces a MetaList with a `null` (or empty MetaInlines) entry; `ReactRenderer.tsx`'s path-extraction expression `o?.c?.[0]?.c` returned `undefined`, which then crashed `resolveComponentPath(undefined, …)` inside the same `useMemo` with `Cannot read properties of undefined (reading 'startsWith')`. The throw bubbles past the local ErrorBoundary (the boundary is a JSX descendant of `ReactRenderer`, not an ancestor) and blanks the page — user can't even finish typing. Fix: filter the extracted paths to keep only non-empty strings. Empty list entries and empty-MetaInlines entries are skipped silently (no `console.warn` — they're transient editor states, not user errors worth surfacing). Two regression tests added to `ReactRenderer.integration.test.tsx`: one for `MetaList c: [null]` (the literal "type a bullet, no value" shape), one for `MetaList c: [{t: 'MetaInlines', c: []}]` (the "opened the string delimiter, no content yet" shape). Both fail before the fix with the exact TypeError above; both pass after.
1 parent 40ea278 commit 02da39a

2 files changed

Lines changed: 100 additions & 1 deletion

File tree

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)