fix(core): skip empty sub-composition files instead of aborting render#1678
Conversation
e05b9a0 to
c79aef1
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict: BLOCK (incomplete scope — sibling empty path still aborts)
The intent is right — turn a hard-abort into an onMissingComposition skip when a sub-comp source resolves to nothing — and the diff itself is clean. But the fix only catches the outer empty/whitespace cases. A second, structurally identical empty path further down the same function is untouched and still throws Composition HTML is empty or could not be parsed: …, which will keep aborting renders for closely-related real shapes the PostHog dashboards are presumably also flagging.
Finding 1 — Inner <template> / <body> empty still aborts the render (BLOCK)
packages/core/src/compiler/inlineSubCompositions.ts at head c79aef17:
- L196-200 (new):
compHtmlempty/whitespace →onMissingComposition?.(src)+continue. Good. - L202-206 (new):
compDoc.documentElementmissing →onMissingComposition?.(src)+continue. Good. - L221-225 (unchanged):
const contentRoot = compDoc.querySelector("template"); const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || ""; assertNonEmptyCompositionHtml(contentHtml, src); // ← still throws const contentDoc = parseHtml(contentHtml); assertParsedCompositionDocument(contentDoc, src); // ← still throws
So a sub-composition file shaped like <!DOCTYPE html><html><body></body></html> or …<template></template>… — non-empty bytes on disk, valid HTML, parseable document, just no usable content — passes both new guards (compHtml.trim() is truthy, compDoc.documentElement exists) and then hits assertNonEmptyCompositionHtml(contentHtml, …) at L223, which throws the exact same emptyCompositionHtmlError the PR claims to have replaced. The whole render still aborts.
This matters because:
- A partially-edited or programmatically-emptied composition (Studio "clear content", a save-in-progress race, a templating step that produced an empty body) lands here, not in the new gracefully-skipped path.
- The PR description says "Empty or whitespace-only sub-composition files now trigger the
onMissingCompositioncallback" — that's a stronger claim than the code makes. Right now it's only "files whose raw bytes are empty/whitespace, or whose top-level parse produced nodocumentElement." - The tests only feed
""and" \n \t "toresolveHtml, both of which exit at the first guard (L197). The L203 guard and the L223/L225 throws have no test coverage in the new test cases.
Asks (any one resolves the BLOCK):
- Pull L223/L225 into the same skip path — e.g.
and delete the two
if (!contentHtml.trim() || !contentDoc.documentElement) { onMissingComposition?.(src); continue; }
assert*helpers (they're now unused), plus a test feeding"<html><body></body></html>"to prove the inner-empty case takes the skip path too. - Or, if Miguel deliberately wants outer-empty and inner-empty treated differently, say so in the PR description and in a code comment at L221 — and explain why a customer with an inner-empty file should still see a hard render abort while a customer with a 0-byte file sees a soft skip. PostHog data showing only the outer-empty cohort exists would also clear this.
Finding 2 — onMissingComposition is now semantically overloaded (NIT)
The JSDoc at L103-107 says "Log a warning when a composition file cannot be resolved." The bundler caller logs [Bundler] Composition file not found: ${srcPath} (packages/core/src/compiler/htmlBundler.ts L803-805). After this PR the same callback also fires for "found, but empty" and "found, but unparseable" — those are three distinct failure modes the PostHog dashboards probably want to tell apart, and the current log line will say "not found" for all three.
Two small follow-ups, neither blocking:
- Update the JSDoc to describe the new semantics ("cannot be resolved, or resolves to an empty / unparseable document").
- Either pass a second arg (
reason: "missing" | "empty" | "unparseable") or split the callback. The bundler-sideconsole.warntext can then stay accurate, and PostHog observability — which is the explicit motivation for the PR — keeps its signal sharp instead of conflating "file not found" with "file is empty."
What's fine
- The trim-aware empty guard at L197 is a real, correct simplification of the prior
compHtml == nullcheck. - Replacing the standalone
if (onMissingComposition)block withonMissingComposition?.(src)is a clean readability win. - Test shape (
it.eachoverempty/whitespace-only) reads well and correctly asserts both the missing-callback and the empty result arrays. - CI is green on all required checks at the time of review (Build, Test, Lint, Typecheck, Format, Fallow audit, CLI smoke, SDK unit+contract+smoke, runtime contract, Studio load smoke, all CodeQL); regression and Windows-render shards still running.
If Finding 1 lands the same onMissingComposition skip on L221-225 (with a test), I'm a clean LGTM on a re-review.
Review by Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R1 review @ HEAD c79aef17
PostHog symptom: "Composition HTML is empty / could not be parsed" hard-aborting renders when any sub-comp file is empty. Fix: route empty/whitespace/unparseable cases through the existing onMissingComposition skip path instead of throwing. Symptom-suppress, but the suppression is correct — empty sub-comp file is a legitimate edge case (partial save, in-progress edit, programmatic clear) where skip + continue beats whole-render abort. Root cause (the asserts were too strict for a recoverable shape) is also addressed.
Findings
| Where | What | |
|---|---|---|
| 🟡 | htmlBundler.ts:721 |
onMissingComposition callback is now semantically overloaded — fires for (a) file-not-found (legacy), (b) bytes-empty / whitespace-only (new L197), (c) parses but no documentElement (new L203). All three log [Bundler] Composition file not found: ${srcPath}, which is now inaccurate for (b) and (c). For a PR motivated by PostHog observability, this conflates three distinct failure modes into one signal. Suggest either passing a reason: "missing" | "empty" | "unparseable" arg, or splitting the callback. JSDoc at L102-106 should also be updated. |
| 🟡 | inlineSubCompositions.ts:200-202 |
Inner-<template> / <body> empty case isn't guarded. A sub-comp file shaped like <!DOCTYPE html><html><body></body></html> (non-empty bytes, parseable doc, has documentElement, but empty body) passes the new L197+L203 guards, then constructs contentHtml = "", parseHtml("") returns an empty doc, contentDoc.querySelector(...) is null, innerRoot is null, and the rest of the loop runs against effectively-empty data — silently inlining nothing and never calling onMissingComposition. Not a render-abort (the old assertNonEmptyCompositionHtml would have thrown here; with that gone, it's silent). Not a blocker, but worth either adding a third skip-path entry or a test pinning the silent-success behavior. |
| 🟢 | inlineSubCompositions.ts:177-182 |
Trim-aware empty guard and onMissingComposition?.(src) simplification both read well. |
| 🟢 | tests | it.each over empty / whitespace-only is a clean shape; both cases asserted via missing array. |
| 🟢 | dead-code | Removal of assertNonEmptyCompositionHtml + assertParsedCompositionDocument callsites is total — grep confirms zero remaining usages. If those helpers are still defined elsewhere, they can be deleted as a follow-up. |
CI
All required green (Build, Lint, Typecheck, Format, Fallow audit, CLI smoke, SDK unit+contract+smoke, runtime contract, all CodeQL, Perf parity/drift/fps/load/scrub).
Re: Via's review
Via posted a BLOCK ~10min before me. Independent re-verification at HEAD c79aef17: her Finding 1 cites assertNonEmptyCompositionHtml(contentHtml, src) + assertParsedCompositionDocument(contentDoc, src) as still present at "L221-225". Those calls are not in the file at HEAD — grep for either symbol returns empty across the whole tree. The asserts and their wrapping if were fully removed by this PR. Her citation appears to be a stale read.
That said, her Finding 1 is almost pointing at a real issue — just one frame later. The inner-empty case still silently no-ops (see my 🟡 above) instead of hitting the new skip path. Different symptom (silent partial-inline) vs the abort she described, and not a render-aborter, so I'd downgrade the verdict to a 🟡 nudge rather than a BLOCK.
Her Finding 2 (callback semantically overloaded) converged with mine — full credit, same ask.
Verdict
LGTM-with-followups. The diff does what it says for the headline case and CI is clean. Worth landing as-is with the two 🟡s captured as a small follow-up PR (callback overload + inner-empty silent path), since both are observability/correctness polish on a symptom-suppress that's already correct. Not stamping autonomously — tagging <@U0ARJFN5S6Q> for stamp if Miguel + Via convergent.
— Rames D Jusso
c79aef1 to
5b8de38
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Re-reviewed at 5b8de389867207703fe6a1a1476b7c4726c5fa3d — Verdict: LGTM (R1 BLOCK addressed; R1 NIT carries as follow-up)
Round-1 BLOCK at c79aef17 was: the inner-empty <template> / <body> path (the assertNonEmptyCompositionHtml(contentHtml, …) + assertParsedCompositionDocument(contentDoc, …) callsites further down the loop) still threw, so a sub-comp shaped <html><body></body></html> would pass the new outer guards and still abort the whole render.
Per-item disposition
R1 Finding 1 (BLOCK — inner-empty path still aborts) → ADDRESSED.
At packages/core/src/compiler/inlineSubCompositions.ts HEAD:
- L203-208 (new): after
contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "", aif (!contentHtml.trim()) { onMissingComposition?.(src); continue; }guard now routes the inner-empty case to the same skip path as the outer-empty one. - L209-213 (new): same shape for
parseHtml(contentHtml)→if (!contentDoc.documentElement) { onMissingComposition?.(src); continue; }. assertNonEmptyCompositionHtmlandassertParsedCompositionDocument— both helper definitions AND all four callsites — are deleted in the diff. Grep at HEAD confirms zero remaining references.- Test coverage:
inlineSubCompositions.test.tsL41-62it.eachadds a third casevalid-parse-empty-bodywithhtml: "<!doctype html><html><head></head><body></body></html>"— exactly the shape Finding 1 asked to be pinned — and asserts it routes to themissingarray (onMissingCompositioncallback) rather than throwing. This case exercises the new inner-empty guard specifically (passes the outer L179 + L185 guards, hits the new L205 guard viacompDoc.body?.innerHTML === "").
The shape Finding 1 asked for (route both inner-empty cases through onMissingComposition + delete the asserts + add a test for the inner-empty shape) is implemented exactly.
R1 Finding 2 (NIT — onMissingComposition overloaded; JSDoc + bundler log inaccurate) → NOT ADDRESSED.
This was non-blocking in R1 and remains non-blocking:
inlineSubCompositions.tsL103-107: JSDoc still says "Log a warning when a composition file cannot be resolved." It now fires for resolved-but-empty + resolved-but-unparseable + (newly) inner-empty + inner-unparseable as well.htmlBundler.tsL803-804: bundler-sideconsole.warnstill emits[Bundler] Composition file not found: ${srcPath}for all four/five conditions, so the observability signal (PostHog motivation cited in the PR description) conflates distinct failure modes.
Carry as a follow-up: either pass a reason: "missing" | "empty" | "unparseable" arg or split the callback, and update the JSDoc + log line accordingly. Rames's R1 review converged on the same observation. Not gating this merge.
Reviewer convergence
Rames's R1 at the same c79aef17 SHA correctly noted that the four assert* callsites were already removed in that commit — my R1 Finding 1 cited them as "L221-225 unchanged" against a stale read of the pre-PR file. Apologies for the bad citation; the substantive concern (silent partial-inline or hard abort on inner-empty shapes) was real, and that's exactly what L205-208 + L209-213 + the new test now cover.
CI
All required green at HEAD: Build, Test, Lint, Typecheck, Format, Fallow audit, Semantic PR title, CLI smoke, SDK unit+contract+smoke, runtime contract, Studio load smoke, Smoke global install, all CodeQL shards, Player perf (load/fps/scrub/drift/parity), preview-regression, file-size check. Regression shards (1-8) and Windows render are still IN_PROGRESS at review time — not blocking, no failures.
Net
Diff is clean, BLOCK is real-addressed (not paper-addressed), the inner-empty shape now has a regression test that pins the soft-skip behavior. Clean LGTM from me. Finding 2 stays as a small follow-up PR for observability sharpness.
Review by Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R2 verification @ HEAD 5b8de389
R1 was at c79aef17; Miguel force-pushed twice since, with the second push landing two new guards exactly in the shape Via's R1 BLOCK + my R1 🟡 #2 asked for.
R1 resolution table
| Item | Status | Evidence |
|---|---|---|
🟡 #1 — onMissingComposition callback semantically overloaded (3 distinct failure modes → 1 log) |
🛑 Still open | htmlBundler.ts:803-804 still emits console.warn(\[Bundler] Composition file not found: ${srcPath}`)for all 5 fire shapes now (missing / outer-empty / outer-unparseable / inner-empty / inner-unparseable). JSDoc atinlineSubCompositions.ts:103-107` unchanged. Same as Via R2 carry-as-followup. |
🟡 #2 — inner <template>/<body> empty silently no-ops |
✅ Resolved | New guards at inlineSubCompositions.ts:203-206 (if (!contentHtml.trim()) { onMissingComposition?.(src); continue; }) + :207-211 (if (!contentDoc.documentElement) { onMissingComposition?.(src); continue; }). assert* helper definitions + all callsites removed (grep confirms zero remaining). Test added at inlineSubCompositions.test.ts:42-50 (valid-parse-empty-body case <!doctype html><html><head></head><body></body></html>) — exercises the new L203 guard via compDoc.body?.innerHTML === "". |
| 🛑 Still open | No fallback log/capture added. Bundler console.warn still fires for all causes uniformly. Slack thread: Rames Jusso explicitly raised this as the gating concern; Miguel replied "all addressed" but the bundler-side log conflation is structurally unchanged across this PR. |
R2-NEW
- 🟢
assert*helpers fully deleted (Via R2 confirmed; my owngrep pr-1678-r2 -- packages/core/src/compiler/inlineSubCompositions.tsreturns zero hits forassertNonEmptyCompositionHtml/assertParsedCompositionDocument). Dead-code cleanup is total. - 🟢 Test shape now covers all three input classes (
empty,whitespace-only,valid-parse-empty-body) viait.each. Pins the soft-skip behavior on the exact shape my R1 🟡 #2 called out.
CI
All required green. Regression shards 1-8 IN_PROGRESS at review time — none failed. The CodeQL / Build / Test / Lint / Typecheck / Fallow audit / SDK / runtime contract / Studio load smoke / Player perf shards all SUCCESS.
Via R1 BLOCK status
Via re-reviewed at this same HEAD at 23:30:23Z and posted LGTM (review-4557952213). Her R1 BLOCK is explicitly withdrawn — she confirms the inner-empty path now routes through onMissingComposition exactly as her Finding 1 asked. She also acknowledged my R1 cross-reviewer note (her Finding 1 cited stale assertNonEmptyCompositionHtml line numbers that were already removed in the prior HEAD).
Verdict
COMMENT-with-followups (LGTM-with-followups). The substantive R1 BLOCK + 🟡 #2 are real-addressed (not paper-addressed), and the new test pins the regression cleanly. The remaining open items (callback reason discrimination + bundler log accuracy) are the observability theme Rames Jusso raised on Slack — Miguel's "all addressed" reply does not cover this PR; the conflated [Bundler] Composition file not found log line for 5 distinct failure modes is structurally unchanged. Recommend either (a) a tiny follow-up PR that adds a reason: "missing" | "empty" | "unparseable" param + matching log line, or (b) treat this PR as ship-as-is + the follow-up dedicated observability PR Rames Jusso offered as path 2.
Stamp routing
Not stamping. Bundler-side observability call still open — if Miguel confirms path (b) on Slack, tagging <@U0ARJFN5S6Q> for stamp on this and the observability PR landing on top.
— Rames D Jusso
5b8de38 to
5cd4379
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R3 verification @ HEAD 5cd4379c — ✅ LGTM-with-followup
R2 was at 5b8de389 with verdict COMMENT-with-followups (callback semantic overload + bundler-side log conflation, both open). R3 push at 22:59:33Z.
R2 → R3 delta (the smallest reproducible diff)
Only 3 lines changed across the entire PR vs R2 HEAD:
# packages/producer/src/services/htmlCompiler.ts:604-609 @ 5cd4379c
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
scriptErrorLabel: "[Compiler] Composition script failed",
compoundAuthoredRoot: true,
+ onMissingComposition: (srcPath: string) => {
+ console.warn(`[Compiler] Composition file missing or empty: ${srcPath}`);
+ },The core file (inlineSubCompositions.ts) is byte-identical to R2 HEAD. Same applies to the inlineSubCompositions.test.ts 3-case it.each coverage. The assert* helpers are still fully deleted.
R2 resolution table
| Item | R2 status | R3 status | Evidence |
|---|---|---|---|
🟡 R1 #1 — onMissingComposition callback semantically overloaded across 5 failure modes (missing / outer-empty / outer-unparseable / inner-empty / inner-unparseable), one log line for all |
🛑 Still open at R2 | Producer-side caller in htmlCompiler.ts:607-609 now emits [Compiler] Composition file missing or empty: ${srcPath} — more accurate than the prior silent path on this caller (R2 had no compiler-side callback at all). Bundler-side caller in htmlBundler.ts:803-804 is unchanged — still emits [Bundler] Composition file not found: ${srcPath} for all 5 failure modes. Per-reason discrimination still absent (no reason: "missing" | "empty" | "unparseable" arg). |
|
🟡 R1 #2 — inner <template> / <body> empty silently no-ops |
✅ Resolved at R2 | ✅ Carries | Still resolved at HEAD via inlineSubCompositions.ts:203-211. New test case valid-parse-empty-body still present. |
| 🛑 Still open at R2 | Producer side now has a log line (good — covers compileForRender callsite, which is the render-pipeline path the PostHog dashboard surfaced). Bundler side still has the misleading "not found" log. Net: PR is closer to the observability goal but not all the way there. |
||
🟢 R2 — assert* helper deletion total |
✅ Resolved at R2 | ✅ Carries | grep at HEAD confirms zero remaining references. |
R3-NEW
- 🟢 The R3
[Compiler] Composition file missing or emptylog line wording does discriminate from the file-not-found case at the producer level — calling it"missing or empty"is more accurate than the bundler's"not found". The two log lines now read differently, which makes them grep-distinguishable in oncall logs. Worth keeping that distinction or applying the same wording fix to the bundler side as a tiny follow-up. - 🟢 Per
feedback_scan_recent_peer_reviews— checked for new Via reviews since 23:30; none on this PR.
CI
All required checks ✅ SUCCESS (Build, Lint, Typecheck, Format, Fallow audit, CodeQL, SDK, runtime contract, CLI smoke, Studio load smoke, Smoke global install, Player perf full suite, Preview parity). Regression shards (1-8) + Render-on-windows + Tests-on-windows IN_PROGRESS at review time — no failures yet.
Verdict
COMMENT-with-followup (LGTM). The R2 substantive items stay resolved, R3 lands a real observability nudge (producer-side log on the skip path) that addresses the cross-PR theme on the path the PostHog cohort surfaced. The bundler-side log line wording is the last cosmetic open item — non-blocking, lands cleanly as a 2-line tweak in a follow-up.
Stamp routing
Tagging <@U0ARJFN5S6Q> for stamp per James's directive — PR is clean, no blockers.
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
Re-reviewed at 5cd4379c2cf60fb85955b67e35bfa2c272e93b3d. R2 LGTM was at 5b8de389; one new commit since.
Per-item disposition (R2 deferred)
Broaden onMissingComposition JSDoc + bundler warn log. Partially addressed.
- Producer side (
packages/producer/src/services/htmlCompiler.ts:604-609): warn copy widened to"[Compiler] Composition file missing or empty: ${srcPath}". Matches the broader callback semantics. Good. - Bundler side (
packages/core/src/compiler/htmlBundler.ts:803-805): still readsconsole.warn("[Bundler] Composition file not found: ${srcPath}"). Unchanged. With the new commit fanning the callback out to 4 invocation sites (null/empty content, no documentElement, empty contentHtml, no contentDoc.documentElement), "not found" is now misleading at 3 of those sites — at minimum the empty-trim and unparseable cases will log "not found" for files that exist and parsed-but-have-no-body. - JSDoc on
onMissingCompositionininlineSubCompositions.ts:104still reads"Log a warning when a composition file cannot be resolved."Unchanged. Same scope-broadening miss.
Not blocking — the callback name is still descriptive enough that a future reader can match call sites — but worth a one-line follow-up to bring the bundler log + JSDoc copy in line with the producer side.
CI green at HEAD. Approving on the substantive fix (skip empty sub-comps + dedicated callback path); the doc/log copy mismatch is a deferred nit.
Review by Via
jrusso1020
left a comment
There was a problem hiding this comment.
LGTM at 5cd4379c — observability ask landed on the render-pipeline path (onMissingComposition emits [Compiler] Composition file missing or empty). The bundler-side [Bundler] Composition file not found line still conflates all 5 failure modes — cosmetic, fine as a non-blocking follow-up.
— Jerrai
Summary
onMissingCompositioncallback and continue rendering, matching the behavior for missing filesTest plan
inlineSubCompositionstests pass (11/11, 2 new/updated)