Skip to content

renderffx/suspense-use-scratch

Repository files navigation

Sledge — Learning React 19 use() + Fiber Suspense by Building a Cache

What This Is

This is not a library. This is a dissection of how React 19's use() hook and Suspense work inside Fiber, exposed by building the simplest possible cache that uses them correctly.

Every line of code exists because React's internals demand it. There is no abstraction, no indirection, no safety net. Just the raw contract between your code and the reconciler.


The Foundational Decision: Module-Level Singleton

Decision: The cache is a Map at the top of the module. Never passed as props. Never in context. Never in a ref.

const registry = new Map<string, CacheEntry<any>>();

Why this matters to Fiber:

When a component calls use() and the thenable is pending, React does NOT return control to your code. It throws the thenable. The throw unwinds through beginWork → caught by throwException → React walks up the fiber tree to a SuspenseComponent → shows fallback. Your component is gone from the call stack.

Later, when the promise resolves, React re-executes the component FROM SCRATCH. The entire function body runs again. Every local variable is recreated.

If the cache were a local variable, a useRef, or a useMemo, it would be recreated on retry. The new code would call getThenable(), find no entry, call the fetcher a second time, create a SECOND Promise, and throw a second time. But React's ping is attached to the FIRST Promise — the one that already resolved. The second Promise never pings React. The Suspense boundary stays on the fallback forever.

This is not a bug in React. It is the correct behavior of a design where the reconciler does not assume your component has side effects. The module-level singleton is the ONLY way to guarantee that the same Map entry is read on every render attempt, because the module scope survives retries.


The Thenable Contract: Why status, value, reason Are on the Promise Object

Decision: Every cache entry stores { status, promise, value, reason }. The status, value, and reason are ALSO set directly on the Promise object via (promise as any).status = 'pending'.

Why this matters to use():

React 19's use() reads properties from the thenable object ITSELF:

function use(thenable) {
  if (thenable.status === 'fulfilled') return thenable.value;
  if (thenable.status === 'rejected')  throw thenable.reason;
  if (thenable.status !== 'pending') {
    thenable.status = 'pending';
    thenable.then(
      v => { thenable.status = 'fulfilled'; thenable.value = v; },
      e => { thenable.status = 'rejected';  thenable.reason = e; }
    );
  }
  throw thenable;
}

It does NOT read from your cache entry. It reads thenable.status, thenable.value, thenable.reason — properties ON the thenable object.

If these properties only exist in your cache Map entry, use() sees undefined for .status, enters the "first encounter" branch, attaches its OWN .then() handlers, sets status = 'pending', and throws. On retry, it does the same thing again — because your getThenable only sets the properties in the Map, not on the thenable. The component suspends forever.

The fix: set the properties on both the entry AND the promise object:

(promise as any).status = 'pending';

And in the .then() handlers:

(promise as any).status = 'fulfilled';
(promise as any).value = value;

Now when use() checks thenable.status, it sees 'fulfilled' on the promise itself. It returns thenable.value. No second throw.


Two Paths, One Registry: getResource vs getThenable

Decision: Two functions that share the same registry but serve different consumers.

Function Behavior Consumer
getResource(key, fetcher) Returns T or throws the promise/reason Direct use with <Suspense> (React 18 pattern)
getThenable(key, fetcher) Returns Promise<T>never throws use() hook (React 19 pattern)

Why two paths exist:

use() is NOT a hook. It is a function that can be called conditionally. But it requires a thenable as input, not a value. getResource throws the promise before use() ever sees it. So useSledge cannot use getResource.

The internal flow of getThenable:

getThenable(key, fetcher):
  cached = registry.get(key)
  if cached exists → return cached.promise  (has .status/.value/.reason attached)

  promise = fetcher()
  entry = { status: 'pending', promise, value: undefined, reason: undefined }
  (promise as any).status = 'pending'
  registry.set(key, entry)

  promise.then(onFulfilled, onRejected)  // updates entry + promise properties

  return promise
  // NO throw — the caller (useSledge) passes this to use()

getResource delegates to getThenable for the cold path:

getResource(key, fetcher):
  cached = registry.get(key)
  if cached exists:
    if fulfilled → return cached.value
    if rejected  → throw cached.reason
    if pending   → throw cached.promise  // Suspense (React 18 style)

  // Cold path: reuse getThenable, then throw
  const thenable = getThenable(key, fetcher)
  throw thenable

This avoids code duplication. The entry creation logic lives in one place (getThenable). getResource uses it when the entry doesn't exist.


The Three-State Machine

Decision: Every entry has exactly three status values — 'pending', 'fulfilled', 'rejected'. No other values. No enums. No Symbols.

