refactor(core): CORE-03 add typed resource scopes and cleanup adapters

This commit is contained in:
Harvey Zhao committed 2026-09-11 00:21:32 +08:00
1 parent 362c616998
commit 3e38dd0d19
12 files changed
+1242 -6

No files matched your search

+44 -1
View File
@@ -48,7 +48,50 @@ installed versions. Candidate corrections have separate published-defect observa
in test/public-behavior.test.js and test/browser/emitter.spec.js. The source type fixture
checks event name/payload correlation, receiver types, custom channels and chain typing.
Emitter owns registrations only; instance teardown must remove the relevant callbacks,
not clear unrelated subscribers before destroy dispatch. Resource scopes follow in CORE-03.
not clear unrelated subscribers before destroy dispatch. Internal resource scopes are described below.
## Internal resource ownership
`src/lifecycle/scope.ts` owns synchronous cleanup registrations. `resources.ts` supplies
DOM listener, timeout, animation frame, request-controller and Blob URL adapters;
it depends only on the scope type and native browser APIs. Neither file is exported
from Artplayer or Artplayer.utils. CORE-03 introduces and tests this boundary;
CORE-04 will connect instance construction/destruction. Existing Events, timers and
requests have not yet been transferred, so their historical lifecycle findings remain open.
Create one ResourceScope for an owner and child() for a replaceable operation.
Call operation.dispose() on completion or replacement; it detaches from its parent,
while sibling operations stay live. Disposing an instance closes all remaining
children. add(cleanup) returns an idempotent release function. Registrations are removed
before invoking cleanup; disposal closes the scope first and unwinds in reverse order.
Late registrations are released immediately. Failures do not interrupt other cleanup:
dispose throws one ResourceCleanupError containing the original errors after unwinding.
Explicit release throws its own error immediately and is still consumed. Owners must
handle these errors at their lifecycle boundary without replacing a primary operation error.
Cleanup returns undefined, deliberately rejecting async callbacks in strict TS. An
asynchronous media/SDK shutdown needs a separately awaited owner protocol; do not cast
its Promise to a synchronous disposer. Emitter subscriptions use a per-owner callback
and add(() => { emitter.off(name, callback) }); do not remove unrelated subscriptions.
DOM adapters preserve function receivers and object handleEvent receivers, snapshot
capture, and release once/aborted subscriptions. They are internal registrations with
their own wrappers, not a replacement for the public Events.proxy identity contract.
Timer/RAF callbacks detach before invoking work and guard queued callbacks after
cancellation. They allocate nothing when closed. Request controllers are optional on
engines lacking AbortController: consumers still need closed/generation checks and
must own rejected fetch Promises. Release a completed request's operation scope to
avoid retaining its controller. objectURL only accepts a Blob it creates a URL for;
it never adopts a caller URL. A closed scope immediately revokes the newly created URL.
Run `yarn test:unit` and `yarn typecheck` for scope failures, reentry, ownership,
controlled timers, queued RAF and type rejection cases. `resource-scope.spec.js` runs
an explicitly identified es2015 internal-source fixture in all three Playwright
engines with native DOM, RAF, timers, fetch abort and URL access. It is not evidence
that the player has adopted scopes or that an installed tarball exposes this API.
Fresh fetches test URL revocation; decoded image caches are not a reliable revocation
oracle. Keep adapter tests and owner integration tests separate and extend both when
moving an existing resource into this boundary.
## Observable utility behavior
@@ -0,0 +1,85 @@
import type ResourceScope from './scope'
export function listen(scope: ResourceScope, target: EventTarget, name: string, callback: EventListenerOrEventListenerObject, options: AddEventListenerOptions = {}): () => void {
const { capture = false, once = false, signal } = options
if (scope.closed || signal?.aborted)
return () => {}
let release = () => {}
function listener(this: EventTarget, event: Event) {
if (once)
release()
if (scope.closed)
return
if (typeof callback === 'function')
callback.call(this, event)
else
callback.handleEvent(event)
}
target.addEventListener(name, listener, options)
release = scope.add(() => {
try {
target.removeEventListener(name, listener, capture)
}
finally {
signal?.removeEventListener('abort', release)
}
})
signal?.addEventListener('abort', release, { once: true })
return release
}
export function timeout(scope: ResourceScope, callback: () => void, delay: number): () => void {
if (scope.closed)
return () => {}
let pending = true
let release = () => {}
const timer = setTimeout(() => {
if (!pending)
return
release()
if (!scope.closed)
callback()
}, delay)
release = scope.add(() => {
pending = false
clearTimeout(timer)
})
return release
}
export function animationFrame(scope: ResourceScope, callback: FrameRequestCallback): () => void {
if (scope.closed)
return () => {}
let pending = true
let release = () => {}
const frame = requestAnimationFrame((time) => {
if (!pending)
return
release()
if (!scope.closed)
callback(time)
})
release = scope.add(() => {
pending = false
cancelAnimationFrame(frame)
})
return release
}
export function requestController(scope: ResourceScope): AbortController | undefined {
if (typeof AbortController === 'undefined')
return undefined
const controller = new AbortController()
scope.add(() => {
controller.abort()
})
return controller
}
export function objectURL(scope: ResourceScope, blob: Blob): string {
const url = URL.createObjectURL(blob)
scope.add(() => {
URL.revokeObjectURL(url)
})
return url
}
+65
View File
@@ -0,0 +1,65 @@
export type Cleanup = () => undefined
export class ResourceCleanupError extends Error {
constructor(readonly errors: unknown[]) {
super('Failed to release ArtPlayer resources')
this.name = 'ResourceCleanupError'
}
}
export default class ResourceScope {
private cleanups = new Set<() => void>()
private disposed = false
get closed(): boolean {
return this.disposed
}
add(cleanup: Cleanup): () => void {
let active = true
const release = () => {
if (!active)
return
active = false
this.cleanups.delete(release)
cleanup()
}
if (this.closed)
release()
else
this.cleanups.add(release)
return release
}
child(): ResourceScope {
const child = new ResourceScope()
const release = this.add(() => {
child.dispose()
})
child.add(() => {
release()
})
return child
}
dispose(): void {
if (this.closed)
return
this.disposed = true
const errors: unknown[] = []
// Unwind dependencies; release removes each registration before invoking it.
for (const release of Array.from(this.cleanups).reverse()) {
try {
release()
}
catch (error) {
if (error instanceof ResourceCleanupError)
errors.push(...error.errors)
else
errors.push(error)
}
}
if (errors.length)
throw new ResourceCleanupError(errors)
}
}