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..f195598
--- /dev/null
+++ b/apps/web/scripts/prerender.mjs
@@ -0,0 +1,76 @@
+// 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, 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';
+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` (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 (!(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);
+}
+
+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);
+ // 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,
+ '',
+ ``
+ );
+ 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 051c7cd..2738dd9 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';
@@ -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/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/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..ab8949f
--- /dev/null
+++ b/apps/web/src/contexts/AuthProvider.tsx
@@ -0,0 +1,56 @@
+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';
+
+/**
+ * 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;
+
+/**
+ * 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 = auth.authStateReady().then(() => {
+ authKnown = true;
+});
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState(() => (authKnown ? auth.currentUser : null));
+ const [loading, setLoading] = useState(() => !authKnown);
+
+ 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/main.tsx b/apps/web/src/main.tsx
index fb06b31..2106538 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -2,15 +2,51 @@
import './index.css';
import { StrictMode } from 'react';
+import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import App from './App.tsx';
+import { authKnownReady } from './contexts/AuthProvider';
-createRoot(document.getElementById('root')!).render(
-
-
-
-
-
-);
+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() {
+ const scroller = () => document.getElementById('layout-border');
+ const reading = { inner: scroller()?.scrollTop ?? 0, window: window.scrollY };
+ flushSync(() =>
+ createRoot(root).render(
+
+
+
+
+
+ )
+ );
+ 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
+// `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`. 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 {
+ mount();
+}
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/prerender.tsx b/apps/web/src/prerender.tsx
new file mode 100644
index 0000000..04d32a8
--- /dev/null
+++ b/apps/web/src/prerender.tsx
@@ -0,0 +1,36 @@
+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` 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);
+ 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/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 {}
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..6b8309c 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -51,3 +51,35 @@ 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 (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
+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"