Skip to content

Server rendering: read snapshots, and a Node backend that writes on a worker thread - #4

Open
sleeper-luke wants to merge 6 commits into
mainfrom
luke/feat/server-rendering
Open

sleeper-luke wants to merge 6 commits into
mainfrom
luke/feat/server-rendering

Conversation

@sleeper-luke

Copy link
Copy Markdown
Contributor

Before: Cellar only runs where its stores bind in the same process that renders: a device, or a browser page. A server that renders with React, such as sleeper-web on Next.js, has no way to render a store's reads. Its hooks answer empty there, so the server's HTML and the browser's first render can't carry players, stats or schedules. There is also no SQLite binding for Node.

After:

  • A page's server code captures what its reads answer: await dehydrateReads([readCall(PlayerService.Hooks.usePlayerDetail, { sport, playerId }), …]). It hands the snapshot to <CellarHydrate snapshot={…}>. readCall takes a service's existing hook or getter, so a service needs no changes.
  • Under CellarHydrate, a read answers from the snapshot while its store has no rows for the partition, and doesn't fetch the partition. readCall(…, { live: true }) makes the browser fetch it and move to the store's value once it lands, for data that keeps changing.
  • configureCellar({ serverRendering: true }) makes hooks answer only from the snapshot and never fetch, so the HTML is what the browser renders first, before its own stores are bound. A call the snapshot doesn't hold renders empty, with the status the browser's first render reports. getValue and a store's lifecycle still read and fetch, which is how a server fills a snapshot.
  • A new @sleeperhq/react-native-cellar/node entry: bindNodeStore(label, store) binds a store to a node:sqlite database of its own. A worker thread parses each fetched body and writes its rows; the main thread reads through its own connection to the same file, which WAL lets run alongside a write.

How:

  • read/hydration.ts holds the snapshot type, dehydrateReads, readCall and CellarHydrate.
  • Each read gets a capture(args, live) that awaits its partitions through the fetch ingest's prefetch, then runs select. pairRead attaches that capture to the hook and getter it publishes.
  • A snapshot files each value under store.read plus the read's args key. That key is a pure content string, so the server and the browser derive the same one.
  • The hooks look up their entry before reading the store.
  • node/node_connection.ts keeps an ordered request port to the writer (node/writer.ts):
    • async calls resolve from replies on that port;
    • a blocking call (execute, executeBatch) waits on a shared signal and collects its reply with receiveMessageOnPort;
    • Cellar only makes blocking writer calls inside a write's turn in the queue, where the writer is idle.
  • The writer runs shred commands through the JS reference interpreter, which moves from the sql.js test connection into the core (write/js_shred.ts). Both connections cache prepared statements.
  • Cellar's runtime configuration, the hydration context and the capture symbol live in global slots (Symbol.for). A server can load the core through its ESM build and through the CommonJS ./node entry, and both see one configuration.
  • ./node is CommonJS under both conditions, because the writer is located as a file beside the entry. packaging.test.ts pins that exception instead of loosening the one-build rule for the other subpaths.

Measured: app-shared's real player and stats stores, against production payloads (NFL players 4.1 MB; NFL season stats 5.3 MB):

partition node writer: total node writer: longest main-thread block sql.js on the main thread
NFL players (12,229 rows) 138 ms 10 ms 325 ms
NFL week 9 stats 159 ms 10 ms 314 ms
NFL week 10 projections 589 ms 25 ms 1,165 ms
NFL season stats 518 ms 25 ms 1,028 ms

A refresh with a tenth of the rows changed blocks the main thread for 1–26 ms.

Limits:

  • node:sqlite logs an ExperimentalWarning once per thread on Node 22/24.
  • A bundler should leave the package external (serverExternalPackages in Next.js), so the writer file resolves beside the entry.
  • Snapshots carry plain data. JSON carries a list's missing entry as null, while a React server-component prop keeps undefined.
  • A read hook makes one extra context lookup per call, on device too. With no CellarHydrate above it, behavior is unchanged.

Tests:

  • 19 cases in src/tests/read/hydration.test.ts: capture, failure reporting, the browser and server hook paths, useEach, nested providers.
  • 9 cases in src/tests/node/node_connection.test.ts: ordering, temp tables, rollback, shred parity with sql.js, a store over the writer, snapshot capture, and the main thread staying free during a 30,000-row ingest (22 ms against 325 ms).
  • Packaging tests for the new entry.
  • yarn test (815 passed), yarn test:prod (797 passed), yarn build, and the rozenite plugin's typecheck and tests all pass locally.
  • The built package also ran end to end in plain Node: ESM core, CommonJS ./node entry, the writer at its default path.

sleeper-luke and others added 6 commits November 10, 2026 16:05
…an run a native shred in JS

Co-authored-by: Cursor <cursoragent@cursor.com>
…ser renders it until its stores have the rows

Co-authored-by: Cursor <cursoragent@cursor.com>
…g on a worker thread

Co-authored-by: Cursor <cursoragent@cursor.com>
…he Node entry

Co-authored-by: Cursor <cursoragent@cursor.com>
…nt can import Cellar under a React with no createContext

Co-authored-by: Cursor <cursoragent@cursor.com>
…'t know the module yet can still bundle it

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant