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

+1 -1
View File
@@ -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",
+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)
}
}
@@ -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"
]
}
@@ -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 需接入实例登记、处理构造失败/重复销毁并补真实播放器回归,再按各任务迁移
具体操作资源。本任务独立提交;回退提交即可恢复文件、脚本和文档。无推送、版本变更或发布。
+3 -2
View File
@@ -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<br>迁移纯工具并冻结导出 | ENG-03, ENG-04, ENG-06, PILOT-01 | utils/time/property/format/file/error/subtitle 的分批 TS 迁移 | Artplayer.utils 导出/参数/绑定保持,纯逻辑与类型测试通过 | M | done |
| CORE-02 | artplayer<br>迁移内部 Emitter | CORE-01, BASE-03 | 带类型事件映射的原 Emitter 实现 | ctx、once/off、重入/异常和链式返回保持 | H | done |
| CORE-03 | artplayer<br>建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | todo |
| CORE-03 | artplayer<br>建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | done |
| CORE-04 | artplayer<br>修复初始化与销毁生命周期 | 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<br>区分输入配置和内部配置 | CORE-01, ENG-04 | Option/ResolvedOption、默认值与校验整理 | 配置合并/错误时机不变,JS 输入仍运行时校验 | M | todo |
| CORE-06 | artplayer<br>建立内部媒体与宿主类型 | 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)
+4
View File
@@ -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 内部资源作用域。
+5 -2
View File
@@ -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",
+75
View File
@@ -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])
})
+196
View File
@@ -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()
})
+25
View File
@@ -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()