#11808·remix

renderToString strips the flush marker, so its output cannot be navigated by the client runtime

Author: kuboonCreated Sep 6, 2026Updated Sep 17, 2026

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

  1. Serve two pages whose HTML comes from renderToString.
  2. Put a clientEntry on page A, so the runtime is active there.
  3. Link from A to B with a plain <a href="/b">.
  4. Click it.

The URL becomes /b; the page stays A. DevTools shows GET /b200.

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:

typescript
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:

  1. consumeFlushBatches() looks for <!-- rmx:flush … -->; with no marker it applies nothing and returns applied: false.
  2. The fallback path needs options.flushKind === 'document' to take the full-document-reload branch — and flushKind comes from the marker, so it is never set.
  3. 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

  1. Keep the markerrenderToString returns a document; stripping only makes sense for a fragment embedded elsewhere.
  2. Split the two uses — keep renderToString marker-free for email/embedding, document renderToStream as the one to serve; a renderDocumentToString would make the choice obvious.
  3. Fail loudly — when a frame load resolves to HTML with neither a flush marker nor a recognisable fragment, call onError or warn instead of dropping it.
  4. At minimum, document it — one line in the renderToString section.

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.