When user asks to fix "X", implement only X. Don't add defensive features for edge cases they didn't mention.
Example: user asked to "update document.title on client-side navigation". First attempt added:
FALLBACK_TAG_NAMES+getReactTypeName()(module-federation edge case — never asked for)- Eager
getHeadSnapshot()replacing the existingCollectedHeadarchitecture - Tree walking with
flattenText/extractTitleTexthelpers
User response: "This looks like an over-engineered mess." Correct reaction — the actual fix is ~15 lines.
spiceflow/src/react/head.tsx uses React.cache(() => ({ tags: [] })) for per-request isolation.
Render order guarantees:
layouts/page render → <Head> runs → pushes into getHeadStore().tags
│
▼
CollectedHead renders LAST
(placed in FlightData.head)
│
▼
reads store, emits <title>...<meta>
This means any server component placed alongside CollectedHead can also read
the populated store. No need for a second tree walk or parallel mechanism.
To add title-updating on client navigation: extend CollectedHead to also render a
'use client' component with the title string. That component does
useEffect(() => { document.title = title }, [title]). Reuses the same store.
If data is already collected somewhere (via React.cache, context, etc), consume
it from its existing storage instead of walking trees or adding parallel state.
After editing root README.md, regenerate the three downstream copies:
echo '<!-- DO NOT EDIT: This file is auto-generated from root README.md -->' > ./spiceflow/README.md
echo '' >> ./spiceflow/README.md
cat ./README.md >> ./spiceflow/README.md
cp ./README.md ./website/app/readme.mdx
cp ./README.md ./website/public/readme.mdThe three generated files are gitignored, so they don't show in git status.
Symptom: dev server crashes on startup with
TypeError: Cannot destructure property 'exportsData' of
'depForEntryFileName[chunk.fileName]' as it is undefined.
at ...vite/dist/node/chunks/node.js:31312:14
Cause: pnpm stores scoped packages in .pnpm/@scope+pkg@version/ directories —
+ is pnpm's scope separator. Vite's flattenId does NOT escape +, but rolldown
replaces + with _ in chunk filenames. So if optimizeDeps.include contains an
absolute path like .pnpm/@vitejs+plugin-rsc@.../.../file.js, the key in
depForEntryFileName has + but the chunk filename has _ → mismatch → crash.
Debug technique: patch node_modules vite to log the missing chunk:
if (!depForEntryFileName[chunk.fileName]) {
console.error('[DEBUG] missing:', chunk.fileName, 'keys:', Object.keys(depForEntryFileName))
}Fix approaches:
- Use
'spiceflow > dep'nested-id syntax — Vite'snestedResolveBasediralready respectspreserveSymlinksso pnpm+paths aren't produced. Simplest. Downside: requires spiceflow to be resolvable from the project root (breaks wrapper-plugin nesting like@holocron.so/vite→ spiceflow). - Wrapper plugins can prefix:
'holocron > spiceflow > superjson'when spiceflow is nested. Wrapper knows its own name. - Use
tryResolve+ post-process.pnpm/.../node_modules/segment to get the symlinked path (our previous fix, now reverted for simplicity).
Upstream: this is a Vite/rolldown bug. flattenId should escape + or
rolldown shouldn't normalize it.
Vite 8.0.13 makes federation remote user-components chunks import the remote
client entry chunk, which executes @vitejs/plugin-rsc/browser and crashes in
the host browser with require("react-dom") missing. Set client build
preserveEntrySignatures = 'strict' for federation remotes so remote component
chunks never depend on the remote app entry.
@holocron.so/vite (at ~/Documents/GitHub/holocron) wraps spiceflow and bundles
it as a transitive dep. When wrapper plugins use spiceflow's
optimizeDeps.include entries, they need to rewrite 'spiceflow > X' to
'<wrapper-name> > spiceflow > X' so Vite can resolve them from the consumer's
project root (where only the wrapper is installed, not spiceflow directly).
Shipped pattern: a small plugin that runs AFTER spiceflowPlugin and maps
entries. Lives in holocron/vite/src/vite-plugin.ts as
rewriteSpiceflowNestedIds (commit 7aff1049 in holocron,
aebee8d in spiceflow).
const rewriteSpiceflowNestedIds: Plugin = {
name: 'holocron:rewrite-spiceflow-nested-ids',
configEnvironment(_name, config) {
if (!config.optimizeDeps?.include) return
config.optimizeDeps.include = (
config.optimizeDeps.include as string[]
).map((entry) =>
typeof entry === 'string' && entry.startsWith('spiceflow >')
? `@holocron.so/vite > ${entry}`
: entry,
)
},
}Plugin order in the returned array matters — spiceflowPlugin must come first
so its 'spiceflow > X' entries are already present when we rewrite.
CollectedHead in spiceflow/src/react/head.tsx used to pick the title in two
independent ways: the SSR <title> went through getProcessedHeadTagElements
(Map-based dedup, last .set() wins), while DocumentTitle's title prop came
from reversed.find((t) => t.type === 'title') (first match wins). For input
store [page, layout] those two code paths disagree: the Map picks page
(layout set first, then page overwrites), the find picks layout (first in
the reversed array). Result: SSR HTML shows <title>Page title</title>, then
hydration runs DocumentTitle.useEffect which sets document.title = "Layout title" and the tab silently flips.
Fix: call getProcessedHeadTagElements first, then read the title from the
deduped output via processedTags.find((t) => t.type === 'title'). One source
of truth, so SSR and client stay in sync regardless of push order.
Testing lesson: toHaveTitle retry assertions can give false positives for
race conditions — they pass as soon as the title matches once. To catch
hydration overrides, first wait for a post-hydration signal (e.g.
layout-mount-count going from 0 to 1) and then assert with a plain expect.
@vitejs/plugin-rsc vendors react-server-dom-webpack as CJS files that do
require("react"). Under pnpm's strict module isolation, this require can
resolve to a different React instance than user code's import React from 'react'.
The vendor's renderToReadableStream sets ReactSharedInternals.A (cache dispatcher)
on its React, but user code reads A from a different instance → React.cache()
returns fresh objects every call → Head/CollectedHead tag collection breaks.
Fix: resolve.dedupe: ['react', 'react-dom', ...] in RSC and SSR environments.
This forces Vite to resolve all React imports from the project root regardless of
where the importer lives. One line in configEnvironment, no custom dispatchers needed.
Wrong fix: Custom AsyncLocalStorage-backed cache dispatcher wired into
ReactSharedInternals.A (commit eb96e01). Works but reimplements React internals
and doesn't address the root cause.
During this session I tried to revert a recently-committed local change and
got blocked by the instruction "NEVER rewrite git history / amend unless
asked". Correct response: add a new follow-up commit that undoes/redoes, do
not reset/amend. That's what aebee8d does on top of 7cdae0c.
Federated RSC streaming needs cancellation wired through both layers: the outer SSE Response stream and the inner Flight stream/parser. If either side lacks cancel()/return() cleanup, aborted decodes keep reading in the background and streaming fixes look correct in happy-path tests while still leaking work.
test-e2e-start runs the built server from the app root, so Node can still fall back to source node_modules and hide missing dist/node_modules files. For standalone tracing regressions, copy dist/ into a temp directory and boot node dist/rsc/index.js there so resolution only sees traced output.
If a .page() or .layout() handler returns a Response, never place that object directly into FlightData (page/layouts) or React RSC serialization crashes with Only plain objects... {page: Response}. Redirect Responses should short-circuit out of renderReact() as raw HTTP responses; non-redirect Responses should be turned into <ThrowResponse> so browser/client navigation still goes through the existing notFound/error boundary flow.