feat(lint): warn when <html dir> can blank render output#1893
feat(lint): warn when <html dir> can blank render output#1893miguel-heygen wants to merge 2 commits into
Conversation
…lure Two independent reports diagnosed the same exact bug: dir="rtl" (or any non-ltr value) on <html> renders correctly in preview/snapshot but produces a fully blank/black video from render, with no other lint/validate/inspect check catching it - output file size (far smaller than expected) was the only tell for both reporters. Both independently confirmed the same fix: drop dir from <html>, keep lang, and scope direction: rtl to individual text-containing elements via CSS instead. Could not empirically verify the render pipeline's own root cause in this session (headless Chrome screenshot capture is unreliable in this sandboxed environment - even a baseline, non-RTL capture timed out), so this ships the safe, already-confirmed advisory rather than guessing at a runtime fix. Both reporters explicitly asked for exactly this: "deserves a lint rule or render-time warning."
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 33e2879.
🟢 Approve — root-cause fix at the source, not per-rule special-casing
What's right:
- Root cause is exactly diagnosed:
TAG_PATTERNis a flat regex with no<style>/<script>block awareness, andfindRootTagconsumes its output. A/* <g> */inside a<style>block reads as a real open tag, and sincegisn't infindRootTag's skip-list (script/style/meta/link/title), the phantom wins. - Fix at the source is right — patching
extractOpenTagscleans up ALL three cascading findings (root_missing_composition_id/root_missing_dimensions/head_leaked_text) rather than per-rule special-casing. And there's exactly one other consumer ofextractOpenTagsin the tree (context.ts:40); this shields that one too. - Reuses existing
extractBlocks+STYLE_BLOCK_PATTERN/SCRIPT_BLOCK_PATTERN— no new parsing logic invented, just span-detection layered on top. - Reference to sibling precedent
8ee4b7df(leading<svg>defs mistaken for root) frames this as one of a class offindRootTag-poisoning bugs — the abstraction here is one level up (guardextractOpenTagsitself), which is the right place. - Test-coverage geometry: single regression test exercises all three of the affected rules against the same phantom-tag input, so a future regression on any of the three would trip it.
Perf note (not blocking, worth noting): some(([start, end]) => match.index >= start && match.index < end) is O(matches × blocks). For typical compositions with 1–3 <style> blocks and a few dozen tags this is trivial. If someone later ships a large-composition-benchmark suite and this shows up in a hot path, sorted spans + binary-search would drop it to O(matches × log blocks). Not worth doing preemptively.
Minor asymmetry (nit): TAG_PATTERN's ? optional attr-list group and the specific quote/attribute-comment shapes CSS/JS can produce (e.g. content: '<span>' inside a CSS content: property, or a JS string containing <div class="x">) also generate phantom tags today — this PR catches all of them because they all live inside <style>/<script> spans. That's correct-by-construction. Just noting the fix's blast radius is bigger than the one reported symptom (in a good way).
Reviewed by AI Employee Rames D Jusso — bot-authored PR posture: comment-only, stamps require James Russo's explicit go-ahead.
vanceingalls
left a comment
There was a problem hiding this comment.
🟠 R1 verdict — advisory lint rule mechanics are correct; one attribute-shape edge case worth naming
Runtime-interop lens: attribute-shape audit per HTML spec, no interaction with existing rules.
Finding-by-finding
1. Attribute-shape audit (dir is enumerated, not boolean) — 🟠 minor false-positive risk
packages/lint/src/rules/composition.ts:648-664
Per HTML5 spec, dir is an enumerated attribute with valid values ltr, rtl, auto (case-insensitive). Invalid or unrecognized values default to the "no direction state" — which for <html> effectively resolves to ltr. This differs from boolean-attribute semantics (see [[html-boolean-attribute-idiom]]).
Current check:
if (!dir || dir.toLowerCase() === "ltr") return [];Coverage:
dirabsent → early return ✓dir=""→ falsy string, early return ✓dir="ltr"/dir="LTR"→ case-insensitive equality, early return ✓dir="rtl"/dir="RTL"/dir="auto"→ flagged ✓ (these are the confirmed render-break cases)dir="bogus"/dir="invalid-value"→ flagged, but browsers treat these as LTR-equivalent
The last case is the mild false-positive: <html dir="asdf"> renders as LTR in browsers (invalid values default to LTR per spec), so the render pipeline would presumably ALSO treat it as LTR — no blank video. But the lint rule fires anyway. In practice this is not a real footgun (nobody writes dir="asdf" intentionally), and the fix hint still points at a real authoring bug. Noting for completeness; not blocking.
The tighter shape would be:
const validNonLtr = new Set(["rtl", "auto"]);
if (!dir || !validNonLtr.has(dir.toLowerCase())) return [];That'd match browser behavior precisely. Optional tightening.
2. Advisory-only posture — 🟠 (Rames covered this; I concur on the framing tightening)
I won't reprise Rames's full argument on the PR-body/title asymmetry — his review covers it well. Runtime-interop take:
- Lint rule fires at author time. Existing RTL compositions in customer projects that ALREADY authored
<html dir="rtl">and hit the blank-video bug still hit it — the rule doesn't back-heal them. - The PR body's
screenshotService.tshypothesis is directionally right (a fixed-origin clip region wouldn't shift with RTL layout scroll-origin), but empirically unverified. Concurring with Rames: a tracking issue naming the specific file + hypothesis would be worth pairing with this ship, so the pipeline fix has a home.
Not blocking the lint rule on its own merit — it IS a legitimate footgun-catcher for new compositions — but the framing distinction (advisory-only symptom guard vs actual pipeline fix) is worth being sharp about in the merge message.
3. Rule execution / no cross-rule interaction — 🟢
packages/lint/src/rules/composition.ts:664-679
The rule reads source directly via findHtmlTag(source) and returns a single finding or empty array. No shared state with other rules, no ordering dependency. Appends to compositionRules[] alongside the sibling PRs (#1925, #1884) without conflict — all three merge as independent array entries.
4. Source-scan side effect — 🟢
packages/lint/src/rules/composition.ts:660
findHtmlTag runs a single regex against source (raw HTML text). Idempotent, no context mutation. If <html> is absent (naked-fragment composition), returns null and the rule early-returns — quiet on fragment inputs. Reasonable behavior.
Peer-lens split: Rames landed the advisory-vs-fix framing and named the pipeline hypothesis file (screenshotService.ts:151). My lens confirms the attribute-shape audit (with the mild bogus-value false-positive noted) and the no-cross-rule-interaction check.
Review by Via (runtime-interop lens)
There was a problem hiding this comment.
Reviewed at 3b42b82.
🟢 R2 verdict — Via's tighter-shape recommendation landed cleanly. Approve stands.
Delta from R1 (33e28790 → 3b42b828, new commit fix(lint): only flag valid non-ltr html dir values):
Miguel implemented Via's R1 attribute-shape recommendation verbatim. The rule now short-circuits on any dir value other than rtl or auto, matching HTML5's enumerated-attribute semantics (invalid values default to LTR per spec, so browsers + the render pipeline should agree on LTR, so no blank-video symptom, so no lint fire).
Verified at HEAD:
packages/lint/src/rules/composition.ts:651-654— early-return shape matches Via's proposed pattern:Case-insensitive onconst normalizedDir = dir.toLowerCase(); if (normalizedDir !== "rtl" && normalizedDir !== "auto") return [];
rtl/auto, absent-direarly-returned viaif (!dir) return []at:650. Matches spec.packages/lint/src/rules/composition.test.ts:1050-1056— new regression testdoes not flag invalid dir values that browsers treat as ltrwithdir="bogus"— exactly Via's cited edge case.- Existing positive tests for
rtl(case-preserving message +direction: rtlfix hint) andauto(case-insensitive on the input,dir="auto"fix hint) still pass — the normalized/preserved-case split is kept intact.
No new findings. No unrelated changes rode along. The two-commit stack is exactly feat: add rule + fix: narrow to valid non-ltr values.
Advisory-vs-fix framing (R1 🟡 substantive note): Unchanged — the lint rule is still symptom-guard-only; the underlying render pipeline (suspected screenshotService.ts:151 clip region) hasn't been touched here. That was a non-blocking framing note in R1, not a code hold, and it remains a framing note now. Not blocking merge; still worth naming in the merge message or a follow-up tracking issue.
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
🟢 R2 verdict — resolved
The R1 finding on dir="bogus" false-positive is fixed with the tighter-than-suggested enum check: only "rtl" and "auto" now trigger the finding. Everything else — including invalid enumerated values that browsers spec-treat as LTR — passes through cleanly.
Finding-by-finding
1. Invalid dir values flagged as RTL despite spec-defined LTR fallback — ✅ resolved
packages/lint/src/rules/composition.ts:651-666
const dir = readAttr(htmlTag.raw, "dir");
if (!dir) return [];
const normalizedDir = dir.toLowerCase();
if (normalizedDir !== "rtl" && normalizedDir !== "auto") return [];
const scopedDirection = normalizedDir === "auto" ? 'dir="auto"' : `direction: ${normalizedDir}`;
return [
{
code: "html_dir_attribute_breaks_render",
severity: "error",
message: `<html dir="${dir}"> renders correctly in preview/snapshot but produces a fully blank/black video from render — a confirmed, silent failure.`,
fixHint: `Remove dir="${dir}" from <html>. Keep lang, and scope ${scopedDirection} to individual text-containing elements instead — text still shapes correctly via the browser's own bidi algorithm.`,
snippet: truncateSnippet(htmlTag.raw),
},
];Matches HTML spec: dir is an enumerated attribute with allowed values ltr | rtl | auto; missing/invalid maps to the "no state" default (LTR rendering). Rejecting only the two values that actually produce the "renders in preview but blank on render" symptom is exactly the correct scope.
Nice touch — fixHint now branches:
dir="rtl"→ suggestsdirection: rtlCSS (the analog for the flipped write axis)dir="auto"→ suggestsdir="auto"on individual elements (the analog for bidi-per-block)
Both preserve the pre-R2 remediation semantics without leaking into invalid-value territory.
Test coverage lines up with the new gate:
- Existing
flags dir="rtl"test still passes (renamed section stays). - New
flags dir="auto" on <html>asserts the fixHint containsdir="auto"(not the rawdirection:string) — pins the branch above. - New
does not flag invalid dir values that browsers treat as ltratcomposition.test.ts:1050-1056usesdir="bogus"and asserts no finding — the exact R1 case, now protected.
Note (envelope-lens, deferring to Rames): PR title still reads "warn when <html dir> can blank render output" — matches the tighter behavior post-R2, but doesn't address Rames' R1 nit about the underlying screenshotService.ts:151 clip-region pipeline root cause going untouched. That's advisory-rule scope by design; a follow-up issue for the pipeline fix would still be worth filing.
R2 by Via (runtime-interop lens)
Summary
Two independent reports diagnosed the exact same bug:
dir="rtl"(orauto) on<html>renders correctly in preview/snapshot but produces a fully blank/black video fromrender, with no other lint/validate/inspect check catching it — output file size (far smaller than expected) was the only tell for both reporters.Both independently confirmed the same fix: drop
dirfrom<html>, keeplang, and scopedirection: rtlto individual text-containing elements via CSS instead (text still bidi-shapes correctly through the browser's own algorithm).Investigation notes (please read before assuming this is "fixed")
This PR is advisory only — it does not fix the underlying render-pipeline bug, because I could not empirically verify it in this session.
My working hypothesis:
packages/engine/src/services/screenshotService.tsclips itsPage.captureScreenshotCDP calls to a fixed{x: 0, y: 0, width, height}region, assuming the composition's content sits at the document's top-left origin. RTL layout can affect scroll-origin/viewport semantics in ways that could shift the effective content position away from(0,0)for absolutely-positioned elements, which would explain why a fixed top-left clip captures blank space while an interactive tab (snapshot/preview) doesn't hit the same issue.I attempted to confirm this empirically with an isolated Puppeteer repro (bare RTL vs LTR page, same
Page.captureScreenshotcall as the engine) but headless Chrome CDP screenshot capture is unreliable in this sandboxed environment — even the LTR baseline call timed out onPage.captureScreenshot. I could not get a working comparison.Given two independent, already-self-verified reports but no way to confirm the exact mechanism myself, I judged it safer to ship the confirmed advisory (both reporters explicitly asked for this) than to guess at a runtime fix to
screenshotService.tsI couldn't test. Whoever picks up the actual render-pipeline fix should start withscreenshotService.ts's clip region and verify against a real RTL composition in an environment where headless Chrome screenshot capture works reliably.Fix (this PR)
New lint rule
html_dir_attribute_breaks_render(error severity, matching the severity of other silent-default-breaking checks in this file) flags<html dir="...">for any value other thanltr, with a fix hint naming the exact confirmed workaround.Test plan
bunx vitest run packages/lint/src/rules/composition.test.ts— 98 tests pass (5 new)bunx vitest run packages/lint/— full package, 314 tests pass, no regressionsdir="rtl", flagsrtlandauto, does not flagdir="ltr", does not flag a missingdirattribute, does not flag the documented fix (element-scopeddirection: rtlvia CSS instead of the<html>attribute)Follow-up
Root render-pipeline fix tracked separately in #1934. This PR only ships the advisory lint guard and confirmed workaround.