From c6a733100d0ee11d5ed0d8188bd944bb35845bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ogle?= Date: Tue, 28 Jul 2026 17:05:36 -0500 Subject: [PATCH] Hydrate server-rendered pages; allow the client-IP header to be configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``. 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. --- README.md | 21 +++++-- assets/build/generate-ssr-pages.js | 15 +++++ assets/js/app.tsx | 59 ++++++++++++++++--- assets/js/ssr.tsx | 21 +++++-- config/runtime.exs | 24 +++++++- .../plugs/client_ip.ex | 15 +++-- 6 files changed, 134 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 72cd2a5..9f6eab0 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ docker run -p 4000:4000 \ and `SECRET_KEY_BASE` and will refuse to boot without them. `PHX_HOST` defaults to `example.com` but should always be set in production (it's used for absolute URLs in emails). `PORT` (default `4000`), `POOL_SIZE`, `ECTO_IPV6`, -`DNS_CLUSTER_QUERY`, `TRUST_PROXY_HEADERS`, `TRUSTED_PROXIES`, `SSR_POOL_SIZE` +`DNS_CLUSTER_QUERY`, `TRUST_PROXY_HEADERS`, `TRUSTED_PROXIES`, +`CLIENT_IP_HEADERS`, `SSR_POOL_SIZE` (number of Inertia SSR Node workers, default `2`), and `NODE_OPTIONS` (set in the Dockerfile to `--max-old-space-size=160`, caps each SSR worker's V8 heap) are optional. The last two are the main levers on the Node-side memory the SSR pool @@ -142,9 +143,21 @@ fresh rate-limit bucket per request. Proxies that reach the app from a private address need nothing further — private and reserved ranges are always treated as proxies. For a proxy on a *public* address, list its ranges in `TRUSTED_PROXIES` (comma-separated CIDRs), or it will -be mistaken for the client. Cloudflare deployments should also read -`cf-connecting-ip` instead of `x-forwarded-for`; see -`ElixirReactStarterWeb.Plugs.ClientIp` for that setting. +be mistaken for the client. + +**Behind Cloudflare, also set `CLIENT_IP_HEADERS=cf-connecting-ip`.** Cloudflare's +edge terminates on a public address, so the default `x-forwarded-for` walk stops +there and reports the edge as the caller — every visitor arriving through the +same edge shares one rate-limit bucket. That is better than the single global +bucket, and still not per-caller. `cf-connecting-ip` holds the address Cloudflare +already resolved, so there is no chain to walk. Listing Cloudflare's published +ranges in `TRUSTED_PROXIES` works too, but that list has to be kept current. + +Whichever header is trusted, a request that reaches the origin *directly* — +bypassing the proxy — can forge it and pick its own rate-limit bucket. Closing +that off means restricting origin access to the proxy (IP allowlisting, a tunnel, +or authenticated origin pulls). The exposure is limited to rate limiting, not +authorization, but it is worth deciding deliberately rather than by default. Database migrations run via the release: `bin/migrate`. See Phoenix's [deployment guides](https://hexdocs.pm/phoenix/deployment.html) for hosting specifics. diff --git a/assets/build/generate-ssr-pages.js b/assets/build/generate-ssr-pages.js index 4e08d0d..535baae 100644 --- a/assets/build/generate-ssr-pages.js +++ b/assets/build/generate-ssr-pages.js @@ -68,6 +68,15 @@ const clientEntries = [...all] .map((p) => ` ${keyFor(p.name)}: () => import('${p.importPath}'),`) .join('\n'); +const serverRendered = all + .filter((p) => p.strategy === 'ssr') + .map((p) => p.name) + .sort(); + +const serverRenderedSet = serverRendered.length + ? `new Set([\n${serverRendered.map((n) => ` '${n}',`).join('\n')}\n])` + : 'new Set()'; + const clientContent = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually // Lazy client page loaders keyed by Inertia page name (the ssr/ or client/ @@ -78,6 +87,12 @@ const pages: Record Promise<{ default: React.ComponentType }> ${clientEntries} }; +// Pages the server actually rendered (pages/ssr/). app.tsx hydrates these — +// adopting the markup already in the document — and plain-renders everything +// else: a client-only page produced no server HTML, so hydrating one would +// fail the match and push React through its recovery path for nothing. +export const serverRenderedPages = ${serverRenderedSet}; + export default pages; `; diff --git a/assets/js/app.tsx b/assets/js/app.tsx index 98cc508..6495072 100644 --- a/assets/js/app.tsx +++ b/assets/js/app.tsx @@ -4,9 +4,9 @@ import './sentry'; import './i18n'; import { go } from '@api3/promise-utils'; import { createInertiaApp, router } from '@inertiajs/react'; -import { createElement, StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import pages from './_pages'; +import { createElement, StrictMode, useEffect } from 'react'; +import { createRoot, hydrateRoot } from 'react-dom/client'; +import pages, { serverRenderedPages } from './_pages'; import { AppProviders } from './app-providers'; import ErrorBoundary from './components/ErrorBoundary'; import { syncLocale } from './components/LocaleSync'; @@ -25,6 +25,31 @@ function applyFlash(flash?: Flash) { if (flash.error) toast.error(flash.error); } +// Guards against StrictMode's deliberate double-invocation of effects in +// dev, which would otherwise raise the same flash twice. +let initialFlashApplied = false; + +/** + * Raises the flash from the *initial* page load, from an effect. + * + * Toasts live in a store outside React, so queueing one before the first + * render would put a toast in the client's tree that the server never + * rendered — a hydration mismatch, and React would discard the + * server-rendered DOM it was meant to adopt. Effects run after the + * hydration commit, so the toast arrives in a follow-up render instead. + * + * Renders nothing, so it costs no DOM on either side. + */ +function InitialFlash({ flash }: { flash?: Flash }) { + useEffect(() => { + if (initialFlashApplied) return; + initialFlashApplied = true; + applyFlash(flash); + }, [flash]); + + return null; +} + createInertiaApp({ resolve: async (name) => { const loader = pages[name]; @@ -53,9 +78,6 @@ createInertiaApp({ }); } - // Initial page load: the server embeds flash directly in initialPage props. - applyFlash(props.initialPage.props.flash as Flash | undefined); - // Client-initiated visits: `success` fires for every successful visit, // including same-URL POST → redirect flows (where `navigate` is skipped // because Inertia treats same-URL responses as history replace). @@ -63,7 +85,7 @@ createInertiaApp({ applyFlash(event.detail.page.props.flash as Flash | undefined); }); - createRoot(el).render( + const tree = ( {/* AppProviders lives INSIDE Inertia's because the providers it wraps consume page context (usePage). Using App's children @@ -80,9 +102,32 @@ createInertiaApp({ )} + ); + + // Match the mount to how the page was actually produced. + // + // For a `pages/ssr/` page the server sent real markup, which the browser + // has already parsed and painted; `hydrateRoot` adopts it, where + // `createRoot` clears the container and builds the whole tree again. It + // also surfaces divergence between the two renders, which the silent + // rebuild never did. + // + // A `pages/client/` page is deliberately absent from the SSR bundle and + // renders as a server-side no-op, so there is nothing to adopt. Hydrating + // one fails the match on every load and pushes React through its recovery + // path to reach the same result `createRoot` reaches directly. + // + // Both branches render the identical tree — only the mount differs. This + // is also why `ssr.tsx` renders ``: anything at the root on one + // side but not the other is a mismatch. + if (serverRenderedPages.has(props.initialPage.component)) { + hydrateRoot(el, tree); + } else { + createRoot(el).render(tree); + } }, http: { xsrfHeaderName: 'x-csrf-token', diff --git a/assets/js/ssr.tsx b/assets/js/ssr.tsx index ad842fe..ed5891f 100644 --- a/assets/js/ssr.tsx +++ b/assets/js/ssr.tsx @@ -6,6 +6,7 @@ import { createElement } from 'react'; import ReactDOMServer from 'react-dom/server'; import pages, { ssrClientOnly } from './_ssr_pages.ts'; import { AppProviders } from './app-providers'; +import Toaster from './components/Toaster'; import i18n from './i18n'; // Sentry for the SSR Node workers (errors only — no tracing). The DSN is @@ -48,12 +49,22 @@ export async function render(page: any) { // rendered inside AppProviders so context-dependent components // (Tooltip, etc.) work during SSR. Side-effect providers like // RealtimeProvider are safe — their useEffect doesn't run server-side. + // + // `Toaster` is here for hydration, not for output: it renders an empty + // Radix viewport element, and the client hydrates rather than rebuilds, + // so anything the client renders at the root must exist here too or + // React finds unexpected DOM and discards the subtree. Toasts are + // always client-side — the store is empty server-side, so this emits + // the viewport and nothing else. setup: ({ App, props }) => ( - - {({ Component, props: pageProps, key }) => ( - {createElement(Component, { key: key ?? undefined, ...pageProps })} - )} - + <> + + {({ Component, props: pageProps, key }) => ( + {createElement(Component, { key: key ?? undefined, ...pageProps })} + )} + + + ), }); }; diff --git a/config/runtime.exs b/config/runtime.exs index 315bb99..8fdd0ab 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -84,9 +84,31 @@ if config_env() == :prod do |> String.split(",", trim: true) |> Enum.map(&String.trim/1) + # CLIENT_IP_HEADERS overrides which header the caller's address is read + # from. Behind a CDN that terminates on a *public* address — Cloudflare + # being the common case — the default `x-forwarded-for` walk stops on the + # CDN's edge and mistakes it for the caller, because only private and + # reserved ranges are skipped automatically. Setting this to + # `cf-connecting-ip` reads the address Cloudflare resolved instead, with + # 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 here would silently disable client-IP resolution instead of + # configuring it. + client_ip_headers = + case "CLIENT_IP_HEADERS" + |> System.get_env("") + |> String.split(",", trim: true) + |> Enum.map(&(&1 |> String.trim() |> String.downcase())) + |> Enum.reject(&(&1 == "")) do + [] -> ["x-forwarded-for"] + headers -> headers + end + config :elixir_react_starter, trust_proxy_headers: System.get_env("TRUST_PROXY_HEADERS", "false") in ~w(true 1), - trusted_proxies: trusted_proxies + trusted_proxies: trusted_proxies, + client_ip_headers: client_ip_headers # The secret key base is used to sign/encrypt cookies and other secrets. # A default value is used in config/dev.exs and config/test.exs but you diff --git a/lib/elixir_react_starter_web/plugs/client_ip.ex b/lib/elixir_react_starter_web/plugs/client_ip.ex index ad650af..4a6d903 100644 --- a/lib/elixir_react_starter_web/plugs/client_ip.ex +++ b/lib/elixir_react_starter_web/plugs/client_ip.ex @@ -36,10 +36,17 @@ defmodule ElixirReactStarterWeb.Plugs.ClientIp do ranges only — e.g. Cloudflare's published egress blocks. * `:client_ip_headers` — headers to read, lowercase (default - `#{inspect(["x-forwarded-for"])}`). A single header is the safe choice: - with several enabled, a caller can add one the proxy doesn't overwrite - and interfere with parsing. Cloudflare deployments typically want - `["cf-connecting-ip"]` instead. + `#{inspect(["x-forwarded-for"])}`). Set from `CLIENT_IP_HEADERS`. A + single header is the safe choice: with several enabled, a caller can + add one the proxy doesn't overwrite and interfere with parsing. + + Behind Cloudflare, set this to `cf-connecting-ip`. Cloudflare's edge + terminates on a *public* address, so the default `x-forwarded-for` + walk stops there and reports the edge as the caller — every visitor + arriving through the same edge then shares one rate-limit bucket. + `cf-connecting-ip` holds the address Cloudflare already resolved, so + there is no chain to walk. Listing Cloudflare's published ranges in + `:trusted_proxies` also works, but that list has to be kept current. """ @behaviour Plug