Skip to content

fix(docs): apply the requested locale to the 404 page in the browser - #213

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-191-404-locale
Aug 26, 2026
Merged

fix(docs): apply the requested locale to the 404 page in the browser#213
hotlong merged 1 commit into
mainfrom
claude/issue-191-404-locale

Conversation

@hotlong

@hotlong hotlong commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #191

(Angle-bracket markup omitted throughout — this repo's sanitizer strips it, including inside code fences. "script element" and "html element" mean the HTML elements of those names.)

All readings below are from afdf0c1, the head of this branch, taken against next start on a production build, with a same-session baseline build of main at ffe36c5 for the before column. Never read off the source.

Read this first: the card's candidate was built, and it is not viable

The card names one candidate — read a middleware-set header through headers() — and asks for it to be treated as a hypothesis. It was built and measured. It regresses four merged fixes and collapses the site's prerendering.

app/not-found.tsx is the root not-found boundary, so it sits in every route's tree. A dynamic API in it opts the whole app out of static generation. Attribution is isolated: the run below had middleware.ts restored to be byte-identical to HEAD (blob 8858b258… on both sides), so the collapse is attributable to this one file.

main ffe36c5 option 1 (headers())
prerendered HTML documents 577 1
/ static ƒ dynamic
/[lang] SSG ƒ dynamic
/[lang]/docs/[[...slug]] SSG ƒ dynamic
/[lang]/privacy, /[lang]/terms SSG ƒ dynamic
/_not-found static ƒ dynamic

With no prerendered param set left, dynamicParams = false has nothing to reject against, so it stops rejecting:

request main ffe36c5 option 1 what that undoes
/foo.bar/privacy 404, 1,518 stripped bytes, real copy 200, a whole privacy page #209 / #180
/foo.bar/terms 404, real copy 200 #209 / #180
/1.2.3/privacy 404, real copy 200 #209 / #180
/foo.bar/docs 404, real copy 500 #180
/foo.bar 404, real copy 307 to /foo.bar/docs #209
/favicon.ico 404, real copy 307 to /favicon.ico/docs #209
/docs/no-such-page 404, real copy 404, 594-byte empty error shell, __next_error__ #182 / #192
/zh-Hans/docs/no-such-page 404, real copy 404, 594-byte empty error shell #182 / #192

The 594 bytes are exactly the shell #209 measured on the notFound() variant. Option 1 buys a correct lang on one URL family — paths matching no route at all — and pays for it with every other row in that table.

The comment already in app/layout.tsx predicted the static-generation half of this. The rest was only visible by building it.

What ships instead

middleware.ts already redirects a prefix-less path to the reader's negotiated locale before this page renders. Measured on main: Accept-Language: zh-CN on /no-such-page answers 307 to /zh-Hans/no-such-page, and on /docs/no-such-page answers 307 to /zh-Hans/docs/no-such-page. So the locale is already in the URL for every request that has one at all, and a header adds no information the URL does not carry.

One file, one script element at the end of the body: read the first path segment, and if it is an enumerated locale, set document.documentElement.lang, the title, and the copy. No dynamic API, so the route stays prerendered.

middleware.ts was read and not touched — the diff is apps/docs/app/not-found.tsx alone.

The cost, stated plainly

The document declares en at parse time and is corrected when the script runs. Two things make that the right trade here rather than a fudge:

  • It is invisible to indexing. A 404 body is not indexed, and the served markup carries meta name="robots" content="noindex" — confirmed present on this tree.
  • It is never inconsistent. lang and the copy are set together in one synchronous block, so the page is English-declaring-English or Chinese-declaring-Chinese, never one over the other. With scripting off it is byte-for-byte what main serves today.

Verification — the fix, in a real browser

Chromium against next start, both trees in one run:

tree /zh-Hans/docs/no-such-page after load
main ffe36c5 lang="en", copy This page could not be found.
this branch afdf0c1 lang="zh-Hans", copy in Chinese

All seven locales, and the fallbacks:

request status lang after load copy after load
/zh-Hans/no-such-page 404 zh-Hans Chinese
/zh-Hans/docs/no-such-page 404 zh-Hans Chinese
/ja/docs/no-such-page 404 ja Japanese
/ko/no-such-page 404 ko Korean
/no-such-page 404 en English — correct, no locale in the URL
/foo.bar/privacy 404 en English — correct, middleware's dot exemption means no locale exists
/favicon.ico 404 en English — correct, same reason

No hydration revert, and that is not incidental. Without suppressHydrationWarning on the copy element React treats the rewritten text as a mismatch and patches English back in. Measured with hydration complaints collected from the console: zero on the production build and zero under next dev, where strict mode makes hydration checks loudest. next dev was checked separately because #181 established that the missing-root-layout validator runs only there — it is silent, and all five 404 URLs server-render the copy in dev.

Scripting disabled — the degradation path: lang="en", English copy. Exactly today's behaviour.

Verification — nothing else moved

The byte-identity property #209 pinned is preserved. Still one prerendered document, served byte-identical for every unmatched URL. sha256 of the script-stripped response:

/no-such-page              479133a86f456e70…
/docs/no-such-page         479133a86f456e70…
/zh-Hans/docs/no-such-page 479133a86f456e70…
/ja/docs/no-such-page      479133a86f456e70…
/zh-Hans/no-such-page      479133a86f456e70…
/foo.bar/privacy           479133a86f456e70…
/foo.bar/terms             479133a86f456e70…
/foo.bar/docs              479133a86f456e70…
/foo.bar                   479133a86f456e70…
/favicon.ico               479133a86f456e70…
/1.2.3/privacy             479133a86f456e70…

The whole change to the served 404 markup, diffed against ffe36c5, script bodies stripped and build-varying asset hashes masked — two changes, nothing else:

  • the copy element gains id="nf-message";
  • the noindex meta moves two positions later in the head, a React head-ordering artifact of the added script element. Still present.

Stripped body: 1,483 to 1,499 bytes. Raw: 8,825 to 11,368.

Every #209 control, re-run on both trees. Status and stripped-body evidence, not status alone.

request main ffe36c5 this branch
/no-such-page 404, real body unchanged — #168 preserved
/docs/no-such-page 404, real body unchanged — #182 / #192 preserved
/zh-Hans/docs/no-such-page 404, real body unchanged
/foo.bar/privacy, /foo.bar/terms, /foo.bar/docs, /foo.bar 404, real body unchanged — #209 preserved
/favicon.ico, /1.2.3/privacy 404, real body unchanged — #209 preserved
/docs/architecture 200, lang=en unchanged
/zh-Hans/docs/architecture 200, lang=zh-Hans unchanged
/ja/docs/architecture 200, lang=ja unchanged
/privacy, /terms, /zh-Hans/privacy 200 unchanged
/docs, / 200, 307 to /docs unchanged
/cn/docs/architecture 308 to /zh-Hans/docs/architecture unchanged
/cn/privacy 308 to /zh-Hans/privacy unchanged
Accept-Language: zh-CN on /docs/architecture 307 to /zh-Hans/... unchanged
Accept-Language: ja on /docs/architecture 307 to /ja/... unchanged
Host: www.objectos.app on /docs/architecture, /, /zh-Hans/docs/architecture 308 to https://docs.objectos.ai/..., prefix kept unchanged
/llms.txt, /robots.txt, /sitemap.xml, /docs/architecture.mdx 200, byte-identical unchanged

The healthy 200 pages are byte-identical, proven rather than asserted. Their raw sha differs between builds because two _next/static chunk URLs carry a content hash that moves on any code change. With script bodies stripped and those URLs masked (masking verified to leave zero unmasked _next/static references), all seven control documents are byte-identical between ffe36c5 and afdf0c1:

docs.html                      IDENTICAL
docs/architecture.html         IDENTICAL
zh-Hans/docs/architecture.html IDENTICAL
ja/docs/architecture.html      IDENTICAL
privacy.html                   IDENTICAL
terms.html                     IDENTICAL
zh-Hans/privacy.html           IDENTICAL

The naive command still lies, reconfirmed on this tree — raw grep scores the 404 copy present on a healthy 200 page, because it is in the RSC payload either way:

                        raw grep   stripped
/foo.bar/privacy           3           1
/no-such-page              3           1
/docs/architecture         1           0     -- healthy page the naive command scores as a 404
/privacy                   1           0     -- ditto

The raw count on a 404 is 3 rather than #209's 1 because the copy now appears in the markup, in the inline script's table, and in the payload. On healthy pages it is unchanged at 1, which is the measurement that the copy table does not ship to non-404 routes.

Nothing left the enumeration. 996/996 and 577 prerendered HTML files on both sides — the figure #192, #204 and #209 all recorded.

Gates

All at afdf0c1, exit code captured before any pipe, each read from the command's own verdict line. --force on every turbo task: the cache is shared across worktrees in this container and would otherwise replay a sibling's green.

gate result
pnpm turbo run type-check --continue --force Tasks: 1 successful, 1 total (script echoed: fumadocs-mdx && next typegen && tsc --noEmit)
pnpm turbo run build --force Compiled successfully in 38.9s, Generating static pages (996/996), 577 prerendered HTML files
pnpm turbo run test --force Tasks: 1 successful, 1 total16 case(s) over 9 rule(s) and 3 artifact(s), 3 self-test(s) passed
node .github/scripts/check-locale-surface.mjs exit 0 — 346/346 sitemap URLs, 0 unexpected, 0 missing; both llms bodies 60/60
node .github/scripts/check-node-floor.mjs exit 0 — "Every declared floor clears what the dependency tree requires"
node .github/scripts/check-translations.mjs exit 0 — "translations gate passed"
node .github/scripts/check-translation-ownership.mjs --actor hotlong --files (diff) exit 0 — 0 translation artifacts, 1 other file

Scope

One file, apps/docs/app/not-found.tsx — narrower than the granted surface, which also allowed middleware.ts. No changeset (this repo has no changeset flow); skip-changeset is not a mechanism here.

No locale sibling files were touched, so the AGENTS.md translation split is not engaged. The seven-locale copy table is UI copy in app code, the shape app/[lang]/privacy/page.tsx already uses; a locale in i18n.ts but missing from the table keeps English, the same fallback Fumadocs applies to an untranslated page.

Filed, not fixed here

One judgement call that is not mine

The card's title is "declares en whatever locale was requested". This branch corrects the declaration when the script runs, not at parse time. If the intent was specifically a server-rendered correct lang, the measurement above says the option space for that is empty: option 1 costs the four merged fixes and 576 prerendered documents, and app/[lang]/not-found.tsx was measured a byte-identical no-op in #182. In that case the honest outcome is to close #191 as won't-fix with this measurement attached, and this PR should be dropped rather than merged. Left as a draft for that reason.


Generated by Claude Code

`app/not-found.tsx` sits above the `[lang]` segment and cannot read the
locale, so it has always declared `en` and served English copy — including
to a reader who was redirected to `/zh-Hans/...` a moment earlier.

The header-through-`headers()` route was built and measured first. It is not
viable: this file is the root not-found boundary, so a dynamic API in it opts
the entire app out of static generation. Prerendered documents fall from 577
to 1, `dynamicParams = false` stops rejecting anything, and `/foo.bar/privacy`
(200) and `/foo.bar/docs` (500) come back along with the 594-byte empty error
shell on `/docs/no-such-page`.

`middleware.ts` already redirects a prefix-less path to the negotiated locale
before this page renders, so the locale is in the URL whenever it exists at
all. A blocking inline script reads it from `location.pathname` and applies it.
The page stays prerendered and byte-identical for every unmatched URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
@hotlong
hotlong marked this pull request as ready for review August 26, 2026 04:57
@hotlong
hotlong merged commit 9a5a032 into main Aug 26, 2026
2 checks passed
hotlong added a commit that referenced this pull request Aug 26, 2026
…219)