Why only these three:

React's use() checks:

thenable.status === 'fulfilled'  → return value
thenable.status === 'rejected'   → throw reason
thenable.status !== 'pending'    → first encounter: attach handlers
                                  → fall through to throw

If your status is 'loading', use() evaluates thenable.status !== 'pending' as true (because 'loading' !== 'pending'). It enters the "first encounter" branch, OVERWRITES your status to 'pending', attaches its OWN .then() handlers, and throws. On retry, your getThenable returns the promise — but use() already overwrote the status. If the promise hasn't resolved yet, use() sees 'pending' and throws again. The cycle repeats indefinitely.

The fix: use 'pending' as the string. Not 'loading'. Not 'fetching'. The exact string 'pending' — because that is the only value that tells use() "I already know about this thenable, do not touch my handlers."


The Deleted Comment That Explained Everything

Decision: There are no comments in the code. Every assumption is encoded in the type system and the structure.

Why this matters for learning:

The code is the source of truth. The comments would describe what the code should do. If they disagree, the code wins. By removing comments, we force the code to be self-explanatory — and if it isn't, the design is wrong.

The architectural knowledge lives in ARCH.md. The code lives in .ts files. They are separate because they serve different readers: the compiler reads the code, humans read the architecture doc. Mixing them creates maintenance drag — when the code changes, the comments rot.


useSledgeLive: Why Two Primitives

Decision: useSledgeLive calls BOTH use() and useSyncExternalStore. It uses use() for initial load and useSyncExternalStore for live updates.

Why use() alone is not enough:

A Promise resolves once. After that, thenable.status is 'fulfilled' forever. use() reads it, sees 'fulfilled', returns the value, and does nothing else. If updateResource() overwrites the entry's value, the promise's .status is still 'fulfilled', but use() has no mechanism to re-read. It already returned.

Why useSyncExternalStore alone is not enough:

useSyncExternalStore needs a getSnapshot function that returns the current value. If the entry doesn't exist yet, getSnapshot returns undefined. But the component needs to SUSPEND, not render with undefined. You could throw a promise from getSnapshot, but getSnapshot is called during React's render phase, and throwing from it bypasses React's Suspense handling — it goes straight to ErrorBoundary.

The fix: Use use() to suspend (it throws the thenable correctly into the Fiber pipeline), then use useSyncExternalStore to subscribe after the initial render commits.

function useSledgeLive(key, fetcher) {
  const thenable = getThenable(key, fetcher);

  const snapshot = useSyncExternalStore(
    cb => subscribeToKey(key, cb),
    () => readCache(key),
    () => undefined
  );

  if (snapshot === undefined) {
    return use(thenable);  // Suspense path
  }

  return snapshot;  // Live read path
}

The branching works because use() CAN be called conditionally — it is not a hook.


The Subscription System: Why a Separate Listener Map

Decision: Listeners are stored in a separate Map<string, Set<() => void>>, not on the cache entry itself.

Why separate from the entry:

The entry stores data. The listeners store change notifications. They have different lifetimes and different access patterns.

  • The entry is read on EVERY render (hot path). Adding a listener set to every entry would allocate memory for non-streaming keys.
  • Listeners are only needed for useSledgeLive. For useSledge, no subscription is required.
  • When clearCache(key) is called, we delete the entry AND the listeners. If they were merged, deleting the entry would orphan the listeners (or vice versa).

The decoupling also matches React's design: useSyncExternalStore receives a subscribe function and a getSnapshot function. They are separate contracts. subscribe registers a callback; getSnapshot reads data. Merging them would break this contract.


Error Handling: Why Raw Rejection Reasons

Decision: entry.reason is the raw value from Promise.reject(reason). No wrapping. No normalization.

Why this matters:

When use() sees thenable.status === 'rejected', it executes throw thenable.reason. This throw goes through throwException, which checks typeof thrownValue.then === 'function'. Since a bare Error object does NOT have a .then method, the throw routes to the ErrorBoundary path, not Suspense.

If you wrap the rejection reason in a new Error, you:

  1. Lose the original stack trace
  2. Make it impossible for ErrorBoundaries to distinguish error types (network vs auth vs validation)
  3. Add an allocation to every rejection

The only normalization needed: if the promise rejects with undefined or null, React's throw handler crashes because typeof undefined.then throws a TypeError during error recovery. The fix is in the promise rejection handler:

(reason) => {
  const normalized = reason != null ? reason : new Error(String(reason));
  entry.status = 'rejected';
  entry.reason = normalized;
  (promise as any).status = 'rejected';
  (promise as any).reason = normalized;
}

