fix(docs): keep the 404 title after hydration via a metadata export - #219
Merged
Conversation
The 404 document rendered a title element reading "404: This page could not be found.", correct while the document parsed. Once React hydrated, Next's metadata system applied the root layout's title.default and the tab read plain "ObjectOS" — a bookmark or history entry for a dead link was indistinguishable from the site index. not-found.tsx is installed by next-app-loader as the *page* module of the /_not-found route, so a metadata export from it is resolved like any page's. Declaring the title there makes the metadata system emit the 404 title instead of the layout default, so the parsed and hydrated titles agree. title.absolute is required because the root layout declares a title.template. The export is static, so the route stays prerendered: 577 prerendered HTML documents and 996/996 static pages, unchanged, and the served 404 is still one document byte-identical across every unmatched URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
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 #212
(Angle-bracket markup omitted throughout — this repo's sanitizer strips it, including inside code fences. "title element" means the HTML element of that name.)
All readings below are from
fdc7c16, the head of this branch, taken againstnext starton a production build, except the two rows explicitly markednext dev. Title behaviour is read in a real Chromium (Playwright, 141.0.7390.37) —curlcannot see this defect, because the whole of it is that the title is correct at parse time and wrong afterwards.The card's caveat was the right question, and the answer is: the mechanism exists
The grading comment asked whether Next supports a
metadataorgenerateMetadataexport fromnot-found.tsxon the installed version, warned that it historically has not, and asked for the answer fromnode_modulesand a build rather than from documentation. Established both ways:From
node_modules(next@16.2.6).next-app-loaderbuilds the/_not-foundentry by installing the user'snot-found.tsxas the page module of that route, not as an error boundary:collectMetadataindist/lib/metadata/resolve-metadata.jsreaches it through the ordinarygetLayoutOrPageModule(tree)path, andgetDefinedMetadatareadsmod.metadata/mod.generateMetadatawith no filter on module type. So for the unmatched-URL path — the one this card is about — an export here is read exactly like any page's.From a build. A sentinel export (
title.absolute = 'SENTINEL_404_PROBE') was built and the prerendered document read directly. The metadata title changed fromObjectOSto the sentinel, and the browser confirmed the hydrated title followed it. The probe was reverted before the real change; its restore is proven by blob identity againstHEAD(0a4c7968…), not by an exit code.So the option space was not empty, and no client-side title swap was needed. The fix is a static server-side declaration.
The defect, measured
Reproduced on current
main(e8b2062), not only on theffe36c5the card recorded. The served document has always carried two title elements in the head — the component's own, then Next's metadata title from the root layout'stitle.default. The browser takes the first while parsing; on hydration React re-inserts the metadata title ahead of it and it wins.mainate8b2062404: This page could not be found.ObjectOS404: This page could not be found.404: This page could not be found.A second finding the card did not have, and it is in the same file. #213's script sets
document.titlefor a non-default locale, and that line was being reverted by the same mechanism — measured one8b2062:main/zh-Hans/no-such-page404: 找不到此页面。ObjectOS/ja/no-such-page404: このページは見つかりませんでした。ObjectOSThe
langattribute and the body copy — the reader-facing halves #213 was actually about — survive hydration and are untouched here. Only its title line was inert.The change
One file, exactly the granted surface.
metadatais declared from the same constant that feeds the title element, so the two cannot drift:absoluteis load-bearing: the root layout declarestitle.template(%s | ObjectOS), and a plain string title would be run through it, putting the hydrated title out of step with the parsed one again — the same class of defect in a new spelling.What this does not fix, stated plainly
After hydration the title is English for every locale. This boundary sits above the
[lang]segment and cannot read the locale, and the file's own docstring records what reading it dynamically costs (577 prerendered documents down to 1). A localized hydrated title is only reachable by putting correctness into a script, which is precisely the trade the dispatch ruled out — so it is not taken.This is a strict improvement in every locale regardless: the bookmark and history entry now read as a 404 rather than as the site index, which is the whole of what the card asked for.
Measured across all seven locales, production build:
lang/no-such-page404: This page could not be found.en/zh-Hans/no-such-page404: 找不到此页面。404: This page could not be found.zh-Hans/ja/no-such-page404: このページは見つかりませんでした。404: This page could not be found.ja/de/no-such-page404: Diese Seite konnte nicht gefunden werden.404: This page could not be found.de/es/no-such-page404: No se ha podido encontrar esta página.404: This page could not be found.es/fr/no-such-page404: Cette page est introuvable.404: This page could not be found.fr/ko/no-such-page404: 이 페이지를 찾을 수 없습니다.404: This page could not be found.koNoted honestly: under
next devthe localized title survives hydration in all seven locales. Dev and prod apply metadata differently here. Prod is the behaviour that ships and prod is what the table above records.The pinned invariants, all re-measured
The route did not go dynamic.
/_not-foundis still○(Static) in the route table.mainate8b2062Still one document, byte-identical across every unmatched URL — #209's property, re-pinned over thirteen URLs. sha256 of the response with script bodies stripped (first 16 hex):
One hash per column, as required. The hash changes between columns because the title changed — that is the fix. Stripped body goes 1,534 to 1,560 bytes: exactly +26, the difference between
ObjectOSand404: This page could not be found.in the second title element, and nothing else.The strip is still what makes the reading real. #182's warning reproduces on this tree — raw
grepscores healthy 200 pages as containing the 404 copy, because it is in the RSC payload either way:Healthy controls, byte-identical to
main. Not merely "still 200" — the same sha256 on both sides:main/privacy628e47d088f3f31a/termsf8132a09c44011f7/docs/architecture2fb0915844412389/zh-Hans/docs/architecture3a58cd2567861545/ja/docs/architecture9819833d7f0aa03f/zh-Hans/privacy3ad2ba1a1d1b280d/docscd095695318ce857Redirects and the host table, every row unchanged.
/cn/docs/architecture/zh-Hans/docs/architecture/cn/privacy/zh-Hans/privacy//docsAccept-Language: zh-CNon/docs/architecture/zh-Hans/...Accept-Language: jaon/docs/architecture/ja/...Host: www.objectos.app/docs/architecturehttps://docs.objectos.ai/docs/architectureHost: www.objectos.app/https://docs.objectos.ai/Host: www.objectos.app/docs/architecture?q=1Host: www.objectos.app/zh-Hans/docs/architectureDotted machine-facing routes, unchanged:
/llms.txt200 (14,672 b),/sitemap.xml200 (299,145 b),/robots.txt200 (117 b),/docs/architecture.mdx200 (12,488 b).noindexstill present on the served 404 — this page still reaches no index, which is why the card was graded cosmetic and why nothing heavier than a static declaration was spent on it.Zero hydration complaints under
next devacrossen,zh-Hans,ja,ko: no hydration warning in the browser console and none in the dev server log. The only console entries are the 404 navigation response itself and the HMR WebSocket failing through this container's proxy — both environmental and both present onmain.Gates
All at
fdc7c16, run after the final commit,--forceon every turbo task because the cache is shared across worktrees in this container and would otherwise replay a sibling's green. Exit code captured before any pipe, and each row reads the command's own verdict line.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 35.2s,Generating static pages using 3 workers (996/996), 577 prerendered HTML filespnpm turbo run test --force3 self-test(s) passed,Tasks: 1 successful, 1 totalnode .github/scripts/check-locale-surface.mjsllmsbodies 60/60Scope
One file,
apps/docs/app/not-found.tsx, the granted surface. No changeset — this repo has no changeset flow and no.changesetdirectory;skip-changesetis not a mechanism here, as #209 recorded. No new findings were filed: the one thing found off the card's face (#213's inert title line) is in this same file and in the same defect class, is fixed by this same change, and is reported above rather than spun out.Generated by Claude Code