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 edit-load cover hydration race that dropped stored covers

The cover was hydrated inside the one-shot ?edit= loader, gated on
pdsUrl. But pdsUrl resolves after agent/did (adoptSession awaits the
profile + PDS lookup), so the article fetch usually landed first with
pdsUrl still null — the cover was skipped and the editLoadedRef one-shot
guard blocked any re-run. The picker showed the empty state, and the
next save passed coverImage=undefined, silently stripping the stored
cover from the document.

Move hydration to a dedicated effect keyed on pdsUrl so it re-runs once
pdsUrl arrives, and extract the guard into a pure, tested
coverFromStoredRef helper.

+64 -8
+13 -8
src/components/Studio.tsx
··· 9 9 import AppBar from './AppBar'; 10 10 import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from '../lib/media/mediaUpload'; 11 11 import { 12 - coverPreviewUrl, 12 + coverFromStoredRef, 13 13 uploadCoverViaMedia, 14 14 type CoverUpload, 15 15 } from '../lib/media/cover'; ··· 74 74 setBlocks( article.blocks as unknown as BlockInstance[] ); 75 75 setTitle( article.title ); 76 76 setExcerpt( article.description ?? '' ); 77 - if ( article.coverImage && pdsUrl ) { 78 - setCover( { 79 - ref: article.coverImage, 80 - previewUrl: coverPreviewUrl( article.coverImage, { pdsUrl, did } ), 81 - } ); 82 - } 83 77 } 84 78 // `article === null` → no owned document has this rkey (stale/bad edit 85 79 // link). Silently start a new article, as before. ··· 97 91 return () => { 98 92 cancelled = true; 99 93 }; 100 - }, [ agent, did, pdsUrl ] ); 94 + }, [ agent, did ] ); 95 + 96 + // Hydrate the cover preview once an edit-loaded article AND the PDS URL are both known. 97 + // pdsUrl resolves after agent/did (adoptSession awaits the profile + PDS lookup), so it can 98 + // arrive after the edit-load fetch — keying this on pdsUrl (not the one-shot loader above) 99 + // avoids dropping a stored cover that would otherwise be stripped on the next save. 100 + useEffect( () => { 101 + const next = coverFromStoredRef( editing?.coverImage, { pdsUrl, did } ); 102 + if ( next ) { 103 + setCover( next ); 104 + } 105 + }, [ editing, pdsUrl, did ] ); 101 106 102 107 // Release the preview object URLs this session minted when the Studio unmounts. 103 108 useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] );
+31
src/lib/media/cover.test.ts
··· 2 2 import { 3 3 validateCoverFile, 4 4 coverPreviewUrl, 5 + coverFromStoredRef, 5 6 uploadCoverViaMedia, 6 7 COVER_MAX_BYTES, 7 8 type CoverUpload, ··· 50 51 expect( url ).toContain( 'com.atproto.sync.getBlob' ); 51 52 expect( url ).toContain( 'did%3Aplc%3Ame' ); 52 53 expect( url ).toContain( 'bafycid' ); 54 + } ); 55 + } ); 56 + 57 + describe( 'coverFromStoredRef', () => { 58 + const ref = { 59 + $type: 'blob' as const, 60 + ref: { $link: 'bafycid' }, 61 + mimeType: 'image/png', 62 + size: 1234, 63 + }; 64 + 65 + it( 'builds a cover from a stored ref once the PDS URL and DID are known', () => { 66 + const cover = coverFromStoredRef( ref, { pdsUrl: 'https://pds.example', did: 'did:plc:me' } ); 67 + expect( cover?.ref ).toBe( ref ); 68 + expect( cover?.previewUrl ).toContain( 'com.atproto.sync.getBlob' ); 69 + } ); 70 + 71 + it( 'returns null until the PDS URL has resolved', () => { 72 + // pdsUrl resolves after agent/did in adoptSession, so the edit-load fetch can land 73 + // first. Returning null (rather than an unusable getBlob URL) lets the caller's 74 + // effect re-run and hydrate once pdsUrl arrives — without this guard a re-save would 75 + // drop the stored cover. 76 + expect( coverFromStoredRef( ref, { pdsUrl: null, did: 'did:plc:me' } ) ).toBeNull(); 77 + expect( coverFromStoredRef( ref, { pdsUrl: 'https://pds.example', did: null } ) ).toBeNull(); 78 + } ); 79 + 80 + it( 'returns null when the article has no stored cover', () => { 81 + expect( 82 + coverFromStoredRef( undefined, { pdsUrl: 'https://pds.example', did: 'did:plc:me' } ) 83 + ).toBeNull(); 53 84 } ); 54 85 } ); 55 86
+20
src/lib/media/cover.ts
··· 50 50 } 51 51 52 52 /** 53 + * Build the cover state for an edit-loaded article from its stored ref. Returns `null` until 54 + * both the PDS URL and DID are known — `pdsUrl` resolves *after* `agent`/`did` (the auth 55 + * provider awaits the profile + PDS lookup), so the edit-load fetch can land before it. The 56 + * caller must run this in an effect keyed on `pdsUrl` so the cover hydrates once it arrives; 57 + * returning `null` (rather than an unusable `getBlob` URL) keeps a premature call harmless. 58 + */ 59 + export function coverFromStoredRef( 60 + ref: BlobRefJson | undefined, 61 + author: { pdsUrl: string | null; did: string | null } 62 + ): CoverUpload | null { 63 + if ( ! ref || ! author.pdsUrl || ! author.did ) { 64 + return null; 65 + } 66 + return { 67 + ref, 68 + previewUrl: coverPreviewUrl( ref, { pdsUrl: author.pdsUrl, did: author.did } ), 69 + }; 70 + } 71 + 72 + /** 53 73 * Upload a cover file through the existing Gutenberg `mediaUpload` handler (same PDS path as 54 74 * content images) and resolve with the persistable ref + preview URL. The handler records the 55 75 * upload in `registry` keyed by the preview `data:` URL it returns via `onFileChange`; we read