But for user errors (explicit reject(new Error(...))), no normalization is needed.


Prefetching: Why getThenable and Not a Separate API

Decision: Prefetching uses the same getThenable function as the hooks. There is no prefetch(key, fetcher) function.

Why a single function:

If prefetching created a separate Promise (e.g., const p = fetch(url)), and the hook created a different promise via getThenable, there would be two Promises for the same key. The hook's promise would be pending. The prefetch promise would resolve and update... nothing. The hook's promise is still pending. The component stays suspended.

getThenable is the single point of entry creation. Whether you call it from an event handler (prefetch) or from useSledge (render), it creates the same entry with the same Promise. One Promise. One ping. One retry.

// Event handler — prefetch
button.onclick = () => getThenable('user:42', fetchUser);
// Creates entry, starts fetch, returns promise (we ignore it)

// Later, during render — consume
function Profile() {
  const user = useSledge('user:42', fetchUser);
  // Same entry exists, possibly already fulfilled → no Suspense
  // If still pending → use() suspends
}

The Fetcher Contract: Why It's Called Exactly Once

Decision: fetcher() is called exactly once per key. The return value is stored. The fetcher reference is not held after that.

Why this matters:

The fetcher might close over component-scoped variables. If the cache held a reference to the fetcher, those closures would prevent garbage collection of the component's fiber and hooks tree. For thousands of keys, this leaks memory.

Also: the fetcher is called during the COLD path of getThenable, which is outside the render phase (the render phase threw immediately after). But the closure variables might reference stale state. By not storing the fetcher, we ensure:

  1. No memory leak from closed-over state
  2. The cache does not depend on component lifecycle
  3. The assertion "one fetch per key" is enforced by the code (registry check before calling fetcher)

The status Write on updateResource: Why It's Mandatory

Decision: updateResource always writes entry.status = 'fulfilled', even if it's already 'fulfilled'.

Why the write is not optional:

In JavaScript, object property writes act as memory ordering barriers in V8's optimizing compiler (specifically, they force a store-store barrier on ARM CPUs). When updateResource writes entry.value = newData and then entry.status = 'fulfilled', the compiler guarantees that the value write is globally visible before the status write.

If we skipped the status write (because it was already 'fulfilled'), a concurrent render on another thread (or a CPU-reordered execution) could read entry.status === 'fulfilled' (the old value, which is still 'fulfilled') but read a partially-written or stale entry.value. This is a tearing read.

The write to status is a commit signal: "all other fields are now consistent." Even though the value doesn't change, the write ensures ordering.


The Test Suite: What It Verifies

Decision: 12 tests that verify the cache contract without React. No jsdom. No React renderer.

Why no React in tests:

The cache registry is a plain TypeScript module. It does not import React. getResource, getThenable, updateResource, readCache, clearCache are all synchronous (the .then() handlers are async but the functions themselves are sync). They can be tested with vitest in Node.js.

What the tests verify:

Test What it catches
getResource throws the raw promise Ensures the thrown value is a Promise (duck-typing check via .then)
getResource returns value after resolution Verifies the pending → fulfilled state transition works
getResource throws reason after rejection Verifies pending → rejected throws the reason
calls fetcher exactly once per key Verifies the singleton contract — retries hit cache, not fetcher
getThenable returns without throwing Verifies the non-throwing path
getThenable returns same thenable on repeat Verifies referential identity — the same Promise object is returned
getThenable attaches status/fulfilled on resolution Verifies the Thenable contract properties are set on the Promise
updateResource sets value for subsequent reads Verifies push-based cache population
updateResource overwrites existing value Verifies in-place mutation
updateResource notifies subscribers Verifies notifyKey fires listeners
clearCache removes a single key Verifies targeted invalidation
clearCache removes all keys Verifies full invalidation

These tests exercise the entire contract. If they pass, the cache correctly implements the Thenable contract. If they don't, no React component will work correctly either.


Project Map

