diff --git a/frontend-vue/src/shared/page-cleanup-registry.ts b/frontend-vue/src/shared/page-cleanup-registry.ts new file mode 100644 index 00000000..ff890d55 --- /dev/null +++ b/frontend-vue/src/shared/page-cleanup-registry.ts @@ -0,0 +1,116 @@ +/** + * 页面卸载清理注册表(Task 89)。 + * + * 统一管理页面级需在卸载时释放的资源: + * - 临时 URL:createObjectURL 产生的 blob URL 通过 revokeUrl 释放; + * - 请求:AbortController 在卸载时 abort(registerRequestController); + * - timer:dispose 时委托 hooks.clearTimers()(配合 categorized-timers + * 的 clearScope 全量清理)。 + * + * dispose 幂等:第二次及以后的调用不再重复清理;dispose 后注册被拒绝 + * 并计数。revokeUrl 抛错不中断其余条目的清理;maxTrackedUrls(默认不 + * 限)用于限制同一页面累计注册的 URL 数量,超过时只保留最近注册的。 + */ +export interface PageCleanupRegistryHooks { + /** 卸载时清空全部 timer */ + clearTimers: () => void + /** 卸载时释放单个 blob URL */ + revokeUrl: (url: string) => void +} + +export interface PageCleanupRegistryOptions { + /** 累计跟踪的 URL 上限(0 表示不限制),超过时只保留最近注册的 */ + maxTrackedUrls?: number +} + +export function createPageCleanupRegistry( + hooks: PageCleanupRegistryHooks, + options: PageCleanupRegistryOptions = {}, +) { + if (typeof hooks?.clearTimers !== 'function') { + throw new Error('clearTimers 必须是函数') + } + if (typeof hooks?.revokeUrl !== 'function') { + throw new Error('revokeUrl 必须是函数') + } + const maxTrackedUrls = options.maxTrackedUrls ?? 0 + const urls: string[] = [] + const controllers: AbortController[] = [] + let disposed = false + let disposeCount = 0 + let rejectedCount = 0 + + function registerObjectUrl(url: string): string { + if (disposed) { + rejectedCount += 1 + return url + } + if (!url) return url + urls.push(url) + if (maxTrackedUrls > 0 && urls.length > maxTrackedUrls) { + urls.splice(0, urls.length - maxTrackedUrls) + } + return url + } + + function registerRequestController(controller: AbortController) { + if (disposed) { + rejectedCount += 1 + return + } + controllers.push(controller) + } + + function dispose(): number { + if (disposed) { + return 0 + } + disposed = true + let released = 0 + for (const url of urls) { + try { + hooks.revokeUrl(url) + } catch { + /* 单个 URL 释放失败不中断其余清理 */ + } + released += 1 + } + urls.length = 0 + for (const controller of controllers) { + try { + controller.abort() + } catch { + /* abort 失败不中断其余清理 */ + } + released += 1 + } + controllers.length = 0 + hooks.clearTimers() + released += 1 + disposeCount += 1 + return released + } + + return { + get trackedUrlCount() { + return urls.length + }, + get requestCount() { + return controllers.length + }, + get disposed() { + return disposed + }, + get disposeCount() { + return disposeCount + }, + get rejectedCount() { + return rejectedCount + }, + registerObjectUrl, + registerRequestController, + dispose, + } +} + +export type PageCleanupRegistry = ReturnType diff --git a/frontend-vue/tests/page-cleanup-registry.test.ts b/frontend-vue/tests/page-cleanup-registry.test.ts new file mode 100644 index 00000000..e411ce7e --- /dev/null +++ b/frontend-vue/tests/page-cleanup-registry.test.ts @@ -0,0 +1,154 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createPageCleanupRegistry } from '../src/shared/page-cleanup-registry.ts' + +interface CleanupHooks { + clearTimers: () => void + revokeUrl: (url: string) => void +} + +function createHooks(): CleanupHooks & { cleared: string[] } { + const cleared: string[] = [] + return { + cleared, + clearTimers: () => cleared.push('timers'), + revokeUrl: (url: string) => cleared.push(`url:${url}`), + } +} + +test('test_task_089_cleanup_normal_default_path', () => { + const hooks = createHooks() + const registry = createPageCleanupRegistry(hooks) + const url = registry.registerObjectUrl('blob:http://x/1') + assert.equal(url, 'blob:http://x/1') + assert.equal(registry.trackedUrlCount, 1) + assert.equal(registry.disposeCount, 0) + const result = registry.dispose() + assert.equal(result, 2) + assert.equal(registry.disposed, true) + assert.equal(registry.trackedUrlCount, 0) + assert.deepEqual(hooks.cleared, ['url:blob:http://x/1', 'timers']) + assert.equal(registry.disposeCount, 1) +}) + +test('test_task_089_cleanup_normal_multiple_items', () => { + const hooks = createHooks() + const registry = createPageCleanupRegistry(hooks) + registry.registerObjectUrl('blob:http://x/1') + registry.registerObjectUrl('blob:http://x/2') + registry.registerObjectUrl('blob:http://x/3') + const aborted: string[] = [] + const makeController = (n: number) => + ({ abort: () => aborted.push(`abort:${n}`) }) as unknown as AbortController + registry.registerRequestController(makeController(1)) + registry.registerRequestController(makeController(2)) + assert.equal(registry.trackedUrlCount, 3) + assert.equal(registry.requestCount, 2) + const result = registry.dispose() + assert.equal(result, 6) + assert.equal(registry.trackedUrlCount, 0) + assert.equal(registry.requestCount, 0) + assert.deepEqual(aborted, ['abort:1', 'abort:2']) + assert.deepEqual( + hooks.cleared, + ['url:blob:http://x/1', 'url:blob:http://x/2', 'url:blob:http://x/3', 'timers'], + ) +}) + +test('test_task_089_cleanup_normal_repeated_operation_is_idempotent', () => { + const hooks = createHooks() + const registry = createPageCleanupRegistry(hooks) + const url = registry.registerObjectUrl('blob:http://x/1') + registry.dispose() + registry.dispose() + registry.dispose() + assert.equal(registry.disposed, true) + assert.equal(registry.trackedUrlCount, 0) + assert.equal(hooks.cleared.length, 2, '第二次及以后的 dispose 不再重复清理') + // dispose 后注册被拒绝 + registry.registerObjectUrl('blob:http://x/2') + assert.equal(registry.trackedUrlCount, 0) + assert.equal(registry.disposeCount, 1) + assert.equal(registry.rejectedCount, 1) + assert.equal(url, 'blob:http://x/1') +}) + +test('test_task_089_cleanup_boundary_empty_input', () => { + const hooks = createHooks() + const registry = createPageCleanupRegistry(hooks) + assert.equal(registry.trackedUrlCount, 0) + assert.equal(registry.requestCount, 0) + const result = registry.dispose() + assert.equal(result, 1, 'dispose 至少执行 clearTimers') + assert.deepEqual(hooks.cleared, ['timers']) + // 空字符串 URL 不注册 + registry.registerObjectUrl('') + assert.equal(registry.trackedUrlCount, 0) +}) + +test('test_task_089_cleanup_boundary_single_item', () => { + const hooks = createHooks() + const registry = createPageCleanupRegistry(hooks) + registry.registerObjectUrl('blob:http://x/single') + const result = registry.dispose() + assert.equal(result, 2) + assert.equal(hooks.cleared.length, 2) + assert.equal(hooks.cleared[0], 'url:blob:http://x/single') + assert.equal(hooks.cleared[1], 'timers') +}) + +test('test_task_089_cleanup_boundary_limit_and_overflow', () => { + const hooks = createHooks() + const registry = createPageCleanupRegistry(hooks, { maxTrackedUrls: 3 }) + for (let i = 1; i <= 5; i++) registry.registerObjectUrl(`blob:http://x/${i}`) + assert.equal(registry.trackedUrlCount, 3, '超过上限只保留最近 3 个') + const result = registry.dispose() + assert.equal(result, 4) + assert.ok(hooks.cleared.includes('url:blob:http://x/3')) + assert.ok(hooks.cleared.includes('url:blob:http://x/5')) + assert.ok(!hooks.cleared.includes('url:blob:http://x/1'), '被驱逐的 URL 不再由注册表 revoke') + assert.equal(hooks.cleared.length, 4) +}) + +test('test_task_089_cleanup_invalid_input_rejected', () => { + assert.throws(() => createPageCleanupRegistry({} as never), /clearTimers 必须是函数/) + assert.throws( + () => createPageCleanupRegistry({ clearTimers: () => {} } as never), + /revokeUrl 必须是函数/, + ) + const hooks = createHooks() + const registry = createPageCleanupRegistry(hooks, { maxTrackedUrls: 0 }) + assert.equal(registry.registerObjectUrl('blob:http://x/1'), 'blob:http://x/1', 'maxTrackedUrls 为 0 时不限上限') + assert.equal(registry.trackedUrlCount, 1) +}) + +test('test_task_089_cleanup_dependency_failure_releases_resources', () => { + let urlFailures = 0 + const hooks: CleanupHooks & { cleared: string[] } = { + cleared: [], + clearTimers: () => hooks.cleared.push('timers'), + revokeUrl: (url) => { + if (url === 'blob:http://x/poison') { + urlFailures += 1 + throw new Error('revoke down') + } + hooks.cleared.push(`url:${url}`) + }, + } + const registry = createPageCleanupRegistry(hooks) + registry.registerObjectUrl('blob:http://x/a') + registry.registerObjectUrl('blob:http://x/poison') + registry.registerObjectUrl('blob:http://x/b') + // revoke 抛错不中断清理,全部条目仍被尝试释放 + const result = registry.dispose() + assert.equal(result, 4) + assert.equal(urlFailures, 1) + assert.equal(registry.trackedUrlCount, 0) + assert.ok(hooks.cleared.includes('url:blob:http://x/a')) + assert.ok(hooks.cleared.includes('url:blob:http://x/b')) + assert.ok(hooks.cleared.includes('timers')) + // 错误可恢复:同一注册表 dispose 后状态一致(幂等) + registry.dispose() + assert.equal(registry.disposeCount, 1) + assert.equal(urlFailures, 1) +})