Skip to content

fix(docs): light the top nav on a blog post — fumadocs matches against the public path, not the /en prerender path - #23028

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22977-docs-nav-active-blog
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22977-docs-nav-active-blog

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22977

Clause-②: no

What changed

The docs site's root provider now hands fumadocs the public pathname. apps/docs/app/[lang]/layout.tsx used RootProvider from fumadocs-ui/provider/next, which is NextProvider (Next's usePathname/useRouter/useParams, next/link, next/image) around the base RootProvider. The new apps/docs/components/root-provider.tsx is that same composition. It changes one thing: usePathname is mapped through toPublicPath() (new, in apps/docs/lib/i18n.ts), which drops the hidden default-locale prefix (/en/blog/SLUG becomes /blog/SLUG, /en becomes /). Every other path is returned unchanged. This is the mapping proxy.ts already applies when it redirects /en/..., and it reads the same i18n config. lib/layout.shared.tsx gains a two-line comment pointing the nav entries at it. Four files, +70 / -1, all in apps/docs. No dependency change and no fumadocs upgrade.

Why: the mechanism, measured

  • .next/prerender-manifest.json lists the posts as /en/blog/SLUG, because every page lives under app/[lang]/. During that prerender, Next's usePathname() answers /en/blog/SLUG.
  • fumadocs-ui isLinkItemActive → isActive(link.url, pathname, nested) (dist/utils/urls.js) compares that against the public urls, /blog and /blog/the-ontology-is-the-software, so it never matches.
  • In the browser, Next's router seeds canonicalUrl from location.href (create-initial-router-state.js), so the client's usePathname() already answers the public /blog/SLUG.
  • The client therefore computes data-active=true, but React 19 does not patch an attribute that mismatches during hydration. The DOM keeps the server's false.
  • /blog is not in generateStaticParams. It renders on demand with the request path, and that is why the control lit.

On the client, toPublicPath is the identity: proxy.ts redirects every /en/... URL away, so a browser pathname never carries the prefix. The change only makes the server prerender agree with what hydration already reads. The whole-page diff below measures this.

Route chosen: match against the public path

