Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const routes: RouteRecordRaw[] = [
path: "/bottom-jump-test",
component: () => import("./pages/PageBottomJumpTest.vue"),
},
{
path: "/memory-test",
component: () => import("./pages/PageMemoryTest.vue"),
},
{
path: "/:catchAll(.*)",
redirect: "/",
Expand Down
65 changes: 65 additions & 0 deletions docs/src/pages/PageMemoryTest.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref } from "vue"
import autoAnimate, { type AnimationController } from "../../../src"

const state = ref<"na" | "retained" | "collected">("na")
let targetRef: WeakRef<HTMLElement> | undefined

function initFormkitAnimationTargets() {
const animationControllers: AnimationController[] = []

for (let i = 0; i < 20; ++i) {
const elm = document.createElement("div")
elm.dataset.autoAnimate = ""
elm.append(document.createElement("div"))
document.body.append(elm)

document
.querySelectorAll<HTMLElement>("[data-auto-animate]")
.forEach((animationTarget) => {
animationControllers.push(autoAnimate(animationTarget))
})
}

const targetCollection = document.querySelectorAll<HTMLElement>(
"[data-auto-animate]",
)
const firstTarget = targetCollection.item(0)
targetRef = new WeakRef(firstTarget)

animationControllers.forEach((controller) => controller?.destroy?.())
targetCollection.forEach((target) => target.remove())
state.value = "retained"
}

function removeImmediatelyDestroyedElement() {
const elm = document.createElement("div")
elm.append(document.createElement("div"))
document.body.append(elm)

autoAnimate(elm)?.destroy?.()

elm.remove()
targetRef = new WeakRef(elm)
state.value = "retained"
}

function checkTarget() {
state.value = targetRef?.deref() ? "retained" : "collected"
}
</script>
<template>
<div class="test-container">
<p>
This fixture emulates the behaviour of @formkit/addon (specifically
<code>createAutoAnimatePlugin()</code>), where
<code>data-auto-animate</code> is selected for repeated initializations.
</p>
<button @click="initFormkitAnimationTargets">Init like FormKit</button>
<button @click="removeImmediatelyDestroyedElement">
Remove immediately destroyed element
</button>
<button @click="checkTarget">Check target</button>
<output aria-live="polite">{{ state }}</output>
</div>
</template>
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"@nuxt/kit": "^3.12.4",
"@nuxt/module-builder": "^0.8.3",
"@nuxt/schema": "^3.12.4",
"@playwright/test": "^1.47.2",
"@playwright/test": "^1.63.0",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"@types/node": "^20.14.15",
Expand Down
42 changes: 16 additions & 26 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 53 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,18 @@ const intersections = new WeakMap<Element, IntersectionObserver>()
* A map of existing mutation observers used to track element movements.
*/
const mutationObservers = new WeakMap<Element, MutationObserver>()
/**
* The active animation controller for each parent.
*/
const animationControllers = new WeakMap<Element, AnimationController>()
/**
* Intervals for automatically checking the position of elements occasionally.
*/
const intervals = new WeakMap<Element, NodeJS.Timeout>()
/**
* Pending timers that start position polling.
*/
const pollTimers = new WeakMap<Element, NodeJS.Timeout>()
/**
* The configuration options for each group of elements.
*/
Expand Down Expand Up @@ -222,25 +230,43 @@ function updateAllPos() {
}

