From afa7f292fb267bb6498bf58afba18369574eee92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 6 Jul 2026 18:16:40 -0400 Subject: [PATCH] feat(lint): warn when can blank render output (#1893) * feat(lint): flag dir="rtl" on as a confirmed silent render failure Two independent reports diagnosed the same exact bug: dir="rtl" (or any non-ltr value) on 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 , 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." * fix(lint): only flag valid non-ltr html dir values --- packages/lint/src/rules/composition.test.ts | 64 +++++++++++++++++++++ packages/lint/src/rules/composition.ts | 32 +++++++++++ 2 files changed, 96 insertions(+) diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 5ac2ad48d..6929d5aa2 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -1009,6 +1009,70 @@ describe("composition rules", () => { }); }); + describe("html_dir_attribute_breaks_render", () => { + const CODE = "html_dir_attribute_breaks_render"; + const find = (findings: { code: string }[]) => findings.find((f) => f.code === CODE); + + it('flags dir="rtl" on ', async () => { + const html = ` + + +
مرحبا
+ +`; + const result = await lintHyperframeHtml(html); + const finding = find(result.findings); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain('dir="rtl"'); + expect(finding?.fixHint).toContain("direction: rtl"); + }); + + it('flags dir="auto" on ', async () => { + const html = ` +
+`; + const result = await lintHyperframeHtml(html); + const finding = find(result.findings); + expect(finding).toBeDefined(); + expect(finding?.fixHint).toContain('dir="auto"'); + }); + + it('does not flag dir="ltr"', async () => { + const html = ` +
+`; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); + + it("does not flag invalid dir values that browsers treat as ltr", async () => { + const html = ` +
+`; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); + + it("does not flag when has no dir attribute", async () => { + const html = ` +
+`; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); + + it('does not flag dir="rtl" scoped to an individual element (the documented fix)', async () => { + const html = ` +
+

مرحبا

+
+`; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); + }); + describe("subcomposition_blanks_before_host", () => { const find = (findings: Array<{ code: string }>) => findings.find((f) => f.code === "subcomposition_blanks_before_host"); diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 1e9625099..07e335d6c 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -634,6 +634,38 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding return findings; }, + // html_dir_attribute_breaks_render — valid non-LTR dir values on + // 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, is the + // only tell). Confirmed independently by two separate reports, both + // diagnosing the same exact trigger and the same fix: drop dir from + // , keep lang, and scope `direction: rtl` to individual + // text-containing elements via CSS instead (text still bidi-shapes + // correctly). Advisory-only — this does not attempt to fix the render + // pipeline's own root cause (suspected to be a capture step that clips a + // fixed top-left-origin screenshot region, which RTL layout can shift the + // actual content away from), only surfaces the already-confirmed footgun + // before someone hits it blind. + ({ source }) => { + const htmlTag = findHtmlTag(source); + if (!htmlTag) return []; + 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: ` renders correctly in preview/snapshot but produces a fully blank/black video from render — a confirmed, silent failure.`, + fixHint: `Remove dir="${dir}" from . 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), + }, + ]; + }, + // subcomposition_blanks_before_host // Warns when a full-bleed sub-composition slot ends before the host composition // does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to