renderToString strips the flush marker, so its output cannot be navigated by the client runtime
Reproduction
Package: @remix-run/ui · Version: 0.8.0, and current main (checked today)
renderToString removes <!-- rmx:flush document --> from its output. That marker is how the client runtime tells a whole document from a fragment, so pages served from renderToString break internal navigation on any page carrying a clientEntry — with no error of any kind.
Steps
- Serve two pages whose HTML comes from
renderToString. - Put a
clientEntryon page A, so the runtime is active there. - Link from A to B with a plain
<a href="/b">. - Click it.
The URL becomes /b; the page stays A. DevTools shows GET /b → 200.
Swapping renderToString for renderToStream (reading the stream to a string, no other change) makes the same click navigate correctly. Appending <!-- rmx:flush document --> to the renderToString output by hand also fixes it, which isolates the marker as the cause.
Where it comes from
renderToString is renderToStream with the markers stripped — packages/ui/src/server/stream.ts#L1480-L1490:
export async function renderToString(node: RemixNode): Promise<string> {
return stripFlushMarkers(
await drain(renderToStream(node, { onError(error) { throw error } })),
)
}On the client, runtime/navigation.ts turns every same-document <a> click into a frame navigation unless the anchor has data-rmx-document or download. The fetched HTML then reaches runtime/frame.ts, where:
consumeFlushBatches()looks for<!-- rmx:flush … -->; with no marker it applies nothing and returnsapplied: false.- The fallback path needs
options.flushKind === 'document'to take the full-document-reload branch — andflushKindcomes from the marker, so it is never set. - Neither branch runs, and the response is dropped.
Each step is reasonable on its own; together a valid 200 response is discarded silently.
Why this may be worth more than a doc note
packages/ui/src/server/README.md recommends renderToString for exactly this job:
Use this when you need the full output before responding (e.g. generating static pages or embedding HTML in an email).
Static pages are pages the client runtime is then asked to navigate between, so following the documented advice produces output the runtime shipped alongside it cannot consume.
Possible fixes
- Keep the marker —
renderToStringreturns a document; stripping only makes sense for a fragment embedded elsewhere. - Split the two uses — keep
renderToStringmarker-free for email/embedding, documentrenderToStreamas the one to serve; arenderDocumentToStringwould make the choice obvious. - Fail loudly — when a frame load resolves to HTML with neither a flush marker nor a recognisable fragment, call
onErroror warn instead of dropping it. - At minimum, document it — one line in the
renderToStringsection.
Happy to send a PR for whichever direction you prefer.
Note on the repro bar: the cause is pinpointed in the package source above rather than shown as a remix new app. Say the word and I will put up a minimal repository.
Expected Behavior
Clicking an internal on a page that carries a clientEntry navigates: the document is swapped for the destination that was just fetched.
Actual Behavior
The URL changes and the document does not. The destination is fetched and returns 200. No thrown error, no onError call, nothing in the console — the response is dropped because it carries no marker.
Source: remix-run/remix