The other route was to prerender the posts under the public path. That means moving the blog route out of app/[lang]/ and exempting /blog/* from the proxy's rewrite. It would undo the i18n routing that proxy.ts keeps on purpose, so that a future language needs only config and content. It would also fix only the blog. The docs pages are prerendered the same way (/en/docs/...) and carry the same defect, measured below. Matching against the public path fixes both at the one place fumadocs reads the pathname, and it changes no route.

Measurements: before (main) and after (this branch)

Both builds use pnpm --filter @objectstack/docs build (the CI Build Docs command), served with next start on a random port:

  • main is 9432bac07, built in a detached comparison worktree.
  • fix is this branch at 7324c3cb2.

Each page was read twice:

  • Server = the raw HTML from fetch.
  • Hydrated = the DOM in the pre-installed Chromium (Playwright, networkidle + 1.5 s).

Values are the data-active of the nav anchors.

page main server main hydrated fix server fix hydrated
/blog/context-window-is-the-constraint Why false · Blog false Why false · Blog false Why false · Blog true Why false · Blog true
/blog/metadata-driven-architecture Blog false Blog false Blog true Blog true
/blog/protocol-first-development Blog false Blog false Blog true Blog true
/blog/the-ontology-is-the-software (the essay) Why false · Blog false Why false · Blog false Why true · Blog true Why true · Blog true
/blog (control) Why false · Blog true Why false · Blog true Why false · Blog true Why false · Blog true
/ (control, no false positive) Why false · Blog false Why false · Blog false Why false · Blog false Why false · Blog false
/docs/build-without-code (control) Why false · Blog false Why false · Blog false Why false · Blog false Why false · Blog false

Blog lights on the essay as well as Why, because Blog is nested-url and the essay sits under /blog. That is what the card's "Blog on every post via nested-url" asks for.

A second run against a fresh next start of the main build gave a byte-identical reading.

Every other nav item, and every other pathname reader

The nav array is Why (url), Blog (nested-url) and the appended GitHub icon item. The GitHub item's url is absolute (https://github.com/objectstack-ai/objectstack), so it can never equal a pathname. It reads data-active=false on every page in both builds.

Because the swap is at the provider, every fumadocs pathname reader sees it, not only the nav. So the check went wider than the nav array. I diffed, across 9 pages (/, /blog, the four posts, /docs, /docs/build-without-code, /docs/upgrading), the multiset of every anchor's href and of every data-active anchor, in the server HTML and in the hydrated DOM, main against fix. The complete diff:

  • /, /blog: no difference, server or hydrated.
  • each post: only the nav data-active flips in the table above. Hrefs are unchanged.
  • docs pages, server HTML only:
    • the sidebar's current-page link now renders data-active=true (/docs/build-without-code, /docs/upgrading);
    • the page footer's prev/next links are now present (/docs/getting-started · /docs/capabilities/request-template, and /docs/api/wire-format · /docs/protocol-upgrade).
  • hydrated DOM on docs pages: no difference at all.
  • page errors: on main every docs page threw a React minified error 418 (hydration text mismatch) on load. With the fix, all 9 pages throw none.

Same defect, docs half

The docs pages are prerendered under /en/docs/..., so they had the same defect:

  • the sidebar's current page was unmarked in the server HTML;
  • the prev/next footer was missing from it, because fumadocs found no current page in the list;
  • the text mismatch made React discard the server tree and client-render it.

So the hydrated docs page looked right, after a recoverable error and a re-render. The blog's attribute-only mismatch raised no error and was never repainted. This PR adds no code for the docs half: it is fixed by the same usePathname mapping. That falls inside the claim's declared apps/docs/** surface, and the hydrated docs DOM is unchanged.

Verification

All at 7324c3cb2:

  • pnpm --filter @objectstack/docs typecheck (fumadocs-mdx && next typegen && tsc --noEmit): exit 0. The first run went red on the provider's Link/Image prop types, which proves the new file is in the program. Fixed in the second commit.
  • pnpm --filter @objectstack/docs build: exit 0, on both main and this branch.
  • node scripts/pm/dispatch-gates.mjs --commands on this diff gave 27 commands: the dispatch list plus pnpm check:docs-locale-catch-all, which lib/i18n.ts / app/[lang]/layout.tsx pull in. All 27 exit 0. --ran reconciliation: "27 derived famil(ies) accounted for — 27 run, 0 NOT-MEASURED (a DERIVED zero …)".
  • ESLint, narrowed to the 4 changed files: eslint --no-inline-config --format json reports 4 files, 0 errors, 0 warnings. The narrowing is proven three ways:
    • ESLint.isPathIgnored is false and calculateConfigForFile resolves for all four, so they are in the linted population;
    • the file count comes from the JSON output;
    • type-aware linting is off (no parserOptions.project in eslint.config.mjs; calculateConfigForFile reports none), so this diff cannot move any untouched file's verdict.
  • apps/docs has no test runner, and the card rules out a new gate. The pin is the before/after measurement above.
  • Build Docs in CI: not measured here; CI runs it.

Changeset

None. @objectstack/docs is "private": true and publishes nothing, so the PR carries skip-changeset.

Acceptance notes

  • toPublicPath() and proxy.ts's /en/... redirect are two spellings of one mapping, both read from lib/i18n.ts's config. The proxy was left untouched to keep this diff off its matcher surface. Folding the redirect onto toPublicPath() would leave one spelling. Noted, not filed; carrier: none.

Generated by Claude Code

…ht on a blog post

Posts prerender under app/[lang]/ at /en/blog/SLUG, and Next's usePathname()
answers that internal path during the prerender while every nav link (and
the docs page tree) is public. The browser-side pathname is already public,
so hydration read a match the server HTML never rendered and React kept the
server's data-active=false.

The root provider is now fumadocs' NextProvider composition with usePathname
mapped through toPublicPath() (the hidden default-locale prefix removed, the
proxy's own /en redirect mapping), so the prerender agrees with hydration.

Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 12, 2026
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Oct 12, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 11:01
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 12, 2026 11:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 5187e57 Oct 12, 2026
31 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22977-docs-nav-active-blog branch October 12, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants