@@ -40,6 +40,12 @@ type Store<T> = {
4040 /** Store-level visibilitychange handler, installed only while this store polls. */
4141 visibilityListener : ( ( ) => void ) | null ;
4242 generation : number ;
43+ /**
44+ * Set when `setClientResourceData` publishes while nobody is subscribed (session-cache
45+ * seed). The first subscribe must quiet-revalidate; otherwise mount never fetches and
46+ * seeded rows stay indefinitely stale with `lastAttemptOk: true`.
47+ */
48+ seedNeedsRevalidate : boolean ;
4349} ;
4450
4551/**
@@ -80,6 +86,7 @@ function getStore<T>(key: string): Store<T> {
8086 inflightOwner : null ,
8187 visibilityListener : null ,
8288 generation : 0 ,
89+ seedNeedsRevalidate : false ,
8390 } ;
8491 stores . set ( key , store ) ;
8592 }
@@ -223,6 +230,8 @@ async function runFetch<T>(
223230 try {
224231 const data = await fetcher ( controller . signal ) ;
225232 if ( gen !== store . generation || controller . signal . aborted ) return ;
233+ // Cleared on settle (not at subscribe) so StrictMode's aborted first mount still revalidates.
234+ store . seedNeedsRevalidate = false ;
226235 store . snapshot = {
227236 data,
228237 error : undefined ,
@@ -233,6 +242,7 @@ async function runFetch<T>(
233242 } ;
234243 } catch ( error ) {
235244 if ( gen !== store . generation || controller . signal . aborted ) return ;
245+ store . seedNeedsRevalidate = false ;
236246 store . snapshot = {
237247 ...store . snapshot ,
238248 // Normalize so a loader that rejects with `undefined` still reads as a failure.
@@ -298,9 +308,12 @@ function subscribeResource<T>(
298308 store . fetcherByListener . set ( onStoreChange , fetcher ) ;
299309 store . subscriberCount ++ ;
300310
301- // Cold start only — keep cached data across transient 0→1 resubscribe gaps.
302- if ( store . subscriberCount === 1 && store . snapshot . data === undefined ) {
303- void runFetch ( store , fetcher , { replaceInflight : true , owner : onStoreChange } ) ;
311+ // Cold start, or a pre-subscribe seed that still needs a network check. Keep
312+ // cached data across transient 0→1 resubscribe gaps when neither applies.
313+ if ( store . subscriberCount === 1 ) {
314+ if ( store . snapshot . data === undefined || store . seedNeedsRevalidate ) {
315+ void runFetch ( store , fetcher , { replaceInflight : true , owner : onStoreChange } ) ;
316+ }
304317 }
305318 recomputePoll ( store ) ;
306319
@@ -328,7 +341,7 @@ function subscribeResource<T>(
328341}
329342
330343/** Module-level fetch cache with useSyncExternalStore subscriptions (no fetch in useEffect). */
331- export interface ClientResourceOptions {
344+ export interface ClientResourceOptions < T = unknown > {
332345 pollMs ?: number ;
333346 enabled ?: boolean ;
334347 /**
@@ -337,18 +350,34 @@ export interface ClientResourceOptions {
337350 * happening off-screen — a restarting server, for instance.
338351 */
339352 pauseWhenHidden ?: boolean ;
353+ /**
354+ * Optional seed applied once before the first subscribe (session-cache revisit).
355+ * Lives in the store — not a render-time ref — so React Compiler / react-hooks/refs
356+ * stay quiet while the mount fetch still quiet-revalidates via `seedNeedsRevalidate`.
357+ */
358+ initialData ?: T ;
359+ }
360+
361+ /** Seed an empty, unsubscribed store. No-ops when data already exists or someone is listening. */
362+ function seedClientResourceIfEmpty < T > ( key : string , data : T ) : void {
363+ const store = getStore < T > ( key ) ;
364+ if ( store . subscriberCount !== 0 || store . snapshot . data !== undefined ) return ;
365+ setClientResourceData ( key , data ) ;
340366}
341367
342368export function useClientResource < T > (
343369 key : string ,
344370 fetcher : ( signal : AbortSignal ) => Promise < T > ,
345- options ?: ClientResourceOptions ,
371+ options ?: ClientResourceOptions < T > ,
346372) : ResourceSnapshot < T > & { refresh : ( opts ?: { forceLoading ?: boolean } ) => void } {
347373 const enabled = options ?. enabled !== false ;
348374 const pollMs = options ?. pollMs ;
349375 // Default true: a background tab has nobody reading the paint. Opt out for polls that
350376 // must keep running while hidden, such as waiting for a restarted server to answer.
351377 const pauseWhenHidden = options ?. pauseWhenHidden !== false ;
378+ if ( enabled && options ?. initialData !== undefined ) {
379+ seedClientResourceIfEmpty ( key , options . initialData ) ;
380+ }
352381 const fetcherRef = useRef ( fetcher ) ;
353382 // Sync latest fetcher every commit. No dep array on purpose: inline fetchers are
354383 // reallocated every render; listing them would re-subscribe forever.
@@ -411,7 +440,7 @@ export function useKeyedClientResource<T>(
411440 key : string ,
412441 deps : readonly unknown [ ] ,
413442 load : ( signal : AbortSignal ) => Promise < T > ,
414- options ?: ClientResourceOptions ,
443+ options ?: ClientResourceOptions < T > ,
415444) : ResourceSnapshot < T > & { refresh : ( opts ?: { forceLoading ?: boolean } ) => void } {
416445 const resource = useClientResource ( key , load , options ) ;
417446 const prevDepsRef = useRef < readonly unknown [ ] | null > ( null ) ;
@@ -447,6 +476,9 @@ export function setClientResourceData<T>(key: string, data: T) {
447476 hasSucceeded : true ,
448477 lastAttemptOk : true ,
449478 } ;
479+ // Only pre-subscribe seeds need a follow-up fetch. Live publishers (mutation
480+ // results) already hold the fresh value and must not schedule a redundant GET.
481+ store . seedNeedsRevalidate = store . subscriberCount === 0 ;
450482 emit ( store ) ;
451483}
452484
0 commit comments