From 049b88dc347ee774d38769c26e83abb122b148c0 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Mon, 27 Jul 2026 12:52:30 +0200 Subject: [PATCH] fix(roadmap): render the feature graph per request, not at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preprod deploy of a336539 failed and the environment has been serving a stale build since: Error: NextRouter was not mounted. Error occurred prerendering page "/roadmap/graph" Export encountered an error on /roadmap/graph, exiting the build. /roadmap/graph used getStaticProps, which makes Next prerender the page at build time. This app is a client-only SPA whose shell calls useRouter outside a mounted router, so the export step dies. Every other page here uses a no-op getServerSideProps for exactly that reason — this page was the one deviation, and the deviation is what broke. It survived a local `next build --webpack` (the same command Railway runs, verified) and failed on the Railway builder, including on a clean build with no .next cache. Rather than chase an environment difference, the page now follows the app's convention. Reading per request means parsing ~66 files per visitor, so loadVaultGraph is memoised: once per process in production, uncached in development so editing a note and refreshing still works. Also re-includes vault/ in .dockerignore. Build-time parsing tolerated the blanket `*.md` filter; runtime parsing does not, and an empty vault would render an empty graph rather than fail loudly. Verified on a clean build: /roadmap/graph is now ƒ rather than ●, serves 200, and the payload carries all 52 feature nodes. 514 tests pass. Co-Authored-By: Claude Fable 5 --- .dockerignore | 4 ++++ src/lib/vault.ts | 19 +++++++++++++++++-- src/pages/roadmap/graph.tsx | 22 ++++++++++++++-------- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.dockerignore b/.dockerignore index 32f19463..17e5e297 100644 --- a/.dockerignore +++ b/.dockerignore @@ -42,6 +42,10 @@ Thumbs.db # Documentation *.md !README.md +# The feature vault is data, not documentation: /roadmap/graph reads these notes +# at runtime, so they have to survive the *.md filter above. +!vault +!vault/** docs # Docker diff --git a/src/lib/vault.ts b/src/lib/vault.ts index 38afb37a..57ed4c9d 100644 --- a/src/lib/vault.ts +++ b/src/lib/vault.ts @@ -174,9 +174,9 @@ function readNotes(dir: string): { title: string; raw: string }[] { /** * Build the graph. Throws when a feature names an area or state that has no note, - * so a typo surfaces at build time instead of silently dropping an edge. + * so a typo surfaces on the first read instead of silently dropping an edge. */ -export function loadVaultGraph(): VaultGraph { +function buildVaultGraph(): VaultGraph { const nodes: VaultNode[] = []; const edges: VaultEdge[] = []; @@ -280,3 +280,18 @@ export function loadVaultGraph(): VaultGraph { return { nodes, edges, counts, generatedFrom: "vault/" }; } + +let cached: VaultGraph | null = null; + +/** + * The graph, parsed once per process. + * + * The vault is immutable inside a deployed container, so re-reading ~66 files on + * every request would be pure waste. In development the cache is skipped, so + * editing a note and refreshing shows the change without a restart. + */ +export function loadVaultGraph(): VaultGraph { + if (process.env.NODE_ENV !== "production") return buildVaultGraph(); + cached ??= buildVaultGraph(); + return cached; +} diff --git a/src/pages/roadmap/graph.tsx b/src/pages/roadmap/graph.tsx index 59a68a10..d1d92bc0 100644 --- a/src/pages/roadmap/graph.tsx +++ b/src/pages/roadmap/graph.tsx @@ -1,20 +1,26 @@ -import type { InferGetStaticPropsType } from "next"; +import type { InferGetServerSidePropsType } from "next"; import { VaultGraphView } from "@/components/pages/homepage/roadmap/graph"; import { loadVaultGraph } from "@/lib/vault"; /** - * The vault is a folder of files that only changes when the repo does, so it is - * read once at build time rather than on every request. This is the one page in - * the app using `getStaticProps` instead of a no-op `getServerSideProps`, and the - * reason is that: no runtime filesystem access, and a malformed note fails the - * build rather than a visitor's request. + * Read the vault per request, like every other page here. + * + * This originally used `getStaticProps` — the vault only changes when the repo + * does, so parsing it once at build time was cheaper. That made Next *prerender* + * the page, which fails: the app is a client-only SPA whose shell calls + * `useRouter` outside a mounted router, so the export step dies with + * "NextRouter was not mounted". It survived a local build and broke the Railway + * one, which is exactly the kind of difference not worth fighting. + * + * The parse is memoised in {@link loadVaultGraph}, so per-request cost is one + * read per process rather than one per visitor. */ -export const getStaticProps = () => ({ props: { graph: loadVaultGraph() } }); +export const getServerSideProps = () => ({ props: { graph: loadVaultGraph() } }); export default function Page({ graph, -}: InferGetStaticPropsType) { +}: InferGetServerSidePropsType) { return (