← All posts

Two render environments, one import

A server component imported a helper from a 'use client' module. It crashed the page — but only after somebody answered a question, because that was the only state in which the component that used it rendered at all. Three bugs found by using the product. Release notes 2026-09-15 — vocion-core#349.

Vocion Teamvocion-v2.74.0

Status: shipped. vocion-core #349 is merged onto the release branch release/2026-09-15-rail (the commit inside it is 662de773) and deployed to a deployment we operate. The pull request is still landing on main individually behind that release branch.

Three bugs, all found the same way: by using the product on a deployment we operate rather than by reading the code. Each is a different shape of the same lesson — a thing that is correct in every state you thought to test and wrong in the one you did not.

1. A helper that only exists in one render environment

A group of questions has a page. Answer one, and the page went white.

The page is a server component. It renders AskReceipt for the questions already decided, and AskReceipt imported labelFor from AskSheet — which starts with 'use client'. Under the App Router, a server module importing from a client module does not get the function; it gets a client reference, an opaque marker meant to be serialised across the boundary. Calling it throws:

Attempted to call labelFor() from the server but labelFor is on the client.

The part worth keeping is why it took so long to see. AskReceipt renders only for decided asks. Until somebody answered something in that group there were no decided asks, so the component never rendered, so the client reference was never called. The import was wrong from the moment it was written; the crash needed a specific data state to exist.

The fix is not a directive. It is a third module that belongs to neither environment: labelFor, OTHER and FIXED_ROWS moved into features/dashboard/inbox/askOptions.ts, which carries no 'use client' and is imported by both the sheet and the receipt. A shared vocabulary should not live inside either half of a boundary it has to cross.

The general form. In a React Server Components app, 'use client' is a property of a module, not of a function. Any pure helper that both halves need must live in a module that declares neither, and the type checker will not tell you: the import resolves, the types line up, and the failure is a runtime throw on a code path that may not exist yet in your dev data.

2. A count that depended on where you were standing

The Decided tab read 0 from the open tab, and 7 the moment you clicked it.

listInbox fetched the decided rows only when the decided tab was the one being viewed — which is an entirely reasonable optimisation right up until the tab label is a count. A count on a tab is a promise about what is behind it. It cannot be a function of which tab you are on.

The fix loads open, snoozed and decided on every call, in one Promise.all, whatever tab is active. Three queries instead of one, and the number is now true from anywhere.

3. A poll that retried a 400 forever

Three repeating 400s in the console on every inbox page.

useActionRunStatus polls for the status of a running action. Two things were wrong at once. It polled for any runId that was not undefined — including 0 and NaN, which are not run ids. And its catch treated a refused request exactly like a dropped connection, so a 400 (a permanent answer: this is not a thing) was retried on the same schedule as a network blip, forever.

Both guards are now pure predicates with their own tests:

Pure, exported, and tested directly, because the interesting cases here are 0, -3, 1.5 and NaN, and none of them are worth a rendered component to assert.

Where this lives in the repo

vocion-core#349, shipped on release/2026-09-15-rail (662de773). Eight files.

Verified: type check clean, the unit suite green with the InboxService case that asserted the old count updated to assert the fix, and a full build. The browser test project fails identically on the base branch — pre-existing, untouched here.

Related: One place to decide.