diff --git a/package.json b/package.json
index 93ae65e54..ebc449ee2 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
- "test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js",
+ "test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
diff --git a/packages/artplayer/ARCHITECTURE.md b/packages/artplayer/ARCHITECTURE.md
index 7a2f6ccb1..ce1492421 100644
--- a/packages/artplayer/ARCHITECTURE.md
+++ b/packages/artplayer/ARCHITECTURE.md
@@ -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
diff --git a/packages/artplayer/src/lifecycle/resources.ts b/packages/artplayer/src/lifecycle/resources.ts
new file mode 100644
index 000000000..5088f590b
--- /dev/null
+++ b/packages/artplayer/src/lifecycle/resources.ts
@@ -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
+}
diff --git a/packages/artplayer/src/lifecycle/scope.ts b/packages/artplayer/src/lifecycle/scope.ts
new file mode 100644
index 000000000..ca7b9caef
--- /dev/null
+++ b/packages/artplayer/src/lifecycle/scope.ts
@@ -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)
+ }
+}
diff --git a/refactor/baselines/resource-scope-validation.json b/refactor/baselines/resource-scope-validation.json
new file mode 100644
index 000000000..4b87cb7f2
--- /dev/null
+++ b/refactor/baselines/resource-scope-validation.json
@@ -0,0 +1,692 @@
+{
+ "task": "CORE-03",
+ "date": "2026-09-11",
+ "node": "v24.21.0",
+ "parentCommit": "362c6169987bb32c120e81ea267eeaa3bd8f63b8",
+ "source": {
+ "packages/artplayer/src/lifecycle/scope.ts": "e2188e8544b27cf8c7ac149e7146af628a2d6166c82b719a247adef5d85be5db",
+ "packages/artplayer/src/lifecycle/resources.ts": "2ff9cb71035ac5694c92a6b01f7454890bd652ba4b7774e8973500de7f7d204c",
+ "test/resource-scope.test.js": "f1b5bfb3fd863bee9493a26152aad6d110d47a8fc1f369da7e94c57d0538a123",
+ "test/types/resource-scope.ts": "a8c056914e8529c871891ed672030248d359a19f8571a5ac5a5f18952ea5def0",
+ "test/browser/resource-scope.spec.js": "0196bd9943c1ce2f6560f87156cd0de47606579c1a736bd46d4943179a121d3f"
+ },
+ "ci": {
+ "command": "yarn ci:check",
+ "passed": 94,
+ "unit": 66,
+ "engineering": 4,
+ "baseline": 24,
+ "sourceTypes": 15,
+ "logSha256": "e4982bd5055665e0bb40dc41c2c1ba319a935bff22ef6565ad102c4403ce09b6"
+ },
+ "build": {
+ "commands": [
+ "yarn build artplayer",
+ "yarn build:i18n"
+ ],
+ "generatedGitDifference": false,
+ "logs": [
+ {
+ "file": "core03-build.log",
+ "sha256": "21ffc4ba5064d496a5c5da4a3f5eaebe9bb6de240fdb44938f2858120e52d934"
+ },
+ {
+ "file": "core03-i18n.log",
+ "sha256": "4b5a3b6f03affadff34eb605405c16cad36f9fb9c7b61e54a446148140048490"
+ }
+ ]
+ },
+ "browser": {
+ "command": "yarn test:browser",
+ "candidateKind": "workspace-build",
+ "internalFixtureKind": "internal-source-fixture",
+ "stats": {
+ "startTime": "2026-09-10T16:19:36.559Z",
+ "duration": 28138.681,
+ "expected": 78,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "internal": [
+ {
+ "engine": "chromium",
+ "version": "153.0.8010.12",
+ "platform": "win32",
+ "fixture": {
+ "kind": "internal-source-fixture",
+ "target": "es2015",
+ "sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471"
+ },
+ "pageErrors": [],
+ "controlledConsole": [
+ "Failed to load resource: net::ERR_FILE_NOT_FOUND"
+ ],
+ "controlledFailedRequests": [
+ {
+ "url": "http://127.0.0.1:8084/test/pattern.mp4",
+ "resourceType": "fetch",
+ "failure": {
+ "errorText": "net::ERR_ABORTED"
+ }
+ },
+ {
+ "url": "blob:http://127.0.0.1:8084/bb671d68-fcc1-4374-ac45-06ea586b13fd",
+ "resourceType": "fetch",
+ "failure": {
+ "errorText": "net::ERR_FILE_NOT_FOUND"
+ }
+ }
+ ]
+ },
+ {
+ "engine": "firefox",
+ "version": "155.0",
+ "platform": "win32",
+ "fixture": {
+ "kind": "internal-source-fixture",
+ "target": "es2015",
+ "sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471"
+ },
+ "pageErrors": [],
+ "controlledConsole": [],
+ "controlledFailedRequests": []
+ },
+ {
+ "engine": "webkit",
+ "version": "26.6",
+ "platform": "win32",
+ "fixture": {
+ "kind": "internal-source-fixture",
+ "target": "es2015",
+ "sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471"
+ },
+ "pageErrors": [],
+ "controlledConsole": [
+ "Failed to load resource"
+ ],
+ "controlledFailedRequests": [
+ {
+ "url": "http://127.0.0.1:8084/test/pattern.mp4",
+ "resourceType": "fetch",
+ "failure": {
+ "errorText": "Load request cancelled"
+ }
+ },
+ {
+ "url": "blob:http://127.0.0.1:8084/884f53c0-917f-4730-be3f-32b8f0d5e2b8",
+ "resourceType": "fetch",
+ "failure": null
+ }
+ ]
+ }
+ ],
+ "cases": [
+ {
+ "file": "chapter.spec.js",
+ "title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate clears titles on blank chapters, zero hover and replacement",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate rejects NaN and can recover after an invalid update",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate ignores pending metadata initialization after immediate destroy",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate deduplicates stylesheet injection while the document is loading",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate clears titles on blank chapters, zero hover and replacement",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate rejects NaN and can recover after an invalid update",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate ignores pending metadata initialization after immediate destroy",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate deduplicates stylesheet injection while the document is loading",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate clears titles on blank chapters, zero hover and replacement",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate rejects NaN and can recover after an invalid update",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate ignores pending metadata initialization after immediate destroy",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "candidate deduplicates stylesheet injection while the document is loading",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "factory, registration, input normalization and safe title text",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "progress bars, real hover, title edges and boundary seek",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "sync update, Infinity, object identity, empty lists and invalid data",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "explicit source update and core-owned destruction",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "factory, registration, input normalization and safe title text",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "progress bars, real hover, title edges and boundary seek",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "sync update, Infinity, object identity, empty lists and invalid data",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "explicit source update and core-owned destruction",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "factory, registration, input normalization and safe title text",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "progress bars, real hover, title edges and boundary seek",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "sync update, Infinity, object identity, empty lists and invalid data",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "explicit source update and core-owned destruction",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "factory, registration, input normalization and safe title text",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "progress bars, real hover, title edges and boundary seek",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "sync update, Infinity, object identity, empty lists and invalid data",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "explicit source update and core-owned destruction",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "factory, registration, input normalization and safe title text",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "progress bars, real hover, title edges and boundary seek",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "sync update, Infinity, object identity, empty lists and invalid data",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "explicit source update and core-owned destruction",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "factory, registration, input normalization and safe title text",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "progress bars, real hover, title edges and boundary seek",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "sync update, Infinity, object identity, empty lists and invalid data",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "chapter.spec.js",
+ "title": "explicit source update and core-owned destruction",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "emitter.spec.js",
+ "title": "published: emitter event names and once across nested dispatch",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "emitter.spec.js",
+ "title": "candidate: emitter event names and once across nested dispatch",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "emitter.spec.js",
+ "title": "published: emitter event names and once across nested dispatch",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "emitter.spec.js",
+ "title": "candidate: emitter event names and once across nested dispatch",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "emitter.spec.js",
+ "title": "published: emitter event names and once across nested dispatch",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "emitter.spec.js",
+ "title": "candidate: emitter event names and once across nested dispatch",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "published: real playback, pause, seek, switch and destroy",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "candidate: real playback, pause, seek, switch and destroy",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "controlled HTTP media failure emits a real video error",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "Range, media identity and docs candidate routes",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "published: real playback, pause, seek, switch and destroy",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "candidate: real playback, pause, seek, switch and destroy",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "controlled HTTP media failure emits a real video error",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "Range, media identity and docs candidate routes",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "published: real playback, pause, seek, switch and destroy",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "candidate: real playback, pause, seek, switch and destroy",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "controlled HTTP media failure emits a real video error",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "playback.spec.js",
+ "title": "Range, media identity and docs candidate routes",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "resource-scope.spec.js",
+ "title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "resource-scope.spec.js",
+ "title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "resource-scope.spec.js",
+ "title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "published: subtitle object URL contains real VTT data",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "published: download starts and removes its temporary anchor",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate: subtitle object URL contains real VTT data",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate: download starts and removes its temporary anchor",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate download releases the anchor even when click fails",
+ "engine": "chromium",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "published: subtitle object URL contains real VTT data",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "published: download starts and removes its temporary anchor",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate: subtitle object URL contains real VTT data",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate: download starts and removes its temporary anchor",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate download releases the anchor even when click fails",
+ "engine": "firefox",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "published: subtitle object URL contains real VTT data",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "published: download starts and removes its temporary anchor",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate: subtitle object URL contains real VTT data",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate: download starts and removes its temporary anchor",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ },
+ {
+ "file": "utils.spec.js",
+ "title": "candidate download releases the anchor even when click fails",
+ "engine": "webkit",
+ "status": "passed",
+ "retry": 0
+ }
+ ]
+ },
+ "firstFailure": {
+ "stats": {
+ "startTime": "2026-09-10T16:15:26.658Z",
+ "duration": 33097.064,
+ "expected": 77,
+ "skipped": 0,
+ "unexpected": 1,
+ "flaky": 0
+ },
+ "engine": "firefox",
+ "assertion": "revokedRejected expected true, received false using an already decoded image",
+ "resolution": "Replace image decoding with fresh fetch before and after revocation; assert exact TypeError. Also abort an in-flight native fetch.",
+ "reportSha256": "8e2bbafe70e81957f37501901957598b3a88d4e5f272dcdaaedb56839f59118a"
+ },
+ "limits": [
+ "No player constructor/destroy integration until CORE-04",
+ "No package install rerun for this currently unreferenced internal module",
+ "No remote CI, device or SDK validation in this task"
+ ]
+}
diff --git a/refactor/changes/2026-09-11-CORE-03-resource-scope.md b/refactor/changes/2026-09-11-CORE-03-resource-scope.md
new file mode 100644
index 000000000..a24408bf1
--- /dev/null
+++ b/refactor/changes/2026-09-11-CORE-03-resource-scope.md
@@ -0,0 +1,47 @@
+# CORE-03:内部资源作用域
+
+## 结构与契约
+
+新增核心 lifecycle/scope.ts 与 resources.ts:前者管理同步清理、父子作用域及异常汇总;
+后者封装 DOM 监听、定时器、RAF、AbortController 与自有 Blob URL。没有新依赖,
+没有把内部能力导出到 Artplayer/utils 或要求旧插件依赖它。
+
+实例可持有长期 scope,切源、字幕请求等单次操作使用 child。子作用域结束会脱离父登记,
+不会释放兄弟操作。清理先关闭作用域,再逆序释放;释放登记在调用前移除,重复和重入安全。
+单项失败不中断其他清理,最后统一抛出 ResourceCleanupError 并保留原始错误。
+显式 release 的错误直接传播,该登记仍只消费一次;后续所有者需在生命周期边界处理。
+
+Cleanup 的返回类型为 undefined,严格拒绝 async/Promise 清理,避免同步 destroy 丢失异步失败。
+异步媒体/SDK 关闭将由对应包明确等待,不伪装成同步回收。Emitter 订阅用独立 callback 加入
+scope,并通过原 off API 释放;实例和操作各自只清理拥有的资源。
+
+监听保留函数/对象接收者、固定 capture、处理 once 重入和外部 abort;计时器/RAF 完成后脱离
+登记,取消后即使已排队的回调被调用也不会工作。缺少 AbortController 时返回 undefined,
+请求所有者必须继续防止过期回调。Blob URL 只释放本 helper 创建的 URL,不接管用户传入 URL。
+
+对应 API-04/05/07/10:这是内部边界建立,不改变现有公开行为。CORE-04 才接入播放器的
+构造/销毁;当前 Events、历史 timer/request 尚未迁移,BASE-LIFE-04/05 与 BASE-PERF-01
+继续开放,不能把本任务的 scope 测试写成播放器泄漏已修复。
+
+## 验证与维护
+
+- test:unit 加入 resource-scope.test.js,共九项资源测试:异常汇总、重入、late add、
+ 子作用域脱离、独立释放失败、订阅归属、受控 timer、已排队 RAF、DOM/abort/URL 与能力缺失。
+- test/types/resource-scope.ts 含四个必须拒绝的类型用例;全部严格源类型和消费者检查通过。
+- yarn ci:check:66 项单元、4 项工程、24 项冻结基线,共 94 项通过,无跳过。
+- 三浏览器 78 项通过;其中 3 项通过明确标注的 es2015 内部源码 fixture 验证原生 DOM、
+ RAF、timer、进行中的 fetch 取消和 Blob URL 撤销。其余为既有发布/工作区播放与插件回归。
+ 源码 fixture 不冒充安装 tarball;内部代码尚未由公开入口引用。
+- 负向控制只允许本 case 的精确 URL、fetch 类型及已观察的引擎错误;其他网络错误不被忽略。
+- yarn build artplayer 与 yarn build:i18n 通过,生成产物无 Git 差异。本次未重复执行安装打包;
+ 没有把上一任务的打包证据冒充本次新执行。
+
+首次浏览器运行 77 通过、Firefox 1 失败:撤销后的 URL 仍能复用已解码图片,说明图片缓存
+不是 URL 可用性的可靠判断。测试改为撤销前读出内容、撤销后 fresh fetch 精确 TypeError;
+同时验证进行中的 fetch 返回 AbortError。没有修改生产实现去迎合缓存,也没有重试或跳过。
+最初 Node RAF mock 误用了不存在的方法创建选项,已改为显式安装并恢复测试全局;生产不打补丁。
+执行指纹、首次失败摘要和最终状态见 [验证报告](../baselines/resource-scope-validation.json)。
+
+[核心架构](../../packages/artplayer/ARCHITECTURE.md) 维护所有权、错误协议、内部/公开边界与修改入口。
+下一项 CORE-04 需接入实例登记、处理构造失败/重复销毁并补真实播放器回归,再按各任务迁移
+具体操作资源。本任务独立提交;回退提交即可恢复文件、脚本和文档。无推送、版本变更或发布。
diff --git a/refactor/plan.md b/refactor/plan.md
index aa9902058..687f0311f 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
-状态:todo 176 / doing 0 / blocked 0 / done 38 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 175 / doing 0 / blocked 0 / done 39 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -106,7 +106,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| CORE-01 | artplayer
迁移纯工具并冻结导出 | ENG-03, ENG-04, ENG-06, PILOT-01 | utils/time/property/format/file/error/subtitle 的分批 TS 迁移 | Artplayer.utils 导出/参数/绑定保持,纯逻辑与类型测试通过 | M | done |
| CORE-02 | artplayer
迁移内部 Emitter | CORE-01, BASE-03 | 带类型事件映射的原 Emitter 实现 | ctx、once/off、重入/异常和链式返回保持 | H | done |
-| CORE-03 | artplayer
建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | todo |
+| CORE-03 | artplayer
建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | done |
| CORE-04 | artplayer
修复初始化与销毁生命周期 | CORE-03, ENG-05, BASE-07 | 构造失败回收、重复/重入 destroy、instances 修复 | 事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归;协调 BASE-PERF-01 销毁后定时器与回调归属 | H | todo |
| CORE-05 | artplayer
区分输入配置和内部配置 | CORE-01, ENG-04 | Option/ResolvedOption、默认值与校验整理 | 配置合并/错误时机不变,JS 输入仍运行时校验 | M | todo |
| CORE-06 | artplayer
建立内部媒体与宿主类型 | CORE-05 | 原生 video、canvas shim、UI host 类型 | 不更改公开 art.video 用法,不以 HTMLVideoElement 断言掩盖 shim | M | todo |
@@ -437,6 +437,7 @@
- PILOT-01: [记录](changes/2026-09-10-PILOT-01-chapter.md) [记录](baselines/pilot-validation.json)
- CORE-01: [记录](changes/2026-09-10-CORE-01-typed-utils.md) [记录](baselines/core-utils-validation.json)
- CORE-02: [记录](changes/2026-09-11-CORE-02-typed-emitter.md) [记录](baselines/emitter-validation.json)
+- CORE-03: [记录](changes/2026-09-11-CORE-03-resource-scope.md) [记录](baselines/resource-scope-validation.json)
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index f2e05f7a9..a0b23fa64 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,9 @@
# 进度与证据
+## 当前实施:CORE-03 内部资源作用域完成
+
+新增两个核心 TS 文件,明确同步清理、实例/操作作用域、异常汇总及原生资源适配。94 项 Node/基线与 78 项三浏览器检查通过;首次 Firefox 图片缓存误判已保留并改为 fresh fetch 验证。见 [交付](changes/2026-09-11-CORE-03-resource-scope.md)。当前 214 项,39 完成、175 待办;核心十个 TS 文件、chapter 五个。作用域尚未接入播放器,旧生命周期风险仍开放;下一项 CORE-04 直接改构造与销毁。
+
## 当前实施:CORE-02 Emitter TS 迁移完成
事件映射、payload/ctx 和链式类型已加入;九组普通契约保持,另修复原型事件名与嵌套快照重复 once。85 项 Node/基线、75 项三浏览器、三格式各 29 项 Emitter 检查通过;严格打包 27 项运行时/五组类型零诊断。见 [交付](changes/2026-09-11-CORE-02-typed-emitter.md)。当前 214 项,38 完成、176 待办。核心八个 TS 文件、chapter 五个 TS 文件;下一项 CORE-03 内部资源作用域。
diff --git a/refactor/tasks.json b/refactor/tasks.json
index e69674df8..c7a57314e 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -789,11 +789,14 @@
"dependsOn": [
"CORE-02"
],
- "status": "todo",
+ "status": "done",
"risk": "H",
"deliverable": "监听/订阅/定时器/RAF/请求/URL 的资源登记",
"acceptance": "实例与操作作用域分离,单项清理失败不阻断其他资源",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-11-CORE-03-resource-scope.md",
+ "baselines/resource-scope-validation.json"
+ ]
},
{
"id": "CORE-04",
diff --git a/test/browser/resource-scope.spec.js b/test/browser/resource-scope.spec.js
new file mode 100644
index 000000000..395e358b7
--- /dev/null
+++ b/test/browser/resource-scope.spec.js
@@ -0,0 +1,75 @@
+import { createHash } from 'node:crypto'
+import { build } from 'esbuild'
+import { expect, test } from './fixtures.js'
+
+// Explicit internal-source fixture, not evidence of an installed public API.
+const fixture = await build({
+ stdin: { contents: 'export { default as ResourceScope } from \'./packages/artplayer/src/lifecycle/scope\'; export * from \'./packages/artplayer/src/lifecycle/resources\'', resolveDir: process.cwd() },
+ bundle: true,
+ write: false,
+ format: 'iife',
+ globalName: 'ResourceFixture',
+ target: 'es2015',
+})
+
+test('internal resource scope: native DOM, RAF, timer, fetch abort and URL release', async ({ page, browserName, diagnostics }, testInfo) => {
+ await testInfo.attach('internal-source-fixture', { contentType: 'application/json', body: JSON.stringify({ kind: 'internal-source-fixture', target: 'es2015', sha256: createHash('sha256').update(fixture.outputFiles[0].text).digest('hex') }) })
+ await page.goto('/test/player.html?core=candidate&chapter=published')
+ await page.addScriptTag({ content: fixture.outputFiles[0].text })
+ const result = await page.evaluate(async () => {
+ const { ResourceScope, listen, timeout, animationFrame, requestController, objectURL } = window.ResourceFixture
+ const instance = new ResourceScope()
+ const operation = instance.child()
+ const button = document.createElement('button')
+ document.body.append(button)
+ const calls = []
+ const options = { capture: true }
+ listen(operation, button, 'click', function () {
+ calls.push(this === button ? 'click' : 'wrong-receiver')
+ }, options)
+ options.capture = false
+ button.click()
+ const text = await (await fetch(objectURL(operation, new Blob(['scoped'])))).text()
+ const revoked = objectURL(operation, new Blob(['revoked']))
+ const before = await (await fetch(revoked)).text()
+ const controller = requestController(operation)
+ await new Promise(resolve => animationFrame(operation, (time) => {
+ calls.push(typeof time)
+ resolve()
+ }))
+ await new Promise(resolve => timeout(operation, () => {
+ calls.push('timer')
+ resolve()
+ }, 0))
+ animationFrame(operation, () => calls.push('cancelled-frame'))
+ timeout(operation, () => calls.push('cancelled-timer'), 0)
+ const pendingRequest = fetch('/test/pattern.mp4', { signal: controller.signal }).then(() => 'resolved', error => error.name)
+ operation.dispose()
+ button.click()
+ // Await independent native scheduling turns so cancelled callbacks would be observable.
+ await new Promise(resolve => requestAnimationFrame(resolve))
+ await new Promise(resolve => setTimeout(resolve, 0))
+ const abortName = await pendingRequest
+ // Image caches may retain decoded pixels; a fresh fetch tests URL availability.
+ let revokedError
+ try {
+ await fetch(revoked)
+ }
+ catch (error) { revokedError = error.name }
+ button.remove()
+ const instanceOpen = !instance.closed
+ instance.dispose()
+ return { text, before, calls, aborted: controller.signal.aborted, abortName, revokedError, instanceOpen, revoked, requestURL: new URL('/test/pattern.mp4', location.href).href }
+ })
+ const { revoked, requestURL, ...behavior } = result
+ expect(behavior).toEqual({ text: 'scoped', before: 'revoked', calls: ['click', 'number', 'timer'], aborted: true, abortName: 'AbortError', revokedError: 'TypeError', instanceOpen: true })
+ const expectedFailures = {
+ chromium: [{ url: requestURL, resourceType: 'fetch', failure: { errorText: 'net::ERR_ABORTED' } }, { url: revoked, resourceType: 'fetch', failure: { errorText: 'net::ERR_FILE_NOT_FOUND' } }],
+ firefox: [],
+ webkit: [{ url: requestURL, resourceType: 'fetch', failure: { errorText: 'Load request cancelled' } }, { url: revoked, resourceType: 'fetch', failure: null }],
+ }
+ for (const failure of diagnostics.failedRequests)
+ expect(expectedFailures[browserName]).toContainEqual(failure)
+ const expectedConsole = { chromium: ['Failed to load resource: net::ERR_FILE_NOT_FOUND'], firefox: [], webkit: ['Failed to load resource'] }
+ expect(diagnostics.consoleErrors).toEqual(expectedConsole[browserName])
+})
diff --git a/test/resource-scope.test.js b/test/resource-scope.test.js
new file mode 100644
index 000000000..b198160d8
--- /dev/null
+++ b/test/resource-scope.test.js
@@ -0,0 +1,196 @@
+import assert from 'node:assert/strict'
+// eslint-disable-next-line test/no-import-node-test -- Control timers and native API failures.
+import { test } from 'node:test'
+import { loadModules } from './helpers/load.js'
+
+const source = 'packages/artplayer/src/lifecycle/'
+const { ResourceScope, ResourceCleanupError, timeout, animationFrame, listen, requestController, objectURL, Emitter } = await loadModules({
+ ResourceScope: `${source}scope`,
+ ResourceCleanupError: { file: `${source}scope`, name: 'ResourceCleanupError' },
+ ...Object.fromEntries(['timeout', 'animationFrame', 'listen', 'requestController', 'objectURL'].map(name => [name, { file: `${source}resources`, name }])),
+ Emitter: 'packages/artplayer/src/utils/emitter',
+})
+
+test('scope closes before cleanup, unwinds children and collects failures without retaining registrations', () => {
+ const scope = new ResourceScope()
+ const calls = []
+ const first = new Error('first')
+ const second = new Error('second')
+ scope.add(() => {
+ calls.push('oldest')
+ throw first
+ })
+ const child = scope.child()
+ child.add(() => {
+ calls.push('child')
+ throw second
+ })
+ scope.add(() => {
+ assert(scope.closed)
+ scope.dispose()
+ scope.add(() => {
+ calls.push('late')
+ })
+ calls.push('newest')
+ })
+ assert.throws(() => scope.dispose(), error => error instanceof ResourceCleanupError && assert.deepEqual(error.errors, [second, first]) === undefined)
+ assert.deepEqual(calls, ['late', 'newest', 'child', 'oldest'])
+ assert(child.closed)
+ scope.dispose()
+ assert.equal(calls.length, 4)
+})
+
+test('operation completion detaches from the live instance and siblings remain usable', () => {
+ const instance = new ResourceScope()
+ const operation = instance.child()
+ const sibling = instance.child()
+ let calls = 0
+ const release = operation.add(() => {
+ calls++
+ })
+ release()
+ release()
+ operation.dispose()
+ assert.equal(calls, 1)
+ assert.equal(instance.closed, false)
+ assert.equal(sibling.closed, false)
+ // A completed operation must no longer be invoked by its former owner.
+ operation.dispose = () => {
+ throw new Error('retained operation')
+ }
+ instance.dispose()
+ assert(sibling.closed)
+ assert(instance.child().closed)
+})
+
+test('failed explicit release is consumed and cannot prevent later scope cleanup', () => {
+ const scope = new ResourceScope()
+ const error = new Error('release')
+ let calls = 0
+ scope.add(() => {
+ calls++
+ })
+ const release = scope.add(() => {
+ throw error
+ })
+ assert.throws(release, value => value === error)
+ release()
+ scope.dispose()
+ assert.equal(calls, 1)
+})
+
+test('scope owns subscriptions without removing unrelated emitter listeners', () => {
+ const emitter = new Emitter()
+ const scope = new ResourceScope()
+ const calls = []
+ const owned = value => calls.push(value)
+ emitter.on('value', owned).on('value', () => calls.push('external'))
+ scope.add(() => {
+ emitter.off('value', owned)
+ })
+ emitter.emit('value', 1)
+ scope.dispose()
+ emitter.emit('value', 2)
+ assert.deepEqual(calls, [1, 'external', 'external'])
+})
+
+test('timers finish or cancel independently and reject work after disposal', (context) => {
+ context.mock.timers.enable({ apis: ['setTimeout'] })
+ const scope = new ResourceScope()
+ const calls = []
+ timeout(scope, () => calls.push('finished'), 5)
+ timeout(scope, () => calls.push('cancelled'), 10)()
+ timeout(scope, () => calls.push('disposed'), 20)
+ context.mock.timers.tick(5)
+ scope.dispose()
+ timeout(scope, () => calls.push('late'), 0)
+ context.mock.timers.tick(100)
+ assert.deepEqual(calls, ['finished'])
+})
+
+test('queued RAF is guarded after cancellation, timestamp is forwarded and completed frames detach', (context) => {
+ const callbacks = []
+ const cancelled = []
+ const descriptors = Object.getOwnPropertyDescriptors(globalThis)
+ globalThis.requestAnimationFrame = callback => callbacks.push(callback)
+ globalThis.cancelAnimationFrame = id => cancelled.push(id)
+ context.after(() => {
+ for (const key of ['requestAnimationFrame', 'cancelAnimationFrame']) {
+ if (descriptors[key])
+ Object.defineProperty(globalThis, key, descriptors[key])
+ else
+ delete globalThis[key]
+ }
+ })
+ const scope = new ResourceScope()
+ const calls = []
+ animationFrame(scope, time => calls.push(time))
+ callbacks[0](12)
+ animationFrame(scope, time => calls.push(time))()
+ callbacks[1](18)
+ animationFrame(scope, time => calls.push(time))
+ scope.dispose()
+ callbacks[2](24)
+ animationFrame(scope, () => calls.push('late'))
+ assert.deepEqual(calls, [12])
+ assert.deepEqual(cancelled, [1, 2, 3])
+ assert.equal(callbacks.length, 3)
+})
+
+test('DOM once reentry, receiver, object listener, external abort and captured options', () => {
+ const scope = new ResourceScope()
+ const target = new EventTarget()
+ let calls = 0
+ listen(scope, target, 'value', function () {
+ assert.equal(this, target)
+ calls++
+ target.dispatchEvent(new Event('value'))
+ }, { once: true })
+ target.dispatchEvent(new Event('value'))
+ assert.equal(calls, 1)
+ const controller = new AbortController()
+ const options = { signal: controller.signal, capture: false }
+ const listener = { handleEvent() {
+ assert.equal(this, listener)
+ calls++
+ } }
+ listen(scope, target, 'object', listener, options)
+ options.capture = true
+ target.dispatchEvent(new Event('object'))
+ controller.abort()
+ target.dispatchEvent(new Event('object'))
+ scope.dispose()
+ listen(scope, target, 'object', listener)
+ target.dispatchEvent(new Event('object'))
+ assert.equal(calls, 2)
+})
+
+test('request cancellation and URL ownership are scoped; caller URLs stay available', async () => {
+ const instance = new ResourceScope()
+ const operation = instance.child()
+ const controller = requestController(operation)
+ const url = objectURL(operation, new Blob(['owned']))
+ const external = URL.createObjectURL(new Blob(['external']))
+ try {
+ assert.equal(await (await fetch(url)).text(), 'owned')
+ operation.dispose()
+ assert(controller.signal.aborted)
+ await assert.rejects(fetch(url))
+ assert.equal(await (await fetch(external)).text(), 'external')
+ assert.equal(instance.closed, false)
+ assert(requestController(operation).signal.aborted)
+ }
+ finally {
+ URL.revokeObjectURL(external)
+ instance.dispose()
+ }
+})
+
+test('request controller capability absence does not prevent synchronous cleanup', (context) => {
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'AbortController')
+ Object.defineProperty(globalThis, 'AbortController', { configurable: true, value: undefined })
+ context.after(() => Object.defineProperty(globalThis, 'AbortController', descriptor))
+ const scope = new ResourceScope()
+ assert.equal(requestController(scope), undefined)
+ scope.dispose()
+})
diff --git a/test/types/resource-scope.ts b/test/types/resource-scope.ts
new file mode 100644
index 000000000..293e26e85
--- /dev/null
+++ b/test/types/resource-scope.ts
@@ -0,0 +1,25 @@
+import { animationFrame, listen, objectURL, requestController, timeout } from '../../packages/artplayer/src/lifecycle/resources'
+import ResourceScope from '../../packages/artplayer/src/lifecycle/scope'
+
+const instance = new ResourceScope()
+const operation = instance.child()
+operation.add(() => {
+ document.body.removeAttribute('data-test')
+})
+timeout(operation, () => {}, 10)
+animationFrame(operation, time => time.toFixed())
+listen(operation, document, 'click', { handleEvent(event) {
+ event.preventDefault()
+} })
+const url: string = objectURL(operation, new Blob())
+const signal: AbortSignal | undefined = requestController(operation)?.signal
+void [url, signal]
+// @ts-expect-error Cleanup is synchronous; async work needs a separately awaited owner.
+operation.add(async () => {})
+// @ts-expect-error A cleanup cannot return a timer handle.
+operation.add(() => 1)
+// @ts-expect-error Delays must be numeric.
+timeout(operation, () => {}, '10')
+// @ts-expect-error Object URLs require a Blob.
+objectURL(operation, 'existing-url')
+instance.dispose()