The served 404 carried two title elements: the component's own, then Next's metadata title from the root layout's title.default. The browser took the first while parsing; on hydration React re-inserted the metadata title ahead of it and the tab read plain ObjectOS -- a bookmark or history entry for a dead link was indistinguishable from the site index.

next-app-loader installs not-found.tsx as the page module of /_not-found rather than as an error boundary, so collectMetadata reads a metadata export here through the ordinary getLayoutOrPageModule path. Confirmed from node_modules and by a sentinel probe read out of the prerendered document.

title.absolute rather than a plain string: the root layout declares title.template, which would otherwise run the 404 title through it and reintroduce the drift in a new spelling. The export derives from the same constant that feeds the title element so the two cannot diverge.

This also repairs #213's title line, which the same mechanism had been reverting since it landed -- /zh-Hans/no-such-page showed the localized title at parse time and ObjectOS after. #213's lang attribute and body copy were unaffected and are untouched here.

After hydration the title is English in every locale: this boundary sits above the [lang] segment, and reading the locale dynamically costs 577 prerendered documents. Not taken.

/_not-found still Static, 577 prerendered files, 996/996. One stripped-body hash across thirteen unmatched URLs, +26 bytes versus main -- exactly the title difference. Healthy controls byte-identical.

Closes #212
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A docs 404 declares en whatever locale was requested — the route that serves it sits above the locale segment

2 participants