From 2b76705367771674e6453cb12e4b8e507399ee8d Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 14:55:09 -0600 Subject: [PATCH 1/7] Keep Firebase out of what the public pages import The Security page imported `MIN_PASSWORD_LENGTH` from `services/signIn`, and `Layout` reads `useAuth` from a module that also holds the provider, so rendering either pulled in the Firebase SDK and started Auth. That is harmless in the browser, but it rules out rendering those pages in Node at build time: started there, Firebase Auth keeps the process from exiting. `useAuth` and its context stay in `contexts/AuthContext.tsx`, now type-only on Firebase, and `AuthProvider` moves to `contexts/AuthProvider.tsx`. The password rule moves to `config/password.ts`. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/App.tsx | 2 +- apps/web/src/config/password.ts | 6 +++ apps/web/src/contexts/AuthContext.tsx | 51 ++++++-------------------- apps/web/src/contexts/AuthProvider.tsx | 38 +++++++++++++++++++ apps/web/src/pages/Login.tsx | 3 +- apps/web/src/pages/ResetPassword.tsx | 2 +- apps/web/src/pages/Security.tsx | 2 +- apps/web/src/services/signIn.ts | 3 -- 8 files changed, 60 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/config/password.ts create mode 100644 apps/web/src/contexts/AuthProvider.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 051c7cd..29ec81f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,8 +3,8 @@ import { Navigate, Outlet, Route, Routes, useSearchParams } from 'react-router'; import Layout from './Layout'; import ProtectedRoute from './components/ProtectedRoute'; import { ScrollToTop } from './components/ScrollToTop'; -import { AuthProvider } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext'; +import { AuthProvider } from './contexts/AuthProvider'; import { KeysProvider } from './contexts/KeysContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { IndexPage } from './pages/Index'; diff --git a/apps/web/src/config/password.ts b/apps/web/src/config/password.ts new file mode 100644 index 0000000..721d593 --- /dev/null +++ b/apps/web/src/config/password.ts @@ -0,0 +1,6 @@ +/** + * What docs/e2ee.md asks of a new password, since the password is the key. Here rather than + * in services/signIn.ts so the Security page can quote it without importing Firebase, which + * the build cannot run when it prerenders that page (src/staticPages.tsx). + */ +export const MIN_PASSWORD_LENGTH = 10; diff --git a/apps/web/src/contexts/AuthContext.tsx b/apps/web/src/contexts/AuthContext.tsx index b5d6906..d0b1bc5 100644 --- a/apps/web/src/contexts/AuthContext.tsx +++ b/apps/web/src/contexts/AuthContext.tsx @@ -1,53 +1,24 @@ -import { User, signOut as firebaseSignOut, getAuth, onAuthStateChanged } from 'firebase/auth'; -import { createContext, useContext, useEffect, useState } from 'react'; - -import { auth } from '@/config/firebase'; -import { sendToExtension } from '@/services/extension'; - -interface AuthContextType { +import type { User } from 'firebase/auth'; +import { createContext, useContext } from 'react'; + +/** + * Who is signed in, for anything that reads it. The provider that fills it in is + * AuthProvider.tsx, kept apart so that reading this imports no Firebase: the static pages + * render through `Layout`, which reads it, in Node at build time (src/prerender.tsx), and + * Firebase Auth started there keeps Node from exiting. + */ +export interface AuthContextType { isAuthenticated: boolean; loading: boolean; user: User | null; signOut: () => Promise; } -const AuthContext = createContext({ +export const AuthContext = createContext({ isAuthenticated: false, loading: true, user: null, signOut: async () => {}, }); -export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const unsubscribe = onAuthStateChanged(auth, (user) => { - console.debug('onAuthStateChanged', user?.uid); - - setUser(user); - setLoading(false); - - sendToExtension({ type: 'ON_AUTH_STATE_CHANGED', user: user }); - }); - - return unsubscribe; - }, []); - - const signOut = async () => { - const auth = getAuth(); - await firebaseSignOut(auth); - }; - - const value = { - isAuthenticated: !!user, - loading, - user, - signOut, - }; - - return {!loading && children}; -} - export const useAuth = () => useContext(AuthContext); diff --git a/apps/web/src/contexts/AuthProvider.tsx b/apps/web/src/contexts/AuthProvider.tsx new file mode 100644 index 0000000..f518425 --- /dev/null +++ b/apps/web/src/contexts/AuthProvider.tsx @@ -0,0 +1,38 @@ +import { User, signOut as firebaseSignOut, getAuth, onAuthStateChanged } from 'firebase/auth'; +import { useEffect, useState } from 'react'; + +import { auth } from '@/config/firebase'; +import { AuthContext } from '@/contexts/AuthContext'; +import { sendToExtension } from '@/services/extension'; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (user) => { + console.debug('onAuthStateChanged', user?.uid); + + setUser(user); + setLoading(false); + + sendToExtension({ type: 'ON_AUTH_STATE_CHANGED', user: user }); + }); + + return unsubscribe; + }, []); + + const signOut = async () => { + const auth = getAuth(); + await firebaseSignOut(auth); + }; + + const value = { + isAuthenticated: !!user, + loading, + user, + signOut, + }; + + return {!loading && children}; +} diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx index 8092f0f..7c99059 100644 --- a/apps/web/src/pages/Login.tsx +++ b/apps/web/src/pages/Login.tsx @@ -7,10 +7,11 @@ import { Navigate, useNavigate } from 'react-router'; import Button from '@/components/Button'; import GlowButton from '@/components/GlowButton'; import { auth } from '@/config/firebase'; +import { MIN_PASSWORD_LENGTH } from '@/config/password'; import { useAuth } from '@/contexts/AuthContext'; import { useKeys } from '@/contexts/KeysContext'; import { resetActionCodeSettings } from '@/services/passwordReset'; -import { MIN_PASSWORD_LENGTH, WrongPasswordError } from '@/services/signIn'; +import { WrongPasswordError } from '@/services/signIn'; import styles from './Login.module.css'; diff --git a/apps/web/src/pages/ResetPassword.tsx b/apps/web/src/pages/ResetPassword.tsx index dd3bddb..c24f5b5 100644 --- a/apps/web/src/pages/ResetPassword.tsx +++ b/apps/web/src/pages/ResetPassword.tsx @@ -6,6 +6,7 @@ import Button from '@/components/Button'; import GlowButton from '@/components/GlowButton'; import styles from '@/components/KeyStep.module.css'; import { RecoveryKey } from '@/components/RecoveryKey'; +import { MIN_PASSWORD_LENGTH } from '@/config/password'; import { KeysChangedError, PasswordChangedError, @@ -19,7 +20,6 @@ import { setNewPassword, startNewKeys, } from '@/services/passwordReset'; -import { MIN_PASSWORD_LENGTH } from '@/services/signIn'; /* * A new recovery key is stored on the server before it is shown, and the reset link is diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 5ae91dd..7d1a037 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -2,8 +2,8 @@ import { Helmet } from 'react-helmet'; import { Link } from 'react-router'; import LegalContainer from '@/components/LegalContainer'; +import { MIN_PASSWORD_LENGTH } from '@/config/password'; import { LIMITS, SUPPORT_EMAIL } from '@/config/plan'; -import { MIN_PASSWORD_LENGTH } from '@/services/signIn'; /** * Security, `/security`: the gist of docs/e2ee.md for the people it protects. What is diff --git a/apps/web/src/services/signIn.ts b/apps/web/src/services/signIn.ts index 3887edc..e2c0611 100644 --- a/apps/web/src/services/signIn.ts +++ b/apps/web/src/services/signIn.ts @@ -29,9 +29,6 @@ import { deviceId, saveAccountKey } from './keyStore'; * `recover` or `startOver`). */ -/** What docs/e2ee.md asks of a new password, since the password is the key. */ -export const MIN_PASSWORD_LENGTH = 10; - /** Neither the password's `authKey` nor the password itself signs in. */ export class WrongPasswordError extends Error {} From e573a595b2df67bd3586c95913ea2dee8a85e90f Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 14:55:28 -0600 Subject: [PATCH 2/7] Prerender the static pages so they read without JavaScript Every path on the site was answered with `index.html`, whose `#root` is empty until the bundle runs, so with JavaScript off the terms, privacy, support and security pages were blank. Crawlers and link previews that run nothing also saw the front page's title on all of them. The build now renders those pages to HTML. `npm run prerender` (the end of `npm run build`) builds `src/prerender.tsx` for Node and writes `dist/.html` for each entry in `src/staticPages.tsx`: the built `index.html` with the page in `#root` and its own title and `og:url`. `App` routes the same list. Each page has a rewrite of its own in `firebase.json` ahead of the catch-all, and the script fails the build when one is missing, or when the renderer imports Firebase (which would hang it). `cleanUrls` would avoid the list but redirects `/index.html`, the site's image page, to `/`. With JavaScript on, `main.tsx` waits for Firebase to report who is signed in before mounting, and `AuthProvider` starts from that answer. Mounting at once rendered the provider's empty first frame over the page, blanking it briefly. The front page is left out: filling `index.html` would show it on every route until the bundle loaded. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 3 +- apps/web/scripts/prerender.mjs | 65 ++++++++++++++++++++++++++ apps/web/src/App.tsx | 12 ++--- apps/web/src/contexts/AuthProvider.tsx | 16 ++++++- apps/web/src/main.tsx | 33 ++++++++++--- apps/web/src/prerender.tsx | 35 ++++++++++++++ apps/web/src/staticPages.tsx | 29 ++++++++++++ apps/web/vite.config.ts | 7 ++- docs/web.md | 30 ++++++++++++ firebase.json | 16 +++++++ 10 files changed, 226 insertions(+), 20 deletions(-) create mode 100644 apps/web/scripts/prerender.mjs create mode 100644 apps/web/src/prerender.tsx create mode 100644 apps/web/src/staticPages.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 5013451..ae1125d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,8 @@ "scripts": { "deploy": "npm run test:rules && npm run build && firebase deploy --only hosting,firestore,storage", "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && npm run prerender", + "prerender": "vite build --ssr src/prerender.tsx --outDir node_modules/.prerender && node scripts/prerender.mjs", "build:chrome": "vite build -c vite.chrome.config.ts", "lint": "eslint .", "preview": "vite preview", diff --git a/apps/web/scripts/prerender.mjs b/apps/web/scripts/prerender.mjs new file mode 100644 index 0000000..d6fca72 --- /dev/null +++ b/apps/web/scripts/prerender.mjs @@ -0,0 +1,65 @@ +// Writes the static pages (src/staticPages.tsx) into the built site as HTML, so they read +// with JavaScript off. Runs after `vite build` (the site) and `vite build --ssr` (the +// renderer, into node_modules/.prerender), from `npm run prerender`. +// +// Each page is the built `index.html` with its body filled in and its title and og:url set, +// so it loads the same bundle and stylesheet as every other page. The bundle then renders +// over it with `createRoot` (src/main.tsx), and a signed-in visitor's nav appears as it +// does today. +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const web = (p) => fileURLToPath(new URL(`../${p}`, import.meta.url)); + +const bundle = new URL('../node_modules/.prerender/prerender.js', import.meta.url); + +// Firebase Auth, once started in Node, holds the process open: the script writes every page +// and then never exits, and the build waits with it. The static pages import none of +// Firebase (src/contexts/AuthContext.tsx, src/config/password.ts); this keeps it that way +// with a message instead of a hang. +const firebaseImport = (await readFile(bundle, 'utf8')).match(/from "firebase\/[^"]*"|import "firebase\/[^"]*"/); +if (firebaseImport) { + throw new Error(`prerender: a static page imports Firebase (${firebaseImport[0]}); see src/staticPages.tsx`); +} + +const { render, STATIC_PAGES } = await import(bundle); +const template = await readFile(web('dist/index.html'), 'utf8'); +const firebase = JSON.parse(await readFile(web('../../firebase.json'), 'utf8')); + +/** + * `html` with `from` replaced once, failing the build when `from` is not there: a change + * to index.html that moved it would otherwise ship pages with nothing filled in. A function + * replacement, so a `$` in the page's text is not read as a pattern. + */ +function fill(html, from, to) { + if (!html.includes(from)) throw new Error(`prerender: ${JSON.stringify(from)} is not in dist/index.html`); + return html.replace(from, () => to); +} + +const rewrites = firebase.hosting.rewrites; +for (const { path } of STATIC_PAGES) { + const file = `${path.slice(1)}.html`; + + // Hosting answers a path with the file of that exact name, and otherwise with the first + // rewrite that matches, which ends in `**` → index.html. So `/terms` reaches terms.html + // only through a rewrite of its own, ahead of `**`. Not `cleanUrls`, which would do it for + // every file: it also redirects `/index.html` to `/`, and the site's image page is + // `/index.html?view=…` (src/services/viewable.ts). + const own = rewrites.findIndex((r) => r.source === path && r.destination === `/${file}`); + const catchAll = rewrites.findIndex((r) => r.source === '**'); + if (own === -1 || own > catchAll) { + throw new Error(`prerender: firebase.json needs { "source": "${path}", "destination": "/${file}" } before "**"`); + } + + const { html, title } = render(path); + let page = template; + page = fill(page, 'PushThat', title); + page = fill( + page, + '', + `` + ); + page = fill(page, '
', `
${html}
`); + await writeFile(web(`dist/${file}`), page); + console.log(`prerender: ${path} → dist/${file}`); +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 29ec81f..2738dd9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,14 +12,11 @@ import Links from './pages/Links'; import Login from './pages/Login'; import NotFound from './pages/NotFound'; import Plan from './pages/Plan'; -import Privacy from './pages/Privacy'; import Push from './pages/Push'; import { ResetPassword } from './pages/ResetPassword'; -import { Security } from './pages/Security'; -import Support from './pages/Support'; -import Terms from './pages/Terms'; import { ViewPush } from './pages/ViewPush'; import { VIEW_PARAM } from './services/viewable'; +import { STATIC_PAGES } from './staticPages'; function IndexOrRedirect() { const { isAuthenticated, loading } = useAuth(); @@ -71,10 +68,9 @@ function App() { > } /> } /> - } /> - } /> - } /> - } /> + {STATIC_PAGES.map(({ path, Page }) => ( + } /> + ))} } /> }> } /> diff --git a/apps/web/src/contexts/AuthProvider.tsx b/apps/web/src/contexts/AuthProvider.tsx index f518425..dd1f25e 100644 --- a/apps/web/src/contexts/AuthProvider.tsx +++ b/apps/web/src/contexts/AuthProvider.tsx @@ -5,9 +5,21 @@ import { auth } from '@/config/firebase'; import { AuthContext } from '@/contexts/AuthContext'; import { sendToExtension } from '@/services/extension'; +/** + * Whether Firebase has said who is signed in yet. On a prerendered page src/main.tsx waits + * for that before mounting, so the provider can start from the answer: starting from + * `loading` would render nothing first, and that empty render replaces the prerendered page + * for the moment before the listener below reports. Everywhere else the app mounts at once, + * this is still false, and the provider waits for the listener as it always has. + */ +let authKnown = false; +auth.authStateReady().then(() => { + authKnown = true; +}); + export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); + const [user, setUser] = useState(() => (authKnown ? auth.currentUser : null)); + const [loading, setLoading] = useState(() => !authKnown); useEffect(() => { const unsubscribe = onAuthStateChanged(auth, (user) => { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fb06b31..b982e5b 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,11 +6,30 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import App from './App.tsx'; +import { auth } from './config/firebase'; -createRoot(document.getElementById('root')!).render( - - - - - -); +const root = document.getElementById('root')!; + +function mount() { + createRoot(root).render( + + + + + + ); +} + +// A prerendered page (src/staticPages.tsx) arrives with `#root` already holding it, and +// `createRoot` replaces that markup with its first render. `AuthProvider` renders nothing +// until Firebase has said who is signed in, so mounting at once would blank the page for as +// long as that takes. Waiting for it first keeps the page on screen until React has the same +// page to show. Elsewhere `#root` is empty and there is nothing to keep, so nothing waits. +// +// Not `hydrateRoot`: it reuses the markup only while the first render matches it exactly, +// and that first render is the provider's empty one. +if (root.hasChildNodes()) { + auth.authStateReady().then(mount, mount); +} else { + mount(); +} diff --git a/apps/web/src/prerender.tsx b/apps/web/src/prerender.tsx new file mode 100644 index 0000000..ec22b6e --- /dev/null +++ b/apps/web/src/prerender.tsx @@ -0,0 +1,35 @@ +import { renderToString } from 'react-dom/server'; +import { Helmet } from 'react-helmet'; +import { StaticRouter } from 'react-router'; + +import Layout from './Layout'; +import { STATIC_PAGES } from './staticPages'; + +export { STATIC_PAGES }; + +/** + * One static page as HTML, for `scripts/prerender.mjs`, which runs this in Node after the + * site's build (`vite build --ssr`). It renders the page in `Layout` as a signed-out visitor + * sees it, which is also the first thing the browser renders before Firebase says who is + * signed in. + * + * Not `App`: its providers read `localStorage` and IndexedDB as they start, which Node does + * not have, and these pages use none of them. `Layout` still imports the Firebase config, + * which only builds the SDK's objects and makes no request until something signs in. + */ +export function render(path: string): { html: string; title: string } { + const entry = STATIC_PAGES.find((p) => p.path === path); + if (!entry) throw new Error(`No static page at ${path}`); + const { Page } = entry; + const html = renderToString( + + + + + + ); + // react-helmet collects what the render asked for and must be emptied after every render, + // or the next page starts with this one's title. + const title = Helmet.renderStatic().title.toString(); + return { html, title }; +} diff --git a/apps/web/src/staticPages.tsx b/apps/web/src/staticPages.tsx new file mode 100644 index 0000000..e33713e --- /dev/null +++ b/apps/web/src/staticPages.tsx @@ -0,0 +1,29 @@ +import { ComponentType } from 'react'; + +import Privacy from './pages/Privacy'; +import { Security } from './pages/Security'; +import Support from './pages/Support'; +import Terms from './pages/Terms'; + +/** + * The pages that read the same for everyone and need nothing from the browser, so the build + * renders each to HTML (`npm run prerender`, `scripts/prerender.mjs`) and they show with + * JavaScript off, to crawlers and link previews, and before the bundle has loaded. `App` + * routes them from this list too, so a page cannot be prerendered under one path and routed + * under another. + * + * A page belongs here only if its first render is the same signed in or out and holds no + * state. The front page does not: every path the site does not know is answered with + * `index.html`, so filling that file would show the front page's text on `/links` and the + * rest until the bundle loaded, and a signed-in visitor to `/` would see it flash before + * being sent to their list. + * + * Each path also needs its rewrite in the root `firebase.json`; the prerender script stops + * the build when one is missing. + */ +export const STATIC_PAGES: { path: string; Page: ComponentType }[] = [ + { path: '/privacy', Page: Privacy }, + { path: '/terms', Page: Terms }, + { path: '/support', Page: Support }, + { path: '/security', Page: Security }, +]; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e8bee36..9b5d8bd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,11 +4,14 @@ import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; // https://vite.dev/config/ -export default defineConfig({ +export default defineConfig(({ isSsrBuild }) => ({ plugins: [react(), tsconfigPaths()], css: { modules: { localsConvention: 'camelCaseOnly', }, }, -}); + // The SSR build is only the static pages' renderer (`npm run prerender`); the site's build + // already copies public/ into dist/. + build: { copyPublicDir: !isSsrBuild }, +})); diff --git a/docs/web.md b/docs/web.md index c3c19aa..0897c44 100644 --- a/docs/web.md +++ b/docs/web.md @@ -51,3 +51,33 @@ change on different schedules and a site deploy should never ship a function by A push to `main` runs this deploy from CI when it changes `apps/web/`; see [deploying](deploying.md). Running it by hand still works, for a deploy from a branch. + +## Pages that read without JavaScript + +Privacy, Terms, Support and Security are prerendered. `npm run build` ends with +`npm run prerender`, which builds `src/prerender.tsx` for Node (`vite build --ssr`, into +`node_modules/.prerender`) and runs `scripts/prerender.mjs`. That writes `dist/.html` +for each entry in `src/staticPages.tsx`: the built `index.html` with the page rendered into +`#root`, the page's own title and its own `og:url`. So the pages read with JavaScript off, +and crawlers and link previews see each page's title rather than the front page's. + +Hosting reaches each file through a rewrite of its own in the root `firebase.json`, ahead of +the `**` catch-all, and the script fails the build when one is missing. `cleanUrls` would do +the same without the list, but it also redirects `/index.html` to `/`, and the site's image +page is `/index.html?view=…`. + +A page belongs on the list only if it renders the same signed in or out, holds no state and +imports nothing from Firebase. Firebase Auth started in Node keeps the script from exiting, +so the script refuses a bundle that imports Firebase rather than hang the build. That is why +`useAuth` lives apart from its provider (`src/contexts/AuthContext.tsx`, +`AuthProvider.tsx`) and the password rule is in `src/config/password.ts`. The front page is +not on the list: every unknown path is answered with `index.html`, so filling that file +would show the front page on `/links` and the rest until the bundle loaded. + +With JavaScript on, `src/main.tsx` waits for Firebase to say who is signed in, then mounts +with `createRoot`, which replaces the prerendered markup with the same page (and the nav, +for someone signed in) without blanking it. + +To preview a build with the rewrites, run the Hosting emulator. It answers 404 for every path +when `dist/` is under a folder whose name starts with a dot, such as a worktree in +`.claude/worktrees/`; serve a copy of `dist/` from somewhere else. diff --git a/firebase.json b/firebase.json index 94faaf3..92fab1d 100644 --- a/firebase.json +++ b/firebase.json @@ -7,6 +7,22 @@ "**/node_modules/**" ], "rewrites": [ + { + "source": "/privacy", + "destination": "/privacy.html" + }, + { + "source": "/terms", + "destination": "/terms.html" + }, + { + "source": "/support", + "destination": "/support.html" + }, + { + "source": "/security", + "destination": "/security.html" + }, { "source": "**", "destination": "/index.html" From 97e4a382adbb0a136d8d8a7e56b75e6e175b5109 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:06:13 -0600 Subject: [PATCH 3/7] Correct the prerender comment about what Layout imports The comment said `Layout` still imports the Firebase config and that this was harmless. It has not since `AuthProvider` moved out of `AuthContext`, and the prerender script fails the build when the renderer imports Firebase, so the sentence read as permission for the very thing the guard forbids. It now says what is true and why the guard exists. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/prerender.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/prerender.tsx b/apps/web/src/prerender.tsx index ec22b6e..04d32a8 100644 --- a/apps/web/src/prerender.tsx +++ b/apps/web/src/prerender.tsx @@ -14,8 +14,9 @@ export { STATIC_PAGES }; * signed in. * * Not `App`: its providers read `localStorage` and IndexedDB as they start, which Node does - * not have, and these pages use none of them. `Layout` still imports the Firebase config, - * which only builds the SDK's objects and makes no request until something signs in. + * not have, and these pages use none of them. `Layout` reads only contexts/AuthContext.tsx, + * which imports no Firebase, and `scripts/prerender.mjs` fails the build if anything here + * does: Firebase Auth started in Node keeps the script from exiting. */ export function render(path: string): { html: string; title: string } { const entry = STATIC_PAGES.find((p) => p.path === path); From d63d5eb5939e2a96a21d4ed62a552cd627c14420 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:06:35 -0600 Subject: [PATCH 4/7] Give each prerendered page its own og:title The prerender set `` and `og:url` but left `og:title` as the front page's headline. Slack, iMessage and X read `og:title` before `<title>`, so a shared `/terms` link still previewed as the front page, now under the Terms URL, and the docs claimed otherwise. `og:title` now carries the page's title. The descriptions stay the product's on purpose: they say what the page belongs to, which is what a card for the terms needs. `fill` takes a pattern so the script does not repeat the front page's headline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web/scripts/prerender.mjs | 23 +++++++++++++++++------ docs/web.md | 6 ++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/web/scripts/prerender.mjs b/apps/web/scripts/prerender.mjs index d6fca72..f195598 100644 --- a/apps/web/scripts/prerender.mjs +++ b/apps/web/scripts/prerender.mjs @@ -2,8 +2,10 @@ // with JavaScript off. Runs after `vite build` (the site) and `vite build --ssr` (the // renderer, into node_modules/.prerender), from `npm run prerender`. // -// Each page is the built `index.html` with its body filled in and its title and og:url set, -// so it loads the same bundle and stylesheet as every other page. The bundle then renders +// Each page is the built `index.html` with its body filled in and its title, og:title and +// og:url set, so it loads the same bundle and stylesheet as every other page, and a shared +// link's card names the page. The descriptions stay the product's: they say what the page +// belongs to, which is what a card for the terms needs. The bundle then renders // over it with `createRoot` (src/main.tsx), and a signed-in visitor's nav appears as it // does today. import { readFile, writeFile } from 'node:fs/promises'; @@ -27,12 +29,14 @@ const template = await readFile(web('dist/index.html'), 'utf8'); const firebase = JSON.parse(await readFile(web('../../firebase.json'), 'utf8')); /** - * `html` with `from` replaced once, failing the build when `from` is not there: a change - * to index.html that moved it would otherwise ship pages with nothing filled in. A function - * replacement, so a `$` in the page's text is not read as a pattern. + * `html` with `from` (a string or a pattern) replaced once, failing the build when `from` is + * not there: a change to index.html that moved it would otherwise ship pages with nothing + * filled in. A function replacement, so a `$` in the page's text is not read as a pattern. */ function fill(html, from, to) { - if (!html.includes(from)) throw new Error(`prerender: ${JSON.stringify(from)} is not in dist/index.html`); + if (!(typeof from === 'string' ? html.includes(from) : from.test(html))) { + throw new Error(`prerender: ${from} is not in dist/index.html`); + } return html.replace(from, () => to); } @@ -54,6 +58,13 @@ for (const { path } of STATIC_PAGES) { const { html, title } = render(path); let page = template; page = fill(page, '<title>PushThat', title); + // Helmet's tag holds the text already escaped for HTML, so it goes into the attribute as is. + const titleText = title.replace(/^]*>|<\/title>$/g, ''); + page = fill( + page, + //, + `` + ); page = fill( page, '', diff --git a/docs/web.md b/docs/web.md index 0897c44..6b8309c 100644 --- a/docs/web.md +++ b/docs/web.md @@ -58,8 +58,10 @@ Privacy, Terms, Support and Security are prerendered. `npm run build` ends with `npm run prerender`, which builds `src/prerender.tsx` for Node (`vite build --ssr`, into `node_modules/.prerender`) and runs `scripts/prerender.mjs`. That writes `dist/.html` for each entry in `src/staticPages.tsx`: the built `index.html` with the page rendered into -`#root`, the page's own title and its own `og:url`. So the pages read with JavaScript off, -and crawlers and link previews see each page's title rather than the front page's. +`#root`, the page's own title (as `` and `og:title`) and its own `og:url`. So the +pages read with JavaScript off, and crawlers and link previews see each page's title rather +than the front page's. The descriptions stay the product's, since they say what the page +belongs to. Hosting reaches each file through a rewrite of its own in the root `firebase.json`, ahead of the `**` catch-all, and the script fails the build when one is missing. `cleanUrls` would do From 1ef60f630ec65d71493e759e756bd37fc226d0cf Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:06:50 -0600 Subject: [PATCH 5/7] Mount prerendered pages on the promise that sets authKnown `AuthProvider` set `authKnown` in its own `authStateReady()` callback and `main.tsx` mounted in another. The provider started from the known answer only because its module registered first. Lazy-load `App` or reorder the imports and `mount` would run with the flag still false, bringing back the blank frame over the prerendered page with nothing failing to say so. `AuthProvider` now exports the promise that sets the flag, and `main.tsx` waits on that one, so the order holds by construction. `main.tsx` no longer imports the Firebase config itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web/src/contexts/AuthProvider.tsx | 8 +++++++- apps/web/src/main.tsx | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/contexts/AuthProvider.tsx b/apps/web/src/contexts/AuthProvider.tsx index dd1f25e..ab8949f 100644 --- a/apps/web/src/contexts/AuthProvider.tsx +++ b/apps/web/src/contexts/AuthProvider.tsx @@ -13,7 +13,13 @@ import { sendToExtension } from '@/services/extension'; * this is still false, and the provider waits for the listener as it always has. */ let authKnown = false; -auth.authStateReady().then(() => { + +/** + * Settles once `authKnown` is set. main.tsx mounts a prerendered page after this rather than + * after its own `authStateReady()`, so the flag is set by then whatever order the modules + * load in; two callbacks on the same promise would only be ordered by which module ran first. + */ +export const authKnownReady: Promise<void> = auth.authStateReady().then(() => { authKnown = true; }); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b982e5b..98fbab6 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,7 +6,7 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import App from './App.tsx'; -import { auth } from './config/firebase'; +import { authKnownReady } from './contexts/AuthProvider'; const root = document.getElementById('root')!; @@ -29,7 +29,7 @@ function mount() { // Not `hydrateRoot`: it reuses the markup only while the first render matches it exactly, // and that first render is the provider's empty one. if (root.hasChildNodes()) { - auth.authStateReady().then(mount, mount); + authKnownReady.then(mount, mount); } else { mount(); } From 2a6b8c2e0270e7d2334edb34d4e3c36f9d2ea353 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:07:04 -0600 Subject: [PATCH 6/7] Give the real reason main.tsx does not hydrate prerendered pages The comment said `hydrateRoot` could not work because the first render is `AuthProvider`'s empty one. That stopped being true when the provider began starting from the known answer, and a comment recording a rejected alternative on a false premise invites someone to rule it out, or back in, for the wrong reason. The reason that holds is the signed-in visitor, whose nav and menu never match the signed-out markup, so hydration would fail for them and render from scratch with an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web/src/main.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 98fbab6..ccf8719 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -26,8 +26,10 @@ function mount() { // long as that takes. Waiting for it first keeps the page on screen until React has the same // page to show. Elsewhere `#root` is empty and there is nothing to keep, so nothing waits. // -// Not `hydrateRoot`: it reuses the markup only while the first render matches it exactly, -// and that first render is the provider's empty one. +// Not `hydrateRoot`. A signed-out visitor's first render does match the markup, but a +// signed-in one's has the nav and the menu, which the prerender never has: hydration fails +// for them, React reports an error and renders from scratch anyway. `createRoot` renders +// from scratch for everyone without the error, at the cost of the DOM the reader was in. if (root.hasChildNodes()) { authKnownReady.then(mount, mount); } else { From da6e9f31d2d2bcc3b8831be52b4b8f57405f3313 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:08:13 -0600 Subject: [PATCH 7/7] Keep the reader's place when the app mounts over a prerendered page A prerendered page can be read and scrolled before the bundle loads, and `/terms#refunds` lands on its section at once. The page scrolls inside `#layout-border`, which `createRoot` replaced with a new element at the top, and then `ScrollToTop`'s mount effect scrolled to the top again, so the reader was thrown back to the start. `ScrollToTop` now scrolls only when the location changes from the one it mounted on, kept in a ref because StrictMode runs the effect twice. `mount` reads the offset, commits the first render with `flushSync`, and puts the offset back. Keyboard focus is still lost for someone who tabbed in before the bundle loaded: carrying it over would mean finding the same element in the new tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web/src/components/ScrollToTop.tsx | 20 +++++++++++------- apps/web/src/main.tsx | 27 +++++++++++++++++++------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ScrollToTop.tsx b/apps/web/src/components/ScrollToTop.tsx index 8e26d3d..5da1f83 100644 --- a/apps/web/src/components/ScrollToTop.tsx +++ b/apps/web/src/components/ScrollToTop.tsx @@ -1,17 +1,23 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router'; +/** + * Scrolls to the top when the app moves to another page. Not on the page it mounted on: that + * one is where the browser put it, at the top, at a `#fragment`, or (on a prerendered page, + * src/main.tsx) wherever the reader had got to before the bundle loaded. The location is kept + * in a ref rather than a "first run" flag because StrictMode runs the effect twice on mount + * and keeps refs between the two runs. + */ export function ScrollToTop() { const { pathname, search } = useLocation(); + const scrolledFor = useRef(pathname + search); useEffect(() => { - if (typeof window !== 'undefined') { - window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); - document.getElementById('layout-border')?.scrollTo({ top: 0, left: 0, behavior: 'auto' }); - } + if (scrolledFor.current === pathname + search) return; + scrolledFor.current = pathname + search; + window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + document.getElementById('layout-border')?.scrollTo({ top: 0, left: 0, behavior: 'auto' }); }, [pathname, search]); return null; } - - diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ccf8719..2106538 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,7 @@ import './index.css'; import { StrictMode } from 'react'; +import { flushSync } from 'react-dom'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; @@ -10,14 +11,28 @@ import { authKnownReady } from './contexts/AuthProvider'; const root = document.getElementById('root')!; +/** + * Mounts the app. On a prerendered page the reader may already have scrolled, or the browser + * may have jumped to a `#fragment`, and the page scrolls inside `#layout-border`, which + * `createRoot` replaces with a new element at the top. So the offset is read first, the + * first render is committed synchronously, and the offset is put back on the new element. + * Keyboard focus is not carried over: it would mean finding the same element in the new + * tree, for someone who tabbed in before the bundle loaded. + */ function mount() { - createRoot(root).render( - <StrictMode> - <BrowserRouter> - <App /> - </BrowserRouter> - </StrictMode> + const scroller = () => document.getElementById('layout-border'); + const reading = { inner: scroller()?.scrollTop ?? 0, window: window.scrollY }; + flushSync(() => + createRoot(root).render( + <StrictMode> + <BrowserRouter> + <App /> + </BrowserRouter> + </StrictMode> + ) ); + if (reading.inner) scroller()?.scrollTo({ top: reading.inner }); + if (reading.window) window.scrollTo({ top: reading.window }); } // A prerendered page (src/staticPages.tsx) arrives with `#root` already holding it, and