fix: bound invalid render durations

This commit is contained in:
James
2026-07-21 03:05:53 +00:00
parent 911b332bb2
commit 344d9c0a87
13 changed files with 193 additions and 30 deletions
+39
View File
@@ -862,6 +862,7 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("repeat: -1");
expect(finding?.fixHint).toContain("Math.max(0, Math.floor");
});
it("does not error on finite repeat values", async () => {
@@ -881,6 +882,44 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("warns when a computed finite repeat can fall through to GSAP's -1 sentinel", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
const duration = 0.5;
const cycleDuration = 1;
const tl = gsap.timeline({ paused: true, repeat: Math.floor(duration / cycleDuration) - 1 });
window.__timelines = { main: tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_repeat_floor_unclamped");
expect(finding?.severity).toBe("warning");
expect(finding?.fixHint).toContain("Math.max(0, Math.floor");
});
it("accepts a clamped computed finite repeat", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
const duration = 0.5;
const cycleDuration = 1;
const tl = gsap.timeline({
paused: true,
repeat: Math.max(0, Math.floor(duration / cycleDuration) - 1),
});
window.__timelines = { main: tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_repeat_floor_unclamped");
expect(finding).toBeUndefined();
});
it("does not error on repeat: -1 inside JavaScript comments", async () => {
const html = `
<html><body>
+31 -4
View File
@@ -1481,10 +1481,10 @@ export const gsapRules: LintRule<LintContext>[] = [
message:
"GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
"capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
"from the composition duration: `repeat: Math.max(0, Math.floor(duration / cycleDuration) - 1)`.",
fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.max(0, Math.floor(totalDuration / singleCycleDuration) - 1)`. " +
"Use Math.floor (not Math.ceil) so the animation fits, and clamp at zero so a short composition cannot evaluate to -1.",
snippet: truncateSnippet(snippet),
});
}
@@ -1510,7 +1510,7 @@ export const gsapRules: LintRule<LintContext>[] = [
"For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
fixHint:
"Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
"`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
"`repeat: Math.max(0, Math.floor(totalDuration / cycleDuration) - 1)`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
@@ -1518,6 +1518,33 @@ export const gsapRules: LintRule<LintContext>[] = [
return findings;
},
// gsap_repeat_floor_unclamped
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
// A direct floor-minus-one expression becomes GSAP's infinite -1 sentinel when
// the visible duration is shorter than one full cycle. Math.max-wrapped forms
// intentionally do not match because `repeat:` is followed by Math.max, not Math.floor.
const pattern = /repeat\s*:\s*Math\.floor\s*\([^)]+\)\s*-\s*1/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: false,
contextBefore: 40,
contextAfter: 40,
})) {
findings.push({
code: "gsap_repeat_floor_unclamped",
severity: "warning",
message:
"GSAP repeat calculation can evaluate to -1 when the composition is shorter than one cycle, " +
"which GSAP interprets as an infinite repeat.",
fixHint:
"Clamp the finite repeat count at zero: " +
"`repeat: Math.max(0, Math.floor(totalDuration / cycleDuration) - 1)`.",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
// scene_layer_missing_visibility_kill
({ scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];