sledge/
├── ARCH.md                  # This file — full architecture deep-dive
├── src/
│   ├── lib/
│   │   ├── sledge-cache.ts      # Core: registry, getThenable, getResource,
│   │   │                         # updateResource, subscribeToKey, readCache, clearCache
│   │   ├── sledge-hooks.ts      # useSledge (use) + useSledgeLive (use + uSES)
│   │   └── sledge-stream.ts     # Binary stream generator for the live example
│   ├── components/
│   │   ├── ErrorBoundary.tsx     # Class component catching rejected thenable throws
│   │   ├── SuspenseFallback.tsx  # Loading indicator
│   │   ├── UserProfile.tsx       # useSledge with simulated fetch
│   │   ├── StreamDisplay.tsx     # useSledgeLive with useSyncExternalStore for chunks
│   │   └── StreamControls.tsx    # Start/Stop/Reset for the stream simulator
│   ├── examples/
│   │   ├── BasicSuspense.tsx     # Switch users, see Suspense cycle in action
│   │   ├── BinaryStream.tsx      # Real-time binary chunks, live subscription
│   │   └── PrefetchExample.tsx   # Pre-populate cache, zero fallback flash
│   ├── App.tsx                   # Tab-based navigation
│   └── main.tsx                  # createRoot + StrictMode
└── tests/
    └── sledge-cache.test.ts      # 12 tests, no React, pure Thenable contract

Running

npm install
npm run dev       # http://localhost:5173 — 3 example tabs
npm test          # 12 tests, all must pass
npm run build     # TypeScript check + production bundle

The Three Examples

1. Basic Suspense (useSledge)

Demonstrates: pending → throw → fallback → resolve → retry → commit.

Switch between User 1/2/3. Each click clears the cache for that user and re-creates the entry. Watch the 2-second fallback, then the data appears. The UI does NOT unmount between switches — the same components re-render with new data because the Suspense boundary is above them.

2. Binary Stream (useSledgeLive)

Demonstrates: use() for initial load, useSyncExternalStore for live updates.

Click "Start stream". The stream metadata fetches (1.5s via Suspense). When the StreamDisplay commits, useSyncExternalStore subscribes to the chunk key. The stream pushes Uint8Array data every 300ms via updateResource. Each push calls notifyKey, which fires the store listener, which triggers a synchronous re-render. The chunk info updates in real-time.

3. Prefetching

Demonstrates: cache-first architecture.

Click "Prefetch" — getThenable creates the entry outside React's render phase. Click the user area — useSledge reads the pre-resolved value. If the prefetch completed: use() sees status: 'fulfilled' and returns immediately — no Suspense. If still pending: use() throws for Suspense — standard fallback.


The Full Fiber Flow

Component: use(thenable)
  │
  ├─ thenable.status === 'fulfilled'
  │   └─ return value (done)
  │
  ├─ thenable.status === 'rejected'
  │   └─ throw reason → ErrorBoundary (done)
  │
  └─ thenable.status === 'pending'
      └─ throw thenable
           │
           ▼
      beginWork catches throw
           │
           ▼
      throwException(root, returnFiber, sourceFiber, thrownValue, lanes)
           │
           ├─ typeof thrownValue.then === 'function'?  YES → Suspense path
           │
           ├─ Walk fiber.return chain → find SuspenseComponent
           │
           ├─ attachPingListener:  thenable.then(ping, ping)
           │
           ├─ boundary.flags |= ShouldCapture
           │  boundary.memoizedState.timedOut = true
           │  source.flags |= DidCapture
           │
           ▼
      Render phase completes (Suspense boundary cuts the tree)
           │
           ▼
      Commit phase:
           ├─ ShouldCapture → DidCapture
           ├─ OffscreenComponent → mode = 'hidden'
           ├─ Insert fallback DOM
           └─ promise.then(ping, ping) — safety re-attach
           │
           ▼
      Promise resolves:
           ├─ entry.status = 'fulfilled'
           ├─ (promise as any).status = 'fulfilled'
           ├─ notifyKey(key) — live subscribers fire
           └─ ping() fires
                ├─ markRootUpdated(root, pingedLanes)
                ├─ ensureRootIsScheduled(root)
                └─ removeFromSuspendedLanes(root, pingedLanes)
           │
           ▼
      Retry render:
           ├─ getThenable → same promise, now fulfilled
           ├─ use(thenable) → status === 'fulfilled' → return value
           └─ Render completes normally
           │
           ▼
      Commit phase:
           ├─ DidCapture cleared
           ├─ timedOut = false
           ├─ Offscreen → mode = 'visible'
           ├─ Remove fallback DOM
           ├─ Reattach primary content DOM
           └─ Effects flush (useEffect callbacks run)

Reading Order

  1. ARCH.md (this file) — understand why every decision was made
  2. src/lib/sledge-cache.ts — the core cache, read it top to bottom
  3. src/lib/sledge-hooks.ts — the hooks, 33 lines total
  4. src/components/UserProfile.tsx — simplest consumer
  5. src/examples/BasicSuspense.tsx — puts it all together
  6. src/components/StreamDisplay.tsx — uses both use() and useSyncExternalStore
  7. tests/sledge-cache.test.ts — proves the contract without React

About

suspense

Resources

License

Contributing

Stars

9 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages