This commit is contained in:
Aadi Jai Gupta
2026-08-31 01:07:05 +00:00
committed by GitHub
2 changed files with 48 additions and 9 deletions
+21
View File
@@ -457,6 +457,27 @@ describe("GSAP rules", () => {
expect(finding?.fixHint).toMatch(/xPercent/);
});
it("does NOT warn when GSAP animates a canvas layout subtree with a CSS transform", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<canvas id="preview" layoutsubtree></canvas>
</div>
<style>
#preview { transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#preview", { x: 20, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});
it("warns when tl.to animates scale on an element with CSS scale transform", async () => {
const html = `
<html><body>
+27 -9
View File
@@ -437,6 +437,27 @@ function tagSimpleSelectors(tag: OpenTag): string[] {
return selectors;
}
function tagHasAttr(tag: OpenTag, attr: string): boolean {
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|$)`, "i").test(tag.attrs);
}
function isLayoutSubtreeCanvas(tag: OpenTag): boolean {
return tag.name.toLowerCase() === "canvas" && tagHasAttr(tag, "layoutsubtree");
}
function tagMatchesSimpleSelector(tag: OpenTag, selector: string): boolean {
if (selector.startsWith("#")) return readAttr(tag.raw, "id") === selector.slice(1);
if (!selector.startsWith(".")) return false;
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
return classes.includes(selector.slice(1));
}
function selectorOnlyMatchesLayoutSubtreeCanvas(selector: string, tags: OpenTag[]): boolean {
const matches = tags.filter((tag) => tagMatchesSimpleSelector(tag, selector));
return matches.length > 0 && matches.every(isLayoutSubtreeCanvas);
}
function combinedTagStyle(tag: OpenTag, styleRules: Map<string, string>): string {
const styles = [readAttr(tag.raw, "style") || ""];
for (const selector of tagSimpleSelectors(tag)) {
@@ -1263,29 +1284,26 @@ export const gsapRules: LintRule<LintContext>[] = [
for (const [, selector, body] of style.content.matchAll(
/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g,
)) {
const normalizedSelector = (selector ?? "").trim();
if (selectorOnlyMatchesLayoutSubtreeCanvas(normalizedSelector, tags)) continue;
const tMatch = body?.match(/transform\s*:\s*([^;]+)/);
if (!tMatch || !tMatch[1]) continue;
const transformVal = tMatch[1].trim();
if (/translate/i.test(transformVal))
cssTranslateSelectors.set((selector ?? "").trim(), transformVal);
if (/scale/i.test(transformVal))
cssScaleSelectors.set((selector ?? "").trim(), transformVal);
cssTranslateSelectors.set(normalizedSelector, transformVal);
if (/scale/i.test(transformVal)) cssScaleSelectors.set(normalizedSelector, transformVal);
}
}
// Also check inline style="..." attributes on tags
for (const tag of tags) {
if (isLayoutSubtreeCanvas(tag)) continue;
const inlineStyle = readAttr(tag.raw, "style");
if (!inlineStyle) continue;
const tMatch = inlineStyle.match(/transform\s*:\s*([^;]+)/);
if (!tMatch || !tMatch[1]) continue;
const transformVal = tMatch[1].trim();
// Derive selectors from the tag's id and all classes
const id = readAttr(tag.raw, "id");
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
const selectors: string[] = [];
if (id) selectors.push(`#${id}`);
for (const cls of classes) selectors.push(`.${cls}`);
const selectors = tagSimpleSelectors(tag);
if (selectors.length === 0) continue;
for (const sel of selectors) {
if (/translate/i.test(transformVal) && !cssTranslateSelectors.has(sel))