fix(lint): stop a leading <svg> defs block from being mistaken for the composition root (#1867)

Two independent post-release feedback reports of the same mechanism: a
leading <svg> block (icon/gradient/filter <defs>, referenced by url(#id)
elsewhere in the document) placed before the real [data-composition-id]
root manufactures root_missing_composition_id + root_missing_dimensions
on an otherwise-correct composition. Moving the <svg> after the root
cleared both findings for each reporter.

findRootTag returned the first body child that wasn't script/style/meta/
link/title, unconditionally — <svg> was never in that skip list, so a
leading defs-only <svg> got treated as the root.

Fix: skip a leading <svg> when it carries none of the composition markers
itself (data-composition-id/data-width/data-height), so an intentionally
SVG-rooted composition is still eligible as the root. The first attempt at
this only skipped the <svg> open tag, which surfaced a second bug:
extractOpenTags is a flat, nesting-unaware scan, so the very next tag it
returns after skipping <svg> is the svg's own nested child (<defs>,
<filter>, ...), not the sibling after </svg>. Track the svg's closing tag
position and skip every tag before it, not just the <svg> tag itself.

Tests: skips a leading svg defs block (no false root findings); still
treats an <svg> as the root when data-composition-id/data-width/
data-height are declared directly on it. Full lint suite (308 tests) passes.
This commit is contained in:
Miguel Ángel
2026-07-02 17:45:09 -07:00
committed by GitHub
parent d2f1adc2af
commit 8ee4b7dfda
2 changed files with 58 additions and 0 deletions
+31
View File
@@ -112,6 +112,37 @@ describe("core rules", () => {
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("skips a leading <svg> defs block when detecting the composition root", async () => {
// Regression: two independent reports of a leading <svg><defs><filter>...
// block (icon/gradient/filter plumbing referenced via url(#id) elsewhere)
// getting mistaken for the composition root, since findRootTag returned
// the first non-script/style/meta/link/title body child unconditionally.
// The <svg> here carries no composition markers, so it must be skipped in
// favor of the real root that follows it.
const html = `
<html><body>
<svg width="0" height="0" style="position:absolute">
<defs><filter id="glow"><feGaussianBlur stdDeviation="4" /></filter></defs>
</svg>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = window.__timelines || {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("still treats an <svg> as the root when it carries composition markers itself", async () => {
const html = `
<html><body>
<svg id="root" data-composition-id="c1" data-width="1920" data-height="1080"></svg>
<script>window.__timelines = window.__timelines || {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("reports error when timeline registry is missing", async () => {
const html = `
<html><body>
+27
View File
@@ -100,6 +100,7 @@ export function findHtmlTag(source: string): OpenTag | null {
};
}
// fallow-ignore-next-line complexity
export function findRootTag(source: string): OpenTag | null {
const bodyOpenMatch = /<body\b([^>]*)>/i.exec(source);
const bodyCloseMatch = /<\/body>/i.exec(source);
@@ -123,8 +124,34 @@ export function findRootTag(source: string): OpenTag | null {
: source.length;
const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source;
const bodyTags = extractOpenTags(bodyContent);
// Set when a leading <svg> defs block is skipped (see below) — extractOpenTags
// is a flat, nesting-unaware scan, so without this the very next tag it
// returns is the svg's own nested child (<defs>, <filter>, ...), not the
// sibling that follows the closed </svg>.
let skipBefore = -1;
for (const tag of bodyTags) {
if (tag.index < skipBefore) continue;
if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
// A leading <svg> block (icon/gradient/filter <defs>, referenced by url(#id)
// from elsewhere in the document) is shared visual plumbing, not the
// composition root — two independent reports of this being mistaken for
// the root, manufacturing root_missing_composition_id/root_missing_dimensions
// on an otherwise-correct composition. Only skip it when it carries none of
// the composition markers itself, so an intentionally SVG-rooted composition
// (data-composition-id/data-width/data-height directly on the <svg>) is
// still eligible as the root.
if (
tag.name === "svg" &&
!readAttr(tag.raw, "data-composition-id") &&
!readAttr(tag.raw, "data-width") &&
!readAttr(tag.raw, "data-height")
) {
const closeMatch = /<\/svg\s*>/i.exec(bodyContent.slice(tag.index));
// No closing tag found (malformed HTML) — skip everything rather than
// risk returning one of the svg's own children as the root.
skipBefore = closeMatch ? tag.index + closeMatch.index + closeMatch[0].length : Infinity;
continue;
}
return { ...tag, index: tag.index + bodyStart };
}
return null;