Hydrate server-rendered pages; allow the client-IP header to be configured - #32
Merged
Merged
Conversation
…gured
Two fixes to things the template already ships but doesn't quite deliver.
## Server-rendered markup was being thrown away
`app.tsx` mounted with `createRoot`, which clears the container and builds
the tree again — discarding markup the SSR bundle produced and the browser
had already parsed and painted.
Measured with a MutationObserver installed ahead of any page script,
capturing a server-rendered element and checking whether that node survives
React mounting:
before / (ssr) kept=false
/login (ssr) kept=false
/register(ssr) kept=false
after / (ssr) kept=true hydrationErrors=0
/login (ssr) kept=true hydrationErrors=0
/register(ssr) kept=true hydrationErrors=0
The mount now follows how the page was produced. `pages/ssr/` pages
hydrate; `pages/client/` pages, which are deliberately absent from the SSR
bundle and render as a server-side no-op, keep using `createRoot` —
hydrating those fails the match on every load and pushes React through its
recovery path to reach the result `createRoot` reaches directly.
`generate-ssr-pages.js` emits `serverRenderedPages` alongside the existing
client-only set, so the strategy directory decides the mount and there is
no second list to keep in step.
Two supporting changes, both required for the client's first render to
match the server's:
* `ssr.tsx` renders `<Toaster />`. Server-side it emits only an empty
Radix viewport, but anything the client renders at the root has to
exist on both sides.
* The initial flash is raised from an effect rather than before render.
Toasts live in a store outside React, so queueing one pre-render put a
toast in the client tree the server never had. A module-level guard
keeps StrictMode's double-invoked effects from raising it twice.
## The client-IP header couldn't actually be configured
`ClientIp` reads `:client_ip_headers`, and both its moduledoc and the
README tell Cloudflare deployments to use `cf-connecting-ip` — but nothing
set it, so following that advice meant editing config by hand.
`CLIENT_IP_HEADERS` now wires it from the environment. This matters
because Cloudflare's edge terminates on a *public* address: the default
`x-forwarded-for` walk stops there and reports the edge as the caller,
since only private and reserved ranges are skipped automatically. Every
visitor arriving through the same edge then shares one rate-limit bucket —
better than the single global bucket `TRUST_PROXY_HEADERS` fixes, and
still not per-caller.
Set-but-empty falls back to the default rather than parsing to `[]`, since
an empty header list is how the plug switches itself off; honouring it
would silently disable client-IP resolution instead of configuring it.
The README also now names the direct-to-origin caveat: whichever header is
trusted, a request that bypasses the proxy can forge it and choose its own
bucket. That is worth deciding deliberately rather than by default.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two fixes to things the template already ships but doesn't quite deliver.
1. Server-rendered markup was being thrown away
SSR works — the Node pool renders real markup and the browser paints it. But
app.tsxmounted withcreateRoot, which clears the container and builds the tree again, discarding what the server produced.I measured it rather than inferring it: a
MutationObserverinstalled ahead of any page script captures a server-rendered element as the parser creates it, then checks whether that same node is still in the document after React mounts.//login/registerkept=falsekept=falsekept=falsekept=truekept=truekept=true…with
hydrationErrors=0on all three after the change.Why it isn't just
hydrateRooteverywhereThat was my first attempt, and it looked correct — it passed a
console.errorcheck. It wasn't. React 19 reports hydration failures throughreportError(), notconsole.error. Listening for windowerrorevents instead showed every client-only page failing the match:That's inherent to the design:
pages/client/are deliberately excluded from the SSR bundle and render as a server-side no-op, so there is nothing to adopt. Hydrating them fails on every load and pushes React through its recovery path to reach the resultcreateRootreaches directly.So the mount follows how the page was produced.
generate-ssr-pages.jsnow emitsserverRenderedPagesnext to the existing client-only set — the strategy directory decides it, with no second list to keep in step.Two supporting changes
Both are required for the client's first render to match the server's:
ssr.tsxrenders<Toaster />. Server-side it emits only an empty Radix viewport, but anything the client renders at the root must exist on both sides or it's a mismatch.2. The client-IP header couldn't actually be configured
ClientIpreads:client_ip_headers, and both its moduledoc and the README tell Cloudflare deployments to usecf-connecting-ip— but nothing ever set it, so following that advice meant hand-editing config.CLIENT_IP_HEADERSnow wires it from the environment.This matters more than it looks. Cloudflare's edge terminates on a public address, and
RemoteIponly skips private and reserved ranges automatically — so the defaultx-forwarded-forwalk stops on the edge and reports it as the caller. Every visitor arriving through the same edge shares one rate-limit bucket: better than the single global bucketTRUST_PROXY_HEADERSfixes, and still not per-caller.cf-connecting-ipholds the address Cloudflare already resolved, so there is no chain to walk.Set-but-empty falls back to the default rather than parsing to
[]— an empty header list is how the plug switches itself off, so honouring it would silently disable client-IP resolution instead of configuring it.The README also now states the direct-to-origin caveat plainly: whichever header is trusted, a request that bypasses the proxy can forge it and pick its own bucket. Closing that means restricting origin access to the proxy. The exposure is limited to rate limiting rather than authorization, but it's worth deciding deliberately.
mix precommitpasses (222 tests). The generated page registries are gitignored, so only the generator changes here.