Skip to content

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

Closed

Description

@hotlong

Blocked-by: #182

Split out of #182 when its first dispatch falsified the original fix and established the real mechanism. That card keeps the half with a measured one-line solution; this is the half that needs a design.

What will be true after #182 lands

#182 sets dynamicParams = false on the docs route, so an unknown docs slug stops rendering dynamically and resolves to the prerendered /_not-found route instead. That fixes the blank body — the 404 markup will be server-rendered.

But /_not-found sits above the [lang] segment. It cannot read the locale, and apps/docs/app/not-found.tsx hardcodes en for exactly that reason (#168, deliberately, matching what main rendered before the shell moved down).

So after #182: /zh-Hans/docs/no-such-page will serve a correct, visible, server-rendered 404 page — in English, declaring lang="en", to a reader who was browsing in Chinese.

That is strictly better than today's blank page. It is still wrong.

Why this is not a one-liner

The locale has to come from above the [lang] segment, and there is no route param there. The route this lands on is /_not-found, which by construction never sees the locale segment.

The candidate named in #182's investigation: read a middleware-set header through headers(). middleware.ts already computes the locale for every request (it negotiates and redirects on it), so the value exists — the question is getting it into a route that has no params.

That makes /_not-found a dynamic route. For a 404 that is probably an acceptable trade, but it is a real property change and it is unverified — nobody has built it. Treat it as a hypothesis, the way #182's original fix should have been treated.

Prove the mechanism before writing the implementation. #182 is the cautionary case: its proposed fix was reasonable, widely-recommended, and a measured no-op, and that only surfaced because the dev built it and diffed the output rather than trusting the shape.

Constraints for whoever takes this

Worth pricing honestly before dispatch

The payoff is smaller than #182's. A crawler hitting this URL is told 404 either way, correctly, and a 404 body is not indexed. The real audience is a human who followed a stale link while browsing in their own language and gets an English page — worth fixing for a site that ships seven locales, but not urgent, and it should not be priced as an SEO card.

Do not dispatch before #182 has merged: the route this fix targets is only reached once dynamicParams = false is in place.

Activity

  1. added theissue type on Aug 26, 2026
  2. added
    pm:queueReady for the PM dispatch loop
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 26, 2026
  3. removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 26, 2026
  4. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    Unblocked — #182's flag landed (6653744) and #180 merged (ffe36c5).

    Claim: PM loop round 11
    Session: session_01G5zjYc2BoFV2NjKBBapC7C
    Branch: claude/issue-191-404-locale
    Worktree: objectos-issue-191
    Domain: n/a (objectos carries no domain:* lanes)
    File surface: apps/docs/app/not-found.tsx, apps/docs/middleware.ts. Stop on breach; explain in the report.
    Container and model: M, mode:subagent, model: opus — the mechanism is unbuilt and the card's own instruction is to treat it as a hypothesis.
    Clause-②: no

    The card's scope just grew, and its cost just became visible

    #180 landed dynamicParams = false on the [lang] segment, so it is no longer only unknown docs slugs that reach /_not-found — every non-locale first segment does too. More URLs now serve the English 404.

    But #209 measured something the card was written without, and it changes the trade. That 404 is currently one prerendered document, served byte-identical everywhere — sha256 fa59d702... across seven distinct bogus URLs and /no-such-page alike. Making it locale-aware means giving that up: a header-driven 404 is a dynamic route, rendered per request, on a path where #182 already proved that dynamic rendering plus notFound() yields an empty shell.

    So price it honestly before building it. The card already says the SEO value is near zero — a 404 body is not indexed, and a crawler is told 404 correctly either way. The real audience is a human who followed a stale link while browsing in their own language.

    Three options. Measure, then recommend — do not assume the card's is best.

    1. Middleware header read through headers() — the card's candidate. Makes /_not-found dynamic. Verify what that costs: does the prerendered 404 disappear from the build output, and does the served body survive, or does it come back as the 594-byte empty shell fix(docs): reject a non-locale first segment at the route level #209 measured on the notFound() variant?
    2. Client-side locale swap — keep the page prerendered and static, read the locale from the URL in the browser, swap the copy. The lang attribute is then wrong at parse time, which matters for indexing and matters not at all on a page that is never indexed. Cheapest by a distance if the only real audience is a human reading it.
    3. Leave it. A defensible outcome. Say so with the measurement if options 1 and 2 both cost more than the problem.

    needs_decision is an acceptable terminal outcome here, and so is a recommendation not to do it.

    Constraints

    Verify with the script-stripped curl from #182 — a raw grep cannot distinguish server-rendered markup from the RSC payload and will report success on a page that has none. #209 reconfirmed that trap on this exact tree: raw grep scores the 404 copy present on healthy 200 pages.


    Generated by Claude Code

  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 26, 2026
  6. self-assigned this
    on Aug 26, 2026
  7. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 191,
      "status": "done",
      "branch": "claude/issue-191-404-locale",
      "pr": "https://github.com/objectstack-ai/objectos/pull/213",
      "premise_still_valid": true,
      "summary": "Priced all three options against real builds before writing the shipped one. Option 1 (the card's candidate, middleware header read through headers()) was built and is not viable: app/not-found.tsx is the ROOT not-found boundary, so it sits in every route's tree and a dynamic API in it opts the whole app out of static generation — prerendered HTML documents fall 577 to 1, and with no prerendered param set left, dynamicParams=false stops rejecting anything, so /foo.bar/privacy answers 200 again, /foo.bar/docs answers 500 again, and /docs/no-such-page comes back as the 594-byte empty error shell. That regresses #180, #182, #192 and #209 to buy a correct lang on one URL family. Attribution was isolated by an ablation with middleware.ts restored byte-identical to HEAD, so the collapse is provably this one file. Shipped option 2 instead, one file, narrower than the granted surface (middleware.ts read and not touched): middleware already 307-redirects a prefix-less path to the negotiated locale BEFORE this page renders, so the locale is already in the URL for every request that has one at all, and a blocking inline script reads it from location.pathname and sets lang, title and copy for all seven locales. The route stays prerendered, the 404 stays one document served byte-identical for every unmatched URL, and with scripting off it is byte-for-byte what main serves today. Left as a draft because one judgement call is the maintainer's, recorded in open_questions.",
      "tests": "All readings at afdf0c1 (git rev-parse --short HEAD from the same tree the gate build ran on), against `next start` on a production build, with a same-session baseline build of main at ffe36c5 in a separate worktree for the before column. Bodies script-stripped before every grep/sha (#182's trap reconfirmed on this tree: raw grep scores the 404 copy present on healthy 200 pages; stripped scores 0). GATES, exit code captured before any pipe, each read from the command's own verdict line: `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 using 3 workers (996/996)', 577 prerendered HTML files; `pnpm turbo run test --force` -> 'Tasks: 1 successful, 1 total', '16 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; check-node-floor.mjs exit 0; check-translations.mjs exit 0; check-translation-ownership.mjs --actor hotlong --files (diff) exit 0, 0 translation artifacts. --force on every turbo task because the cache is shared across worktrees in this container. ABLATION (option 1 attribution): mutation confirmed on disk by grep -c of the injected token in both files before building, and by `git diff --stat`; the middleware half was then restored with `git checkout HEAD -- <abs path>` and the restore PROVEN by blob identity (git hash-object 8858b258... == git rev-parse HEAD:apps/docs/middleware.ts, both non-empty), not by an exit code, and `git diff HEAD --stat` then listed not-found.tsx alone. Rebuilt with --force after that restore; the route table still showed 577->1 and every user-facing route dynamic, so the collapse is attributable to headers() in the not-found boundary alone. Both probe files were restored to HEAD at the end, each proven by blob identity, before the shipped version was written. BROWSER (real Chromium, /opt/pw-browsers, AGENTS.md rule 2): baseline ffe36c5 on /zh-Hans/docs/no-such-page gives lang=en and English copy; afdf0c1 gives lang=zh-Hans and Chinese copy, in the same run. Also ja, ko, and the English fallbacks /no-such-page, /foo.bar/privacy, /favicon.ico. Zero hydration complaints on the production build AND under `next dev` (strict mode, loudest hydration checks); `next dev` also checked because #181 established the missing-root-layout validator runs only there — silent, and all five 404 URLs server-render the copy in dev. javaScriptEnabled:false gives lang=en and English copy, i.e. exactly today's behaviour. CONTROLS: every row of #209's table re-run on both trees. All eleven 404 URLs answer 404 with a real body and one identical stripped sha (479133a86f456e70...). /cn/ legacy redirects 308 and the www.objectos.app host redirect 308 byte-identical to baseline. /llms.txt, /robots.txt, /sitemap.xml, /docs/architecture.mdx byte-identical. The seven healthy 200 control documents differ in raw sha only because two _next/static chunk URLs carry a content hash; with script bodies stripped and those URLs masked (masking verified to leave zero unmasked _next/static references) all seven are byte-identical between ffe36c5 and afdf0c1. The whole change to the served 404 markup is two things: the copy element gains id=nf-message, and the noindex meta moves two positions later in the head (still present).",
      "open_questions": [
        {
          "question": "The card's title is 'declares en whatever locale was requested'. This branch corrects the declaration when the inline script runs, not at parse time. Does a post-parse correction satisfy the card, or was a server-rendered correct lang the point?",
          "options": [
            "A - accept the shipped client-side correction: the reader sees their language, the document is never internally inconsistent (lang and copy are set together in one synchronous block), a 404 body is not indexed and the served markup carries robots=noindex, and with scripting off it is byte-for-byte today's page",
            "B - require a server-rendered lang: measurement says the option space for that is empty. Option 1 costs #180, #182, #192, #209 and 576 prerendered documents; app/[lang]/not-found.tsx was measured a byte-identical no-op in #182. So B means closing #191 as won't-fix with this measurement attached and dropping PR #213",
            "C - leave it entirely: no PR, keep the English 404, close #191 with the measurement"
          ],
          "recommendation": "A, because it is the only option that changes anything for the reader the card names and it costs nothing measurable — every #209 control holds byte-identically, the 404 stays one prerendered document, and the diff is 105 lines in one file, most of them the comment recording why option 1 cannot be used. B and C are both defensible and both terminate in the same artifact, this measurement; if either is chosen, PR #213 should be closed rather than merged, which is why it is a draft."
        }
      ],
      "out_of_scope_findings": [
        "filed as #212: the 404 document's title reverts to 'ObjectOS' once React hydrates, losing the '404:' prefix — measured pre-existing on ffe36c5 and reproduced identically on this branch, so not introduced here"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  8. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    ACCEPT — merging #213. Taking the judgement call you correctly refused to take.

    CI 2/2 green on afdf0c1, one file, narrower than the granted surface (middleware.ts read and not touched).

    First: the measurement that matters most here is of the thing we did not ship

    I asked for the card's candidate to be treated as a hypothesis. It was built, and it is not a near-miss — it is a trap:

    main option 1 (headers())
    prerendered HTML documents 577 1

    app/not-found.tsx is the root not-found boundary, so it sits in every route's tree, and a dynamic API in it opts the entire app out of static generation. With no prerendered param set left, dynamicParams = false has nothing to reject against and stops rejecting — so /foo.bar/privacy returns to 200, /foo.bar/docs returns to 500, /favicon.ico goes back to redirecting into the docs route, and the docs 404s collapse to the 594-byte empty shell. Four merged fixes undone — #180, #182, #192, #209 — by a one-line change to a file none of them touch.

    Isolating that to the one file by restoring middleware.ts to be byte-identical to HEAD before measuring is what makes it evidence rather than a coincidence.

    And the summary line is the one to remember: option 1 buys a correct lang on one URL family — paths matching no route at all — and pays for it with every other row.

    This is the single most valuable thing produced this round, and it is a negative result. Had this card been implemented the obvious way by anyone not measuring the build output, it would have shipped green — every gate we have passes on it — and quietly reverted a day of work.

    The decision

    Merge it. The card's title says "declares en whatever locale was requested," and you are right that this branch corrects the declaration when the script runs rather than at parse time. But lang="en" was the card's evidence, not its requirement. The stated harm is a reader who followed a stale link while browsing in their own language and is answered in English. That reader is served correctly now, measured in a real browser across all seven locales.

    The parse-time half is worth what the card itself says it is worth: nothing. A 404 body is not indexed, the served markup carries noindex (confirmed present on this tree), and a crawler is told 404 correctly either way. There is no consumer of parse-time lang on this page that we can name.

    So closing it won't-fix would trade a real reader-facing improvement, available at essentially zero cost, for a property with no consumer. That is the wrong trade, and it would be the wrong trade even if option 1 were free.

    What makes it zero cost is the part you preserved: still one prerendered document, byte-identical across all eleven unmatched URLs (479133a8...), 577 prerendered files, and the seven healthy control pages byte-identical to main once content-hashed asset URLs are masked. #209's property survives intact.

    suppressHydrationWarning being necessary rather than decorative — without it React patches the English copy back in — is exactly the kind of detail that would have made this ship broken-but-green, and you measured zero hydration complaints under next dev specifically because strict mode makes them loudest there.

    Residuals, stated rather than buried

    • lang is wrong between parse and script execution. Assistive technology reads the DOM after load, so in practice it sees the corrected value; but the window is real and this is the honest description of it.
    • The swap is an inline script. If a CSP without unsafe-inline or a nonce is ever added to this app, the swap stops silently and the page degrades to English — which is today's behaviour, so the failure mode is a return to the status quo rather than a break. Worth knowing before someone adds a CSP and wonders why this stopped.
    • [finding] The 404 document's title reverts to "ObjectOS" once React hydrates, losing the "404:" prefix #212 — the 404 title reverting to ObjectOS on hydration, losing the 404: prefix — is measured pre-existing on ffe36c5 and correctly not addressed here. Graded separately.

    On handing the call back

    Leaving it draft and naming the one question that was not yours was right, and it is the second time today a dev has done that instead of picking the answer that would let them close the card. The alternative — shipping and letting me discover the parse-time caveat in the diff — would have been faster and worse.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions