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"