Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/fumadocs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions apps/fumadocs/scripts/check-client-bundle.ts
Original file line number Diff line number Diff line change
@@ -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 <assetsDir>
// 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}`,
);
76 changes: 76 additions & 0 deletions apps/fumadocs/scripts/check-module-identity.ts
Original file line number Diff line number Diff line change
@@ -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`,
);
6 changes: 2 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.