fix(docs): apply the requested locale to the 404 page in the browser - #213
Merged
Conversation
`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
marked this pull request as ready for review
August 26, 2026 04:57
This was referenced Aug 26, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 againstnext starton a production build, with a same-session baseline build ofmainatffe36c5for 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.tsxis 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 hadmiddleware.tsrestored to be byte-identical to HEAD (blob8858b258…on both sides), so the collapse is attributable to this one file.mainffe36c5headers())/○staticƒdynamic/[lang]●SSGƒdynamic/[lang]/docs/[[...slug]]●SSGƒdynamic/[lang]/privacy,/[lang]/terms●SSGƒdynamic/_not-found○staticƒdynamicWith no prerendered param set left,
dynamicParams = falsehas nothing to reject against, so it stops rejecting:mainffe36c5/foo.bar/privacy/foo.bar/terms/1.2.3/privacy/foo.bar/docs/foo.bar/foo.bar/docs/favicon.ico/favicon.ico/docs/docs/no-such-page__next_error__/zh-Hans/docs/no-such-pageThe 594 bytes are exactly the shell #209 measured on the
notFound()variant. Option 1 buys a correctlangon 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.tsxpredicted the static-generation half of this. The rest was only visible by building it.What ships instead
middleware.tsalready redirects a prefix-less path to the reader's negotiated locale before this page renders. Measured onmain:Accept-Language: zh-CNon/no-such-pageanswers307to/zh-Hans/no-such-page, and on/docs/no-such-pageanswers307to/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.tswas read and not touched — the diff isapps/docs/app/not-found.tsxalone.The cost, stated plainly
The document declares
enat parse time and is corrected when the script runs. Two things make that the right trade here rather than a fudge:meta name="robots" content="noindex"— confirmed present on this tree.langand 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 whatmainserves today.Verification — the fix, in a real browser
Chromium against
next start, both trees in one run:/zh-Hans/docs/no-such-pageafter loadmainffe36c5lang="en", copyThis page could not be found.afdf0c1lang="zh-Hans", copy in ChineseAll seven locales, and the fallbacks:
langafter load/zh-Hans/no-such-pagezh-Hans/zh-Hans/docs/no-such-pagezh-Hans/ja/docs/no-such-pageja/ko/no-such-pageko/no-such-pageen/foo.bar/privacyen/favicon.icoenNo hydration revert, and that is not incidental. Without
suppressHydrationWarningon 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 undernext dev, where strict mode makes hydration checks loudest.next devwas 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:
The whole change to the served 404 markup, diffed against
ffe36c5, script bodies stripped and build-varying asset hashes masked — two changes, nothing else:id="nf-message";noindexmeta 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.
mainffe36c5/no-such-page/docs/no-such-page/zh-Hans/docs/no-such-page/foo.bar/privacy,/foo.bar/terms,/foo.bar/docs,/foo.bar/favicon.ico,/1.2.3/privacy/docs/architecturelang=en/zh-Hans/docs/architecturelang=zh-Hans/ja/docs/architecturelang=ja/privacy,/terms,/zh-Hans/privacy/docs,//docs/cn/docs/architecture/zh-Hans/docs/architecture/cn/privacy/zh-Hans/privacyAccept-Language: zh-CNon/docs/architecture/zh-Hans/...Accept-Language: jaon/docs/architecture/ja/...Host: www.objectos.appon/docs/architecture,/,/zh-Hans/docs/architecturehttps://docs.objectos.ai/..., prefix kept/llms.txt,/robots.txt,/sitemap.xml,/docs/architecture.mdxThe healthy 200 pages are byte-identical, proven rather than asserted. Their raw sha differs between builds because two
_next/staticchunk 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/staticreferences), all seven control documents are byte-identical betweenffe36c5andafdf0c1:The naive command still lies, reconfirmed on this tree — raw
grepscores the 404 copy present on a healthy 200 page, because it is in the RSC payload either way: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.--forceon every turbo task: the cache is shared across worktrees in this container and would otherwise replay a sibling's green.pnpm turbo run type-check --continue --forceTasks: 1 successful, 1 total(script echoed:fumadocs-mdx && next typegen && tsc --noEmit)pnpm turbo run build --forceCompiled successfully in 38.9s,Generating static pages (996/996), 577 prerendered HTML filespnpm turbo run test --forceTasks: 1 successful, 1 total—16 case(s) over 9 rule(s) and 3 artifact(s),3 self-test(s) passednode .github/scripts/check-locale-surface.mjsllmsbodies 60/60node .github/scripts/check-node-floor.mjsnode .github/scripts/check-translations.mjsnode .github/scripts/check-translation-ownership.mjs --actor hotlong --files (diff)Scope
One file,
apps/docs/app/not-found.tsx— narrower than the granted surface, which also allowedmiddleware.ts. No changeset (this repo has no changeset flow);skip-changesetis 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.tsxalready uses; a locale ini18n.tsbut missing from the table keeps English, the same fallback Fumadocs applies to an untranslated page.Filed, not fixed here
ObjectOSonce React hydrates, losing the404:prefix. Measured pre-existing onffe36c5and reproduced identically on this branch, so it is not introduced here. [finding] The 404 document's title reverts to "ObjectOS" once React hydrates, losing the "404:" prefix #212 is not addressed in this PR.One judgement call that is not mine
The card's title is "declares
enwhatever locale was requested". This branch corrects the declaration when the script runs, not at parse time. If the intent was specifically a server-rendered correctlang, the measurement above says the option space for that is empty: option 1 costs the four merged fixes and 576 prerendered documents, andapp/[lang]/not-found.tsxwas 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