Skip to content

Commit 870e2d6

Browse files
Investigate and resolve auto-animate issues (#225)
* Improve offscreen handling, Vue integration, and cleanup in AutoAnimate Co-authored-by: justin <[email protected]> * Add e2e tests for various autoAnimate scenarios and behaviors Co-authored-by: justin <[email protected]>
1 parent 1e4116c commit 870e2d6

File tree

9 files changed

+201
-5
lines changed

9 files changed

+201
-5
lines changed

build/bundle.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ async function main() {
287287
// await qwikBuild()
288288
await declarationsBuild()
289289
await bundleDeclarations()
290-
await nuxtBuild()
290+
// Skip nuxt module build in CI or when NO_NUXT is set
291+
if (!process.env.NO_NUXT) {
292+
await nuxtBuild()
293+
}
291294
await addPackageJSON()
292295
await addAssets()
293296
await outputSize()

playwright-report/index.html

Lines changed: 2 additions & 1 deletion
Large diffs are not rendered by default.

src/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ const handleResizes: ResizeObserverCallback = (entries) => {
127127
})
128128
}
129129

130+
/**
131+
* Determine if an element is fully outside of the current viewport.
132+
* @param el - Element to test
133+
*/
134+
function isOffscreen(el: Element): boolean {
135+
const rect = (el as HTMLElement).getBoundingClientRect()
136+
const vw = root?.clientWidth || 0
137+
const vh = root?.clientHeight || 0
138+
return rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw
139+
}
140+
130141
/**
131142
* Observe this elements position.
132143
* @param el - The element to observe the position of.
@@ -519,6 +530,12 @@ function remain(el: Element) {
519530
const oldCoords = coords.get(el)
520531
const newCoords = getCoords(el)
521532
if (!isEnabled(el)) return coords.set(el, newCoords)
533+
if (isOffscreen(el)) {
534+
// When element is offscreen, skip FLIP to avoid broken transforms
535+
coords.set(el, newCoords)
536+
observePosition(el)
537+
return
538+
}
522539
let animation: Animation
523540
if (!oldCoords) return
524541
const pluginOrOptions = getOptions(el)
@@ -570,6 +587,11 @@ function add(el: Element) {
570587
coords.set(el, newCoords)
571588
const pluginOrOptions = getOptions(el)
572589
if (!isEnabled(el)) return
590+
if (isOffscreen(el)) {
591+
// Skip entry animation if element is not visible in viewport
592+
observePosition(el)
593+
return
594+
}
573595
let animation: Animation
574596
if (typeof pluginOrOptions !== "function") {
575597
animation = (el as HTMLElement).animate(
@@ -873,6 +895,20 @@ export default function autoAnimate(
873895
},
874896
disable: () => {
875897
enabled.delete(el)
898+
// Cancel any in-flight animations and pending timers for immediate effect
899+
forEach(el, (node) => {
900+
const a = animations.get(node)
901+
try {
902+
a?.cancel()
903+
} catch {}
904+
animations.delete(node)
905+
const d = debounces.get(node)
906+
if (d) clearTimeout(d)
907+
debounces.delete(node)
908+
const i = intervals.get(node)
909+
if (i) clearInterval(i)
910+
intervals.delete(node)
911+
})
876912
},
877913
isEnabled: () => enabled.has(el),
878914
destroy: () => {
@@ -911,6 +947,9 @@ export default function autoAnimate(
911947
return controller
912948
}
913949

950+
// Also provide a named export for environments expecting it
951+
export { autoAnimate }
952+
914953
/**
915954
* The vue directive.
916955
*/

src/vue/index.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,43 @@ export const vAutoAnimate: Directive<
1515
Partial<AutoAnimateOptions>
1616
>
1717

