fix(lint): stop duplicate_composition_id firing on repeated sub-composition mounts (#3404)

sub-compositions.md documents mounting one sub-composition several times
with different data-variable-values to get per-instance variations. That
necessarily repeats data-composition-id, so the rule reported our own
documented pattern as an error and blocked check with no correct way to
satisfy it.

The rule bucketed every element by id with no awareness of
data-composition-src, so it could not tell a composition root from a
mount. The runtime already distinguishes them: repeated mounts are
rewritten to id__hf1, id__hf2 so they coexist, and render, validate,
inspect and snapshot all handle the pattern.

Skip mounts, the same way the rule already skips tags inside an inert
template. The collision it exists for is unaffected: its own fixHint
names a <meta> tag carrying the root's id, and that tag has no
data-composition-src.

Closes #3403
This commit is contained in:
Miguel Ángel
2026-08-21 18:59:21 -04:00
committed by GitHub
parent e1191edba6
commit 09a5ef7092
2 changed files with 54 additions and 0 deletions
@@ -315,6 +315,50 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
it("does not flag one sub-composition mounted repeatedly with per-instance values", async () => {
// Regression: sub-compositions.md "Per-Instance Variables" documents
// mounting one source several times with different data-variable-values.
// That necessarily repeats the id, and the runtime rewrites repeated
// mounts to `id__hf1`/`id__hf2` so they coexist. Flagging it made the
// documented pattern an error with no correct way to satisfy it.
const html = `<!DOCTYPE html>
<html>
<body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="6" data-no-timeline>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-variable-values='{"text":"one"}' data-start="0" data-duration="2"></div>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-variable-values='{"text":"two"}' data-start="2" data-duration="2"></div>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-variable-values='{"text":"three"}' data-start="4" data-duration="2"></div>
</div>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "duplicate_composition_id")).toBeUndefined();
});
it("still flags a real collision between a root and a non-mount element", async () => {
// The guard that keeps the exemption honest: skipping mounts must not
// blind the rule to the meta-versus-root collision it exists for, even
// when a legitimately repeated mount is present in the same file.
const html = `<!DOCTYPE html>
<html>
<head>
<meta name="composition-id" data-composition-id="main">
</head>
<body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="4" data-no-timeline>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-start="0" data-duration="2"></div>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-start="2" data-duration="2"></div>
</div>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "duplicate_composition_id");
expect(finding).toBeDefined();
expect(finding?.message).toContain("main");
});
it("ignores composition ids inside inert template content", async () => {
const html = `<!DOCTYPE html>
<html><body>
+10
View File
@@ -262,6 +262,16 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const tagsByCompositionId = new Map<string, string[]>();
for (const tag of tags) {
if (isInsideInertTemplate(tag, tags)) continue;
// A `data-composition-src` element is a MOUNT of a sub-composition, not a
// composition root, and sub-compositions.md documents mounting one source
// repeatedly with different `data-variable-values` to get per-instance
// variations. Those mounts legitimately share an id: the runtime rewrites
// repeated ones to `id__hf1`, `id__hf2` so they coexist. Counting them
// here made the documented pattern an error with no correct way to
// satisfy it. The collision this rule exists for -- a <meta> tag carrying
// the root's id, per its own fixHint -- is unaffected, since that tag has
// no `data-composition-src`.
if (readAttr(tag.raw, "data-composition-src")) continue;
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
if (!compositionId || compositionId.trim().length === 0) continue;