A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Fix editor image upload hanging on an endless spinner

Inserting an image left the Image block spinning forever. The mediaUpload
handler previewed via URL.createObjectURL(file), a blob: URL. The Image
block (@wordpress/block-library@9.24.0) treats any blob: URL as a
still-uploading image (is-transient + Spinner) and runs
useUploadMediaFromBlobURL, which re-invokes mediaUpload and only clears
the transient state when handed back a non-blob: URL. Stock WordPress
satisfies that by calling onFileChange twice (temporary blob:, then the
final URL+id); our handler calls it once, so returning another blob: URL
meant the spinner never cleared (and uploadBlob could fire twice).

Preview from a data: URL (FileReader.readAsDataURL) instead. isBlobURL()
is false for data: URLs, so the block renders it as an ordinary inline
image immediately -- no transient state, no re-upload loop. The blob is
still committed to the PDS at publish; attachBlobRefs rewrites the data:
URL to the canonical getBlob URL, keyed the same as before.

revokeBlobRegistry now just clears the map (data: URLs are plain strings,
nothing to revoke). Trade-off: data: URLs are base64, so they bloat the
block tree and the localStorage draft -- documented in Decision 0006.

+72 -59
+25 -11
docs/decisions/0006-image-blob-pipeline.md
··· 19 19 20 20 1. Upload to the writer's PDS: `agent.uploadBlob(file, { encoding: file.type })` → 21 21 `BlobRef`. 22 - 2. Hand the editor a **local object URL** (`URL.createObjectURL(file)`) via 22 + 2. Hand the editor a **`data:` URL** (`FileReader.readAsDataURL(file)`) via 23 23 `onFileChange([{ url, alt }])` so the image previews immediately. **No `id`** — PDS 24 24 blobs aren't WP attachments; an `id` makes the Image block fetch `/wp/v2/media/<id>`, 25 25 which 404s. 26 26 3. **Retain the blob ref + the canonical `getBlob` URL** in an in-memory registry keyed 27 27 by the object URL. 28 28 29 - ### Preview via object URL, not the live `getBlob` URL (corrected 2026-06-08) 29 + ### Preview via a `data:` URL, not the live `getBlob` URL (corrected 2026-06-08) 30 30 A just-uploaded blob is **unreferenced** (temporary on the PDS) until a record commits a 31 31 reference to it; `com.atproto.sync.getBlob` fails for it (observed: **500** on a 32 32 bsky.network PDS). So the original "preview from the live `getBlob` URL" plan below is 33 - wrong for in-editor preview — it only resolves *after* publish. We preview from a local 34 - object URL instead, and `attachBlobRefs` rewrites that transient `blob:` URL to the 35 - canonical `getBlob` URL at publish (so the stored record stays portable, never a dead 36 - object URL). The reader (SP4) still reconstructs the URL from `skypressBlob` + the 37 - author's current PDS. 33 + wrong for in-editor preview — it only resolves *after* publish. We preview from a `data:` 34 + URL instead, and `attachBlobRefs` rewrites that transient `data:` URL to the canonical 35 + `getBlob` URL at publish (so the stored record stays portable, never a dead preview URL). 36 + The reader (SP4) still reconstructs the URL from `skypressBlob` + the author's current PDS. 38 37 39 - Each object URL pins its `File` in memory until revoked, so the registry releases them 40 - (`revokeBlobRegistry`) at the points where the editor is torn down and the previews leave 41 - the DOM — switching/starting an article and Studio unmount — never mid-edit, which would 42 - blank a still-displayed preview. 38 + ### Why a `data:` URL and NOT a `blob:` object URL (corrected 2026-06-09) 39 + The first cut used `URL.createObjectURL(file)`, a `blob:` URL. That **hangs the editor**: 40 + the Image block (`@wordpress/block-library@9.24.0`) treats *any* `blob:` URL as a 41 + still-uploading image — it sets `temporaryURL` (`is-transient` + a `<Spinner/>`) and runs 42 + `useUploadMediaFromBlobURL`, which re-invokes `mediaUpload` for the file behind that URL 43 + and only clears the transient state when `onChange` returns a **non-`blob:`** URL. Stock 44 + WordPress satisfies this by calling `onFileChange` twice (temporary `blob:`, then the 45 + final `http(s)` URL+id); our handler calls it once. Handing back another `blob:` URL means 46 + the spinner never clears (and `uploadBlob` may fire twice). A `data:` URL is not a `blob:` 47 + URL, so the block renders it as an ordinary inline image immediately — no transient state, 48 + no re-upload loop. (Unit tests assert the preview is a `data:` URL and never `blob:`.) 49 + 50 + A `data:` URL is a plain string (no `File` pinned in memory), so there is nothing to 51 + revoke; `revokeBlobRegistry` just clears the map at teardown — switching/starting an 52 + article and Studio unmount — so a later article can't resolve a stale preview at publish. 53 + The trade-off is that `data:` URLs are base64, so they bloat the block tree and the 54 + localStorage draft; a large image can approach the ~5MB localStorage quota. Acceptable for 55 + v1 — drafts-with-images is already a known stopgap (see Consequences) — and revisited if 56 + quota becomes a real problem (e.g. strip preview URLs from the persisted draft). 43 57 44 58 The "Media Library" button (Gutenberg's `editor.MediaUpload`, which opens the legacy 45 59 `wp.media` Backbone frame) is **disabled** via a filter override — SkyPress has no media
+3 -3
docs/specs/sp3-image-blob-pipeline.md
··· 11 11 ## Success criteria 12 12 13 13 1. Inserting an image in the editor uploads it to the writer's PDS and previews via a 14 - local object URL (a just-uploaded, unreferenced blob can't be served by `getBlob` yet 15 - — see Decision 0006). 14 + `data:` URL (a just-uploaded, unreferenced blob can't be served by `getBlob` yet, and a 15 + `blob:` object URL would make the Image block spin forever — see Decision 0006). 16 16 2. On publish, image blocks carry a proper `skypressBlob` ref (`{$type:'blob',…}`) in the 17 17 stored `content`, keeping the blob in-use (not GC'd) and portable. 18 18 3. Pure helpers (`getBlob` URL builder, blob-ref attach transform) unit-tested. ··· 52 52 | Check | Result | 53 53 |---|---| 54 54 | 25 unit tests (incl. blob helpers) | pass | 55 - | Editor upload | `mediaUpload` → `uploadBlob` → image previews via a local object URL | 55 + | Editor upload | `mediaUpload` → `uploadBlob` → image previews via a `data:` URL | 56 56 | Stored content | image block carries `skypressBlob` = `{$type:'blob', ref:{$link:'bafkrei…'}, mimeType:'image/png', size:70}` | 57 57 | Blob served | `getBlob` returns the bytes (200, 70 bytes) | 58 58 | **Blob in-use** | the CID appears in `com.atproto.sync.listBlobs` — referenced by the record, so **not garbage-collected** (the load-bearing reason to store a typed ref, Decision 0006) |
+2 -2
src/lib/media/blob.ts
··· 13 13 14 14 /** 15 15 * What a session upload records for each previewed image. The editor previews from a 16 - * transient object URL (`blob:…`); `attachBlobRefs` looks that key up at publish and 17 - * persists the portable `url` (a `getBlob` URL) plus the typed `ref` (`skypressBlob`). 16 + * transient `data:` URL; `attachBlobRefs` looks that key up at publish and persists the 17 + * portable `url` (a `getBlob` URL) plus the typed `ref` (`skypressBlob`). 18 18 */ 19 19 export interface BlobUpload { 20 20 ref: BlobRefJson;
+15 -28
src/lib/media/mediaUpload.test.ts
··· 1 - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 1 + import { describe, expect, it, vi } from 'vitest'; 2 2 import type { Agent } from '@atproto/api'; 3 3 import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from './mediaUpload'; 4 4 ··· 8 8 } 9 9 10 10 describe( 'createMediaUpload', () => { 11 - beforeEach( () => { 12 - // jsdom doesn't implement object URLs; stub a deterministic one per call. 13 - let n = 0; 14 - vi.stubGlobal( 'URL', { 15 - ...URL, 16 - createObjectURL: vi.fn( () => `blob:preview-${ ++n }` ), 17 - } ); 18 - } ); 19 - afterEach( () => vi.unstubAllGlobals() ); 20 - 21 11 function setup() { 22 12 const registry: BlobRegistry = new Map(); 23 13 const uploadBlob = vi ··· 33 23 return { registry, uploadBlob, handler }; 34 24 } 35 25 36 - it( 'uploads to the PDS, previews via an object URL, and omits any attachment id', async () => { 26 + it( 'uploads to the PDS, previews via a data URL (never a blob: URL), and omits any attachment id', async () => { 37 27 const { uploadBlob, handler } = setup(); 38 28 const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 39 29 const onFileChange = vi.fn(); ··· 43 33 expect( uploadBlob ).toHaveBeenCalledWith( file, { encoding: 'image/png' } ); 44 34 expect( onFileChange ).toHaveBeenCalledTimes( 1 ); 45 35 const [ media ] = onFileChange.mock.calls[ 0 ][ 0 ]; 46 - // Preview src is the local object URL — never the live getBlob URL (unreferenced 47 - // blobs 500 on com.atproto.sync.getBlob until a record commits them). 48 - expect( media.url ).toBe( 'blob:preview-1' ); 36 + // Preview src is a data: URL — NEVER a blob: URL. The Image block treats any blob: 37 + // URL as "still uploading" (is-transient + Spinner) and re-runs its upload hook 38 + // expecting a non-blob URL back; a blob: preview hangs the editor forever. 39 + expect( media.url.startsWith( 'data:image/png;base64,' ) ).toBe( true ); 40 + expect( media.url.startsWith( 'blob:' ) ).toBe( false ); 49 41 // No `id`: PDS blobs are not WP attachments, so the image block must not try to 50 42 // fetch /wp/v2/media/<id> (that 404s). 51 43 expect( 'id' in media ).toBe( false ); 52 44 } ); 53 45 54 - it( 'registers the blob ref + canonical getBlob URL keyed by the preview URL', async () => { 46 + it( 'registers the blob ref + canonical getBlob URL keyed by the preview (data) URL', async () => { 55 47 const { registry, handler } = setup(); 56 48 const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 49 + const onFileChange = vi.fn(); 57 50 58 - await handler( { filesList: [ file ], onFileChange: vi.fn() } ); 51 + await handler( { filesList: [ file ], onFileChange } ); 59 52 60 - expect( registry.get( 'blob:preview-1' ) ).toEqual( { 53 + const [ media ] = onFileChange.mock.calls[ 0 ][ 0 ]; 54 + expect( registry.get( media.url ) ).toEqual( { 61 55 ref: { 62 56 $type: 'blob', 63 57 ref: { $link: 'bafycid123' }, ··· 88 82 } ); 89 83 90 84 describe( 'revokeBlobRegistry', () => { 91 - it( 'revokes every preview object URL and empties the registry', () => { 92 - const revoke = vi.fn(); 93 - vi.stubGlobal( 'URL', { ...URL, revokeObjectURL: revoke } ); 85 + it( 'empties the registry', () => { 94 86 const ref = { $type: 'blob' as const, ref: { $link: 'cid' }, mimeType: 'image/png', size: 1 }; 95 87 const registry: BlobRegistry = new Map( [ 96 - [ 'blob:preview-1', { ref, url: 'https://pds.example.com/a' } ], 97 - [ 'blob:preview-2', { ref, url: 'https://pds.example.com/b' } ], 88 + [ 'data:image/png;base64,aaa', { ref, url: 'https://pds.example.com/a' } ], 89 + [ 'data:image/png;base64,bbb', { ref, url: 'https://pds.example.com/b' } ], 98 90 ] ); 99 91 100 92 revokeBlobRegistry( registry ); 101 93 102 - expect( revoke ).toHaveBeenCalledTimes( 2 ); 103 - expect( revoke ).toHaveBeenCalledWith( 'blob:preview-1' ); 104 - expect( revoke ).toHaveBeenCalledWith( 'blob:preview-2' ); 105 94 expect( registry.size ).toBe( 0 ); 106 - 107 - vi.unstubAllGlobals(); 108 95 } ); 109 96 } );
+27 -15
src/lib/media/mediaUpload.ts
··· 1 1 import type { Agent } from '@atproto/api'; 2 2 import { buildGetBlobUrl, type BlobUpload } from './blob'; 3 3 4 - /** Maps a preview (object) URL → its blob ref + canonical getBlob URL for this session. */ 4 + /** Maps a preview (data) URL → its blob ref + canonical getBlob URL for this session. */ 5 5 export type BlobRegistry = Map< string, BlobUpload >; 6 6 7 7 /** 8 - * Release the preview object URLs held by `registry` and empty it. Each preview URL was 9 - * minted with `URL.createObjectURL`, which pins its `File` in memory until revoked — so 10 - * an editing session that uploads many images would otherwise leak them all until the 11 - * page unloads. Call this only when those previews are no longer on screen (the editor is 12 - * being torn down for a new/other article, or unmounted), never mid-edit. 8 + * Forget the previews held by `registry` and empty it. Previews are `data:` URLs (plain 9 + * strings, no `File` pinned), so there is nothing to revoke — but the map must still be 10 + * cleared so a later article can't accidentally resolve a stale preview at publish. Call 11 + * this only when those previews are no longer on screen (the editor is being torn down for 12 + * a new/other article, or unmounted), never mid-edit. 13 13 */ 14 14 export function revokeBlobRegistry( registry: BlobRegistry ): void { 15 - for ( const previewUrl of registry.keys() ) { 16 - URL.revokeObjectURL( previewUrl ); 17 - } 18 15 registry.clear(); 19 16 } 20 17 18 + /** Read a file into a `data:` URL (base64) for inline preview. */ 19 + function readAsDataUrl( file: File ): Promise< string > { 20 + return new Promise( ( resolve, reject ) => { 21 + const reader = new FileReader(); 22 + reader.onload = () => resolve( reader.result as string ); 23 + reader.onerror = () => reject( reader.error ?? new Error( `Could not read "${ file.name }".` ) ); 24 + reader.readAsDataURL( file ); 25 + } ); 26 + } 27 + 21 28 interface MediaFile { 22 29 url: string; 23 30 alt: string; ··· 33 40 34 41 /** 35 42 * A Gutenberg `mediaUpload` handler backed by atproto blobs (Decision 0006): 36 - * upload each file to the writer's PDS, hand back a local object URL for preview, and 37 - * record the blob ref + canonical `getBlob` URL in `registry` so publish can persist them. 43 + * upload each file to the writer's PDS, hand back a `data:` URL for preview, and record 44 + * the blob ref + canonical `getBlob` URL in `registry` so publish can persist them. 38 45 * 39 - * Preview uses a local object URL rather than the live `getBlob` URL because a 40 - * just-uploaded blob is unreferenced (temporary on the PDS) until a record commits it — 46 + * Preview uses a `data:` URL rather than the live `getBlob` URL because a just-uploaded 47 + * blob is unreferenced (temporary on the PDS) until a record commits it — 41 48 * `com.atproto.sync.getBlob` fails for it, so an inline `getBlob` preview would 500. 49 + * 50 + * Crucially the preview must NOT be a `blob:` object URL: the Image block treats any 51 + * `blob:` URL as a still-uploading image (`is-transient` + a Spinner) and re-runs its own 52 + * upload hook expecting a non-`blob:` URL back — so a `blob:` preview spins forever and 53 + * never commits. A `data:` URL reads as an ordinary inline image and renders immediately. 42 54 */ 43 55 export type MediaUploadHandler = ( args: MediaUploadArgs ) => Promise< void >; 44 56 ··· 65 77 const res = await agent.uploadBlob( file, { encoding: file.type } ); 66 78 const { blob } = res.data; 67 79 const cid = blob.ref.toString(); 68 - // Preview from a local object URL; persist the portable getBlob URL on publish. 69 - const previewUrl = URL.createObjectURL( file ); 80 + // Preview from a data: URL; persist the portable getBlob URL on publish. 81 + const previewUrl = await readAsDataUrl( file ); 70 82 registry.set( previewUrl, { 71 83 ref: { 72 84 $type: 'blob',