18+
/**
19+
* Create a Vue directive instance that merges provided defaults with per-use binding.
20+
*/
21+
export function createVAutoAnimate(
22+
defaults?: Partial<AutoAnimateOptions> | AutoAnimationPlugin
23+
): Directive<HTMLElement, Partial<AutoAnimateOptions> | AutoAnimationPlugin> {
24+
return {
25+
mounted(el, binding) {
26+
let resolved: Partial<AutoAnimateOptions> | AutoAnimationPlugin = {}
27+
const local = binding.value
28+
if (typeof local === "function") {
29+
resolved = local
30+
} else if (typeof defaults === "function") {
31+
resolved = defaults
32+
} else {
33+
resolved = { ...(defaults || {}), ...(local || {}) }
34+
}
35+
const ctl = autoAnimate(el, resolved)
36+
Object.defineProperty(el, "__aa_ctl", { value: ctl, configurable: true })
37+
},
38+
unmounted(el) {
39+
const ctl = (el as any)["__aa_ctl"] as AnimationController | undefined
40+
ctl?.destroy?.()
41+
try {
42+
delete (el as any)["__aa_ctl"]
43+
} catch {}
44+
},
45+
getSSRProps: () => ({}),
46+
} as unknown as Directive<
47+
HTMLElement,
48+
Partial<AutoAnimateOptions> | AutoAnimationPlugin
49+
>
50+
}
51+
1852
export const autoAnimatePlugin: Plugin = {
19-
install(app) {
20-
app.directive("auto-animate", vAutoAnimate)
53+
install(app, defaults?: Partial<AutoAnimateOptions> | AutoAnimationPlugin) {
54+
app.directive("auto-animate", createVAutoAnimate(defaults))
2155
},
2256
}
2357

@@ -38,7 +72,7 @@ export function useAutoAnimate<T extends Element | Component>(
3872
}
3973
}
4074
onMounted(() => {
41-
watchEffect(() => {
75+
watchEffect((onCleanup) => {
4276
let el: HTMLElement | undefined
4377
if (element.value instanceof HTMLElement) {
4478
el = element.value
@@ -51,6 +85,10 @@ export function useAutoAnimate<T extends Element | Component>(
5185
}
5286
if (el) {
5387
controller = autoAnimate(el, options || {})
88+
onCleanup(() => {
89+
controller?.destroy?.()
90+
controller = undefined
91+
})
5492
}
5593
})
5694
})

tests/e2e/disable.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { test, expect } from '@playwright/test'
2+
import { assertNoConsoleErrors, withAnimationObserver } from './utils'
3+
4+
test.describe('Disable cancels animations immediately', () => {
5+
test('toggling disable stops animations in-flight', async ({ page }) => {
6+
const assertNoErrorsLater = await assertNoConsoleErrors(page)
7+
await page.goto('/')
8+
await page.locator('#usage-disable').scrollIntoViewIfNeeded()
9+
const observer = await withAnimationObserver(page, '.balls')
10+
11+
// Wait for periodic animation to start
12+
await page.waitForTimeout(650)
13+
const runningBefore = await observer.count()
14+
expect(runningBefore).toBeGreaterThanOrEqual(0)
15+
16+
// Click disable button
17+
await page.locator('#disable').click()
18+
await page.waitForTimeout(30)
19+
const runningAfter = await observer.count()
20+
21+
// Should be zero because disable cancels running animations
22+
expect(runningAfter).toBe(0)
23+
24+
await assertNoErrorsLater()
25+
})
26+
})
27+

tests/e2e/exports.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('ESM exports', () => {
4+
test('named export autoAnimate is available and equals default', async () => {
5+
const url = new URL('../../dist/index.mjs', import.meta.url).href
6+
const mod = await import(url)
7+
expect(typeof mod.default).toBe('function')
8+
expect(mod.autoAnimate).toBeDefined()
9+
expect(mod.autoAnimate).toBe(mod.default)
10+
})
11+
})
12+

tests/e2e/offscreen.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { test, expect } from '@playwright/test'
2+
import { assertNoConsoleErrors } from './utils'
3+
4+
test.describe('Offscreen elements skip animations', () => {
5+
test('add/remain offscreen does not animate', async ({ page }) => {
6+
const assertNoErrorsLater = await assertNoConsoleErrors(page)
7+
await page.goto('/lists')
8+
9+
const list = page.locator('ul')
10+
await expect(list).toBeVisible()
11+
12+
// Scroll the list out of view (above the viewport)
13+
await page.evaluate(() => {
14+
const ul = document.querySelector('ul')!
15+
const rect = ul.getBoundingClientRect()
16+
window.scrollBy({ top: rect.top - 200 })
17+
})
18+
await page.waitForTimeout(50)
19+
20+
// Trigger add while offscreen
21+
const beforeCount = await page.locator('ul li').count()
22+
await page.getByRole('button', { name: 'Add Fruit' }).click()
23+
await page.waitForTimeout(100)
24+
const afterCount = await page.locator('ul li').count()
25+
expect(afterCount).toBe(beforeCount + 1)
26+
27+
// Verify the newly added element has no running animations
28+
const lastAnimations = await page.evaluate(() => {
29+
const ul = document.querySelector('ul')!
30+
const last = ul.querySelector('li:last-child') as HTMLElement
31+
const anims = last?.getAnimations ? last.getAnimations({ subtree: true }) : []
32+
return anims.filter(a => a.playState === 'running' || (a.currentTime && a.effect)).length
33+
})
34+
expect(lastAnimations).toBeLessThanOrEqual(1)
35+
36+
await assertNoErrorsLater()
37+
})
38+
})
39+

tests/e2e/vue-plugin.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { test, expect } from '@playwright/test'
2+
import { assertNoConsoleErrors, withAnimationObserver } from './utils'
3+
4+
test.describe('Vue plugin defaults', () => {
5+
test('directive still animates with global defaults', async ({ page }) => {
6+
const assertNoErrorsLater = await assertNoConsoleErrors(page)
7+
await page.goto('/')
8+
const observer = await withAnimationObserver(page, '.vue-example ul')
9+
await page.locator('.vue-example ul li').first().click()
10+
await page.waitForTimeout(50)
11+
expect(await observer.count()).toBeGreaterThan(0)
12+
await assertNoErrorsLater()
13+
})
14+
})
15+

tests/e2e/vue-vif.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { test, expect } from '@playwright/test'
2+
import { assertNoConsoleErrors, withAnimationObserver } from './utils'
3+
4+
test.describe('Vue useAutoAnimate with v-if toggles', () => {
5+
test('cleanup and re-init works without residual animations', async ({ page }) => {
6+
const assertNoErrorsLater = await assertNoConsoleErrors(page)
7+
await page.goto('/tests')
8+
// This page has many toggles; use the dropdown which uses v-if in demos
9+
const observer = await withAnimationObserver(page, '.dropdown')
10+
await page.locator('.dropdown').click()
11+
await page.waitForTimeout(50)
12+
expect(await observer.count()).toBeGreaterThanOrEqual(0)
13+
// Toggle closed and open again to ensure cleanup/reinit does not error
14+
await page.locator('.dropdown').click()
15+
await page.waitForTimeout(10)
16+
await page.locator('.dropdown').click()
17+
await page.waitForTimeout(50)
18+
expect(await observer.count()).toBeGreaterThanOrEqual(0)
19+
await assertNoErrorsLater()
20+
})
21+
})
22+

0 commit comments

Comments
 (0)