/**
* Its possible for a quick scroll or other fast events to get past the
* intersection observer, so occasionally we need want "cold-poll" for the
* latests and greatest position. We try to do this in the most non-disruptive
* fashion possible. First we only do this ever couple seconds, staggard by a
* random offset.
* It's possible for a quick scroll or other fast events to get past the
* intersection observer, so occasionally we need to "cold-poll" for the
* latest and greatest position. We try to do this in the most non-disruptive
* fashion possible. First, we only do this every couple of seconds, staggered
* by a random offset. Second, we keep track of the timers and intervals so we
* can clear them when the element is destroyed.
* @param el - Element
*/
function poll(el: Element) {
setTimeout(
() => {
intervals.set(
el,
setInterval(() => lowPriority(updatePos.bind(null, el)), 2000),
)
},
Math.round(2000 * Math.random()),
if (pollTimers.has(el) || intervals.has(el)) return
pollTimers.set(el,
setTimeout(
() => {
pollTimers.delete(el)
intervals.set(
el,
setInterval(() => lowPriority(updatePos.bind(null, el)), 2000),
)
},
Math.round(2000 * Math.random()),
),
)
}

/**
* Cleanup function for timers/intervals.
*/
function stopPolling(el: Element) {
const pollTimer = pollTimers.get(el)
if (pollTimer) clearTimeout(pollTimer)
pollTimers.delete(el)

const interval = intervals.get(el)
if (interval) clearInterval(interval)
intervals.delete(el)
}

/**
* Perform some operation that is non critical at some point.
* @param callback
Expand Down Expand Up @@ -644,6 +670,9 @@ function cleanUp(el: Element, styles?: Partial<CSSStyleDeclaration>) {
siblings.delete(el)
animations.delete(el)
intersections.get(el)?.disconnect()
intersections.delete(el)
resize?.unobserve(el)
stopPolling(el)
setTimeout(() => {
if (DEL in el) delete (el as any)[DEL]
Object.defineProperty(el, NEW, { value: true, configurable: true })
Expand Down Expand Up @@ -878,12 +907,15 @@ export interface AutoAnimationPlugin {
* immediate children. Specifically it adds effects for adding, moving, and
* removing DOM elements.
* @param el - A parent element to add animations to.
* @param options - An optional object of options.
* @param config - An optional object of options.
*/
export default function autoAnimate(
el: HTMLElement,
config: Partial<AutoAnimateOptions> | AutoAnimationPlugin = {},
): AnimationController {
// Destroy the old controller.
animationControllers.get(el)?.destroy?.()

if (supportedBrowser && resize) {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
const isDisabledDueToReduceMotion =
Expand Down Expand Up @@ -928,13 +960,15 @@ export default function autoAnimate(
const d = debounces.get(node)
if (d) clearTimeout(d)
debounces.delete(node)
const i = intervals.get(node)
if (i) clearInterval(i)
intervals.delete(node)
stopPolling(node)
})
},
isEnabled: () => enabled.has(el),
destroy: () => {
// Don't destroy the new controller.
if (animationControllers.get(el) !== controller) return

animationControllers.delete(el)
enabled.delete(el)
parents.delete(el)
options.delete(el)
Expand All @@ -955,9 +989,7 @@ export default function autoAnimate(
io?.disconnect()
intersections.delete(node)
// clear intervals and debounces
const i = intervals.get(node)
if (i) clearInterval(i)
intervals.delete(node)
stopPolling(node)
const d = debounces.get(node)
if (d) clearTimeout(d)
debounces.delete(node)
Expand All @@ -967,6 +999,7 @@ export default function autoAnimate(
})
},
})
animationControllers.set(el, controller)
return controller
}

Expand Down
39 changes: 39 additions & 0 deletions tests/e2e/memory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { approximateMemoryUsage, forceGC } from './utils'
// It is expected to fail currently due to known leaks.
test('memory does not grow unbounded after repeated add/remove', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Coarse memory check limited to Chromium')
test.setTimeout(60_000)
await page.goto('/lists')

// Warm-up interactions
Expand Down Expand Up @@ -40,4 +41,42 @@ test('memory does not grow unbounded after repeated add/remove', async ({ page,
expect(growth).toBeLessThan(threshold)
})

test.describe('createAutoAnimatePlugin-like memory growth', () => {
test.beforeEach(async ({ page }) =>
await page.goto('/memory-test')
)

test('releases targets repeatedly initialized like createAutoAnimatePlugin', async ({
page,
browserName,
context,
}) => {
test.skip(browserName !== 'chromium', 'GC check limited to Chromium')

await page.getByRole('button', { name: 'Init like FormKit' }).click()
await page.waitForTimeout(2000)

const session = await context.newCDPSession(page)
await session.send('HeapProfiler.collectGarbage')
await page.getByRole('button', { name: 'Check target' }).click()

await expect(page.getByRole('status')).toHaveText('collected')
})

test('releases target destroyed before polling starts', async ({
page,
browserName,
context,
}) => {
test.skip(browserName !== 'chromium', 'GC check limited to Chromium')

await page.getByRole('button', { name: 'Remove immediately destroyed element' }).click()
await page.waitForTimeout(2000)

const session = await context.newCDPSession(page)
await session.send('HeapProfiler.collectGarbage')
await page.getByRole('button', { name: 'Check target' }).click()

await expect(page.getByRole('status')).toHaveText('collected')
})
})
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"compilerOptions": {
"experimentalDecorators": true,
"module": "esnext",
"target": "es2019",
"lib": ["es2019", "dom"],
"target": "es2021",
"lib": ["es2021", "dom"],
"strict": true,
"allowJs": false,
"moduleResolution": "node",
Expand Down