Rename + completa la utilidad de 013ef5afc: - `DataUrlBlobUrls` → `DataUrlBlobCache` (nombre torpe: doble «Url» + plural); fichero data-url-blobs.* → data-url-blob-cache.*. - Añade `prune(keep)`: revoca + descarta los object URLs cuyo `blob:` URL ya no está en `keep` (el set de `<img>` montados), liberando el blob de una imagen borrada sin esperar a `dispose()`. Antes solo se revocaba todo al desmontar (leak acotado a la sesión). - palabras-image-blobs: tras el swap colecta los `img[src^="blob:"]` montados y poda. Docs (imagenes-base64 / handoff) actualizados. Verificado: 6 tests adom (incl. prune revoca-solo-no-keep) + check 0 errores; Playwright: el tecleo NO se regresa por la query de poda (9.2ms). La lógica de poda queda por unit test; el borrado end-to-end no se pudo conducir fiable en el harness (mismo flake del menú/panel de imagen), cableado trivial sobre el effect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
f9889b0b18
commit
719575bae9
@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DataUrlBlobCache } from './data-url-blob-cache';
|
||||
|
||||
// node has `atob` / `Blob` but not the DOM-only `URL.createObjectURL`; stub it.
|
||||
describe('DataUrlBlobCache', () => {
|
||||
let created: Blob[];
|
||||
let revoked: string[];
|
||||
let n: number;
|
||||
const origCreate = URL.createObjectURL;
|
||||
const origRevoke = URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
created = [];
|
||||
revoked = [];
|
||||
n = 0;
|
||||
URL.createObjectURL = ((blob: Blob) => {
|
||||
created.push(blob);
|
||||
return `blob:test/${++n}`;
|
||||
}) as typeof URL.createObjectURL;
|
||||
URL.revokeObjectURL = ((url: string) => {
|
||||
revoked.push(url);
|
||||
}) as typeof URL.revokeObjectURL;
|
||||
});
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = origCreate;
|
||||
URL.revokeObjectURL = origRevoke;
|
||||
});
|
||||
|
||||
it('resolves a base64 data-URL to a blob URL, decoding the payload + mime', async () => {
|
||||
const cache = new DataUrlBlobCache();
|
||||
const url = cache.resolve('data:text/plain;base64,SGk='); // "Hi"
|
||||
expect(url).toBe('blob:test/1');
|
||||
expect(created).toHaveLength(1);
|
||||
expect(await created[0].text()).toBe('Hi');
|
||||
expect(created[0].type).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('caches by data-URL identity — one object URL per source', () => {
|
||||
const cache = new DataUrlBlobCache();
|
||||
const a = cache.resolve('data:text/plain;base64,SGk=');
|
||||
const b = cache.resolve('data:text/plain;base64,SGk=');
|
||||
expect(a).toBe(b);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes non-data URLs through unchanged (real + existing blob URLs)', () => {
|
||||
const cache = new DataUrlBlobCache();
|
||||
expect(cache.resolve('https://x/y.png')).toBe('https://x/y.png');
|
||||
expect(cache.resolve('blob:already')).toBe('blob:already');
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves a malformed data-URL as-is', () => {
|
||||
const cache = new DataUrlBlobCache();
|
||||
expect(cache.resolve('data:no-comma')).toBe('data:no-comma');
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('prune() revokes only the object URLs whose blob is NOT in the keep set', () => {
|
||||
const cache = new DataUrlBlobCache();
|
||||
const a = cache.resolve('data:text/plain;base64,SGk='); // blob:test/1
|
||||
const b = cache.resolve('data:text/plain;base64,Qnll'); // blob:test/2 ("Bye")
|
||||
cache.prune(new Set([a])); // keep A, drop B
|
||||
expect(revoked).toEqual([b]);
|
||||
// A stays cached (re-resolve reuses it, no new object URL); B was pruned
|
||||
// (re-resolve mints a fresh one).
|
||||
expect(cache.resolve('data:text/plain;base64,SGk=')).toBe(a);
|
||||
expect(created).toHaveLength(2);
|
||||
cache.resolve('data:text/plain;base64,Qnll');
|
||||
expect(created).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('dispose() revokes every object URL it created', () => {
|
||||
const cache = new DataUrlBlobCache();
|
||||
cache.resolve('data:text/plain;base64,SGk=');
|
||||
cache.resolve('data:text/plain;base64,Qnll');
|
||||
cache.dispose();
|
||||
expect(revoked).toEqual(['blob:test/1', 'blob:test/2']);
|
||||
});
|
||||
});
|
||||
@ -1,66 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DataUrlBlobUrls } from './data-url-blobs';
|
||||
|
||||
// node has `atob` / `Blob` but not the DOM-only `URL.createObjectURL`; stub it.
|
||||
describe('DataUrlBlobUrls', () => {
|
||||
let created: Blob[];
|
||||
let revoked: string[];
|
||||
let n: number;
|
||||
const origCreate = URL.createObjectURL;
|
||||
const origRevoke = URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
created = [];
|
||||
revoked = [];
|
||||
n = 0;
|
||||
URL.createObjectURL = ((blob: Blob) => {
|
||||
created.push(blob);
|
||||
return `blob:test/${++n}`;
|
||||
}) as typeof URL.createObjectURL;
|
||||
URL.revokeObjectURL = ((url: string) => {
|
||||
revoked.push(url);
|
||||
}) as typeof URL.revokeObjectURL;
|
||||
});
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = origCreate;
|
||||
URL.revokeObjectURL = origRevoke;
|
||||
});
|
||||
|
||||
it('resolves a base64 data-URL to a blob URL, decoding the payload + mime', async () => {
|
||||
const store = new DataUrlBlobUrls();
|
||||
const url = store.resolve('data:text/plain;base64,SGk='); // "Hi"
|
||||
expect(url).toBe('blob:test/1');
|
||||
expect(created).toHaveLength(1);
|
||||
expect(await created[0].text()).toBe('Hi');
|
||||
expect(created[0].type).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('caches by data-URL identity — one object URL per source', () => {
|
||||
const store = new DataUrlBlobUrls();
|
||||
const a = store.resolve('data:text/plain;base64,SGk=');
|
||||
const b = store.resolve('data:text/plain;base64,SGk=');
|
||||
expect(a).toBe(b);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes non-data URLs through unchanged (real + existing blob URLs)', () => {
|
||||
const store = new DataUrlBlobUrls();
|
||||
expect(store.resolve('https://x/y.png')).toBe('https://x/y.png');
|
||||
expect(store.resolve('blob:already')).toBe('blob:already');
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves a malformed data-URL as-is', () => {
|
||||
const store = new DataUrlBlobUrls();
|
||||
expect(store.resolve('data:no-comma')).toBe('data:no-comma');
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('dispose() revokes every object URL it created', () => {
|
||||
const store = new DataUrlBlobUrls();
|
||||
store.resolve('data:text/plain;base64,SGk=');
|
||||
store.resolve('data:text/plain;base64,Qnll'); // "Bye"
|
||||
store.dispose();
|
||||
expect(revoked).toEqual(['blob:test/1', 'blob:test/2']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue