From b1bbb93f37f53e94765ebd276f7a3360c7b0e683 Mon Sep 17 00:00:00 2001 From: Leo <108278866+leoisadev1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:34:26 -0400 Subject: [PATCH] fix(docs): unify lucide-react so fumadocs-core resolves once; guard the build PR #123's in-range deps refresh bumped the app's lucide-react to ^1.23.0 while the lockfile kept fumadocs-ui's edge on 1.16.0. lucide-react is a peer dependency of fumadocs-core, so bun's isolated linker materialized fumadocs-core@16.9.1 once per peer set: two module instances, two React contexts. RootProvider provided on one instance while components consumed the other, and every page crashed at hydration with "You need to wrap your application inside `FrameworkProvider`". The build stayed green, so the crash shipped and took production down; the vite 8.0.14 -> 8.1.3 bump in the same refresh was a red herring (verified innocent once lucide was unified). - Revert lucide-react to ^1.16.0 so app and fumadocs-ui share one install. - scripts/check-module-identity.ts (pre-build): fail if fumadocs-core, react, react-dom, @tanstack/react-router, or lucide-react resolve to different physical installs from the app vs fumadocs-ui. This would have failed PR #123 in CI (release:ci runs the docs build with the frozen lockfile). - scripts/check-client-bundle.ts (post-build backstop): fail if the framework-context module lands in more than one client chunk. - AGENTS.md: document the invariant for future deps refreshes. Production was restored by promoting the last good deployment (email-sdk-fumadocs-3wsvdubrx, commit 599b614); this makes main safe to deploy again. Generated-By: PostHog Code Task-Id: e66c853d-287b-4334-8055-51c53c0cd66b --- AGENTS.md | 14 ++++ apps/fumadocs/package.json | 4 +- apps/fumadocs/scripts/check-client-bundle.ts | 64 ++++++++++++++++ .../fumadocs/scripts/check-module-identity.ts | 76 +++++++++++++++++++ bun.lock | 6 +- 5 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 apps/fumadocs/scripts/check-client-bundle.ts create mode 100644 apps/fumadocs/scripts/check-module-identity.ts diff --git a/AGENTS.md b/AGENTS.md index 3bf35fd4..cd6a10f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,20 @@ HTML, and the build-time snapshot still feeds sitemap/rss/feed. Set `NOTRA_API_KEY` for the blog fetch (local: `apps/fumadocs/.env.local`; production: the Vercel project env). Without the key the fetch is skipped. +2026-07-07: The app's `lucide-react` version must resolve to the same install +fumadocs-ui uses. lucide-react is a peer dependency of fumadocs-core, so a +version split makes bun materialize fumadocs-core once per peer set; two +fumadocs-core instances mean two React contexts and every page crashes at +hydration with "You need to wrap your application inside `FrameworkProvider`" +while the build stays green (this took production down when a deps refresh +bumped only the app's copy to 1.23.0). `bun run build` now runs +`scripts/check-module-identity.ts` (pre-build, fails on any singleton split: +fumadocs-core, react, react-dom, @tanstack/react-router, lucide-react) and +`scripts/check-client-bundle.ts` (post-build backstop against the bundler +duplicating the framework-context chunk). When bumping lucide-react or +fumadocs packages, bump them together and let the identity check confirm a +single resolution. + `.github/workflows/blog-schedule.yml` refreshes the build-time snapshot (sitemap/rss/feed) by hitting a Vercel deploy hook on the `notra-published` `repository_dispatch` event, a daily cron, or manual `workflow_dispatch`. It diff --git a/apps/fumadocs/package.json b/apps/fumadocs/package.json index a18695e4..f5b7ae93 100644 --- a/apps/fumadocs/package.json +++ b/apps/fumadocs/package.json @@ -5,7 +5,7 @@ "sideEffects": false, "scripts": { "dev": "vite dev --port=4000", - "build": "bun scripts/fetch-notra-posts.ts && vite build && bun scripts/ensure-root-index.ts", + "build": "bun scripts/check-module-identity.ts && bun scripts/fetch-notra-posts.ts && vite build && bun scripts/check-client-bundle.ts && bun scripts/ensure-root-index.ts", "posts:fetch": "bun scripts/fetch-notra-posts.ts", "start": "serve .output/public --config ../../serve.json", "preview": "vite preview", @@ -24,7 +24,7 @@ "fumadocs-core": "16.9.1", "fumadocs-mdx": "15.0.9", "fumadocs-ui": "16.9.1", - "lucide-react": "^1.23.0", + "lucide-react": "^1.16.0", "marked": "^18.0.5", "posthog-js": "^1.386.6", "react": "^19.2.7", diff --git a/apps/fumadocs/scripts/check-client-bundle.ts b/apps/fumadocs/scripts/check-client-bundle.ts new file mode 100644 index 00000000..8dc9c930 --- /dev/null +++ b/apps/fumadocs/scripts/check-client-bundle.ts @@ -0,0 +1,64 @@ +// 2026-07-07: Backstop after `vite build`: the client bundle must contain at +// most one copy of fumadocs-core's framework context module. Two copies mean +// two React context instances — RootProvider writes to one while components +// read the other, crashing every page at hydration with "You need to wrap +// your application inside `FrameworkProvider`". The usual root cause is two +// physical fumadocs-core installs (see check-module-identity.ts, which runs +// before the build and catches that directly); this check additionally +// guards against the bundler itself splitting the module graph. Note it can +// miss an install-level split when the bundler merges identical module +// content, so it complements — not replaces — the identity check. +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +// The context module carries this unique error string; at most one client +// chunk may contain it. Two or more means two context instances at runtime. +// Note: byte-identical *leaf* chunks are normal here (archived docs versions +// compile the same MDX pages N times, and the lazy search graph duplicates +// tiny helpers) — only duplication of the context module is fatal, so that +// is the only thing this guard fails on. +const CONTEXT_MARKER = "FrameworkProvider"; + +const candidateDirs = [ + // Vercel Build Output (nitro vercel preset writes to the repo root) + resolve(import.meta.dirname, "../../../.vercel/output/static/assets"), + resolve(import.meta.dirname, "../.vercel/output/static/assets"), + // Local nitro output + resolve(import.meta.dirname, "../.output/public/assets"), +]; + +// Optional explicit dir (used by tests / ad-hoc runs): bun scripts/check-client-bundle.ts +// Otherwise prefer the most recently written candidate so a stale local +// .vercel/output never shadows a fresh .output build (or vice versa). +const assetsDir = process.argv[2] + ? resolve(process.argv[2]) + : candidateDirs + .filter((dir) => existsSync(dir)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]; + +if (!assetsDir) { + console.error("[check-client-bundle] no client assets directory found; looked in:"); + for (const dir of candidateDirs) console.error(` - ${dir}`); + process.exit(1); +} + +const jsFiles = readdirSync(assetsDir).filter((file) => file.endsWith(".js")); + +const markerChunks = jsFiles.filter((file) => + readFileSync(join(assetsDir, file)).includes(CONTEXT_MARKER), +); + +if (markerChunks.length > 1) { + console.error( + `[check-client-bundle] fumadocs framework context ("${CONTEXT_MARKER}") is bundled into ${markerChunks.length} client chunks — RootProvider and consumers would use different context instances and every page would crash at hydration:`, + ); + for (const file of markerChunks) console.error(` - ${file}`); + console.error( + "[check-client-bundle] the module graph is duplicated. Check for two physical fumadocs-core installs first (scripts/check-module-identity.ts), then for a bundler chunking regression.", + ); + process.exit(1); +} + +console.log( + `[check-client-bundle] ok: framework context in ${markerChunks.length === 1 ? "exactly one" : "no"} of ${jsFiles.length} client chunks in ${assetsDir}`, +); diff --git a/apps/fumadocs/scripts/check-module-identity.ts b/apps/fumadocs/scripts/check-module-identity.ts new file mode 100644 index 00000000..b14f5e28 --- /dev/null +++ b/apps/fumadocs/scripts/check-module-identity.ts @@ -0,0 +1,76 @@ +// 2026-07-07: The docs app and fumadocs-ui must resolve singleton-critical +// packages to the SAME physical install. lucide-react is a peer dependency of +// fumadocs-core, so if the app's lucide-react version diverges from the one +// fumadocs-ui resolves (as happened when a deps-refresh bumped the app to +// lucide-react 1.23.0 while the lockfile kept fumadocs-ui's edge on 1.16.0), +// bun's isolated linker materializes fumadocs-core once per peer set. Two +// fumadocs-core instances mean two React context instances: RootProvider +// provides on one, components consume the other, and every page dies at +// hydration with "You need to wrap your application inside +// `FrameworkProvider`". The build stays green, so this must be checked +// explicitly. Runs before `vite build`; exits non-zero on any split. +import { realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; + +const appDir = resolve(import.meta.dirname, ".."); + +// Packages that hold React contexts or other module-level singletons shared +// between the app and fumadocs-ui. A second instance of any of these breaks +// hydration or context lookups at runtime. +const SINGLETONS = ["fumadocs-core", "react", "react-dom", "@tanstack/react-router"]; + +function resolveFrom(baseDir: string, pkg: string): string { + const req = createRequire(resolve(baseDir, "noop.js")); + return realpathSync(dirname(req.resolve(`${pkg}/package.json`))); +} + +const fumadocsUiDir = resolveFrom(appDir, "fumadocs-ui"); + +let failed = false; + +for (const pkg of SINGLETONS) { + const fromApp = resolveFrom(appDir, pkg); + let fromUi: string; + try { + fromUi = resolveFrom(fumadocsUiDir, pkg); + } catch { + // fumadocs-ui doesn't depend on it (directly or via peers) — nothing to split. + continue; + } + + if (fromApp !== fromUi) { + failed = true; + console.error(`[check-module-identity] "${pkg}" resolves to two different installs:`); + console.error(` app -> ${fromApp}`); + console.error(` fumadocs-ui -> ${fromUi}`); + } +} + +// The usual trigger for a fumadocs-core split is lucide-react diverging +// (it is a peer of fumadocs-core), so name it explicitly when it happens. +try { + const lucideApp = resolveFrom(appDir, "lucide-react"); + const lucideUi = resolveFrom(fumadocsUiDir, "lucide-react"); + if (lucideApp !== lucideUi) { + failed = true; + console.error( + "[check-module-identity] lucide-react diverged between the app and fumadocs-ui — keep the app's lucide-react range resolving to the same version fumadocs-ui uses (it is a peer dependency of fumadocs-core and splits it when versions differ):", + ); + console.error(` app -> ${lucideApp}`); + console.error(` fumadocs-ui -> ${lucideUi}`); + } +} catch { + // lucide-react missing on one side is fine. +} + +if (failed) { + console.error( + "[check-module-identity] duplicated singleton installs crash every page at hydration (FrameworkProvider error). Align the versions in apps/fumadocs/package.json with what fumadocs-ui resolves, or run a full re-resolve so bun unifies them.", + ); + process.exit(1); +} + +console.log( + `[check-module-identity] ok: ${SINGLETONS.join(", ")} and lucide-react each resolve to a single install`, +); diff --git a/bun.lock b/bun.lock index a3b721b3..01dbc1f0 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "fumadocs-core": "16.9.1", "fumadocs-mdx": "15.0.9", "fumadocs-ui": "16.9.1", - "lucide-react": "^1.23.0", + "lucide-react": "^1.16.0", "marked": "^18.0.5", "posthog-js": "^1.386.6", "react": "^19.2.7", @@ -977,7 +977,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "lucide-react": ["lucide-react@1.16.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1489,8 +1489,6 @@ "fumadocs-mdx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.16.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ=="], - "h3/srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], "h3-v2/srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="],