adom: renombra DataUrlBlobUrls → DataUrlBlobCache + poda de caché

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
dev 3 months ago
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']);
});
});

@ -6,14 +6,15 @@
* surface, drags on every keystroke. Swapping it for a `blob:` URL keeps the
* source (persistable base64) in the MODEL while the DOM carries only a short
* reference. Caches by data-URL identity so repeated resolves of the same
* source reuse one object URL; `dispose()` revokes them all.
* source reuse one object URL; `prune()` releases the ones no longer in use and
* `dispose()` revokes them all.
*
* Stateful (owns the object-URL lifecycle) → lives in `$adom`, not the pure
* `$libs/dom`. Object URLs are same-origin scoped, which holds for every
* in-page editor (including a same-origin iframe), so the ambient
* `URL` / `Blob` / `atob` are correct here.
*/
export class DataUrlBlobUrls {
export class DataUrlBlobCache {
readonly #cache = new Map<string, string>();
/** A `blob:` URL for a `data:` URL (cached); any other string passes through
@ -30,6 +31,18 @@ export class DataUrlBlobUrls {
return url;
}
/** Revoke + drop every cached object URL whose `blob:` URL is NOT in `keep`
* (the set still referenced by mounted nodes). Call after each render so a
* removed image releases its object URL promptly instead of lingering until
* `dispose()`. */
prune(keep: ReadonlySet<string>): void {
for (const [dataUrl, blobUrl] of this.#cache) {
if (keep.has(blobUrl)) continue;
URL.revokeObjectURL(blobUrl);
this.#cache.delete(dataUrl);
}
}
/** Revoke every object URL this cache created and clear it. */
dispose(): void {
for (const url of this.#cache.values()) URL.revokeObjectURL(url);

@ -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']);
});
});

@ -87,4 +87,4 @@ export type { ScrollStateOptions } from './scroll-state.svelte.js';
export { RovingFocusGroup } from './roving-focus-group.svelte.js';
export type { RovingFocusOrientation } from './roving-focus-group.svelte.js';
export { DataUrlBlobUrls } from './data-url-blobs.js';
export { DataUrlBlobCache } from './data-url-blob-cache.js';

@ -79,7 +79,7 @@
comparación DEEP del doc en `syncExternalState` (el sync del `bind:value`) que recorría el string base64
por tecla, porque el editor escribe `value` con la MISMA referencia del doc interno. **Fix 1
(`d96e4cde7`)**: check por REFERENCIA antes del deep-compare (20→12ms/char). **Fix 2 (`013ef5afc`, win
secundario)**: `$adom.DataUrlBlobUrls` (utilidad NUEVA reutilizable) + swap de `<img data:>`→`blob:` en
secundario)**: `$adom.DataUrlBlobCache` (utilidad NUEVA reutilizable — `resolve`/`prune`/`dispose`) + swap de `<img data:>`→`blob:` en
palabras (el modelo conserva el base64; 12→8ms/char). Ubicación de la utilidad VALORADA: NO en `<Image>`
(ningún consumidor pasa base64 → prematuro). Verificado: profiler + Playwright + 58 tests (provider +
adom util). **LECCIÓN**: para un atasco de perf, **perfilar ANTES de teorizar** — dos hipótesis

@ -75,7 +75,8 @@ REFERENCIA antes del deep-compare — el caso común (ediciones propias) en O(1)
`samePalabrasDocument` solo corre si la referencia difiere de verdad. Medido:
**20 → 12 ms/char**.
**Fix 2 — `013ef5afc`** (win secundario): `$adom.DataUrlBlobUrls` + swap del `src`
**Fix 2 — `013ef5afc`** (win secundario; renombrado a `DataUrlBlobCache` + poda
después): `$adom.DataUrlBlobCache` (caché con `resolve`/`prune`/`dispose`) + swap del `src`
de los `<img data:…>` por `blob:` URLs en palabras (el modelo conserva el base64).
El `src` de MB en el DOM SÍ añadía ~2-4ms. Medido: **12 → 8 ms/char**. Ubicación
valorada: la utilidad NO va en `<Image>` (ningún consumidor pasa base64 →

@ -13,7 +13,7 @@
* `img[src^="data:"]` query is a native tag+attr scan that returns empty during
* plain typing (everything already swapped).
*/
import { DataUrlBlobUrls } from '$adom';
import { DataUrlBlobCache } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/palabras';
let {
@ -24,14 +24,23 @@
content: HTMLElement;
} = $props();
const blobs = new DataUrlBlobUrls();
const blobs = new DataUrlBlobCache();
$effect(() => {
void api.blocks; // re-run after each render
// Swap freshly-rendered `data:` srcs for `blob:` URLs.
for (const img of content.querySelectorAll<HTMLImageElement>('img[src^="data:"]')) {
const dataUrl = img.getAttribute('src');
if (dataUrl) img.src = blobs.resolve(dataUrl);
}
// Release the object URLs whose image is no longer mounted (deleted /
// replaced) — keep only those still referenced by a live `<img>`.
const active = new Set<string>();
for (const img of content.querySelectorAll<HTMLImageElement>('img[src^="blob:"]')) {
const src = img.getAttribute('src');
if (src) active.add(src);
}
blobs.prune(active);
});
// Revoke every object URL when the editor unmounts.

Loading…
Cancel
Save

Powered by TurnKey Linux.