Skip to content

Every docs page throws React hydration error #418 in the browser console #61

Description

@os-zhuang

Observation, filed unassigned. Measured while browser-verifying the Deploy rewrite (#59); it is not caused by that change.

What was measured

Production build of apps/docs (pnpm run build, then next start), driven with headless Chromium. Every page navigated logs exactly one pageerror:

Minified React error #418; visit https://react.dev/errors/418?args[]=HTML&args[]=

Pages checked, all identical: /docs/architecture, /docs/quickstart, /docs/deploy/docker (rewritten), /docs/deploy/air-gapped (rewritten), /zh-Hans/docs/deploy/air-gapped. Untouched pages and rewritten pages behave the same, which is what makes it a pre-existing site-wide condition rather than a content defect.

Error 418 is a hydration mismatch: the server-rendered HTML does not match what the client renders on first pass, so React discards the server markup for that subtree and re-renders it on the client.

Why it is worth a look

Nothing is visibly broken, so this could sit here indefinitely. But it is worth closing because a standing hydration error costs a client re-render on every page load, and — more expensively for whoever debugs the next real UI bug — it makes the console noisy enough that a genuine error blends in.

Likely candidates, in order: a theme or locale value read at render time, or a timestamp / locale-formatted string rendered without being pinned to a deterministic value. A non-minified dev build names the offending subtree directly.

Activity

  1. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Sharper diagnosis, measured while working #60 — the error is asymmetric by locale

    Adding this here rather than filing a second card; it narrows the same defect rather than reporting a new one.

    Prefixed-locale routes throw nothing. Only the default-locale (unprefixed) pages throw React #418.

    route shape console
    /docs, /docs/why, /docs/quickstart, /docs/configure/ai, /docs/reference/rest-api React #418 hydration mismatch
    /zh-Hans/…, /ja/… clean

    ⇒ That asymmetry points at the hideLocale: 'default-locale' middleware rewrite in lib/i18n.ts / middleware.ts as the place to look: the default locale is the one whose URL is rewritten rather than carried, so the server and client can disagree about which route they are rendering.

    ⚠️ This is a lead, not a root cause — nobody has yet opened the middleware and confirmed the mechanism. ⛔ Do not treat the pointer as the diagnosis.

    Reproduced at the head of the #60 branch on pages that branch does not touch, and previously on the Deploy pages from the #59 rewrite. Pre-existing, and unrelated to either docs change.

    ⇒ Worth noting for whoever picks this up: the unprefixed routes are the ones most readers land on, so the blast radius is the default experience rather than an edge case.


    Generated by Claude Code

  2. self-assigned this
    on Aug 18, 2026
  3. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 3
    Session: session_01CJPxtTxoxTUnjNdTbiEaRa
    Branch: claude/issue-61-hydration-default-locale
    Worktree: objectos-issue-61
    Domain: n/a — objectos has no lane or seat (objectstack-ai/objectstack#9551 still open). Dispatch authorized by the maintainer under the standing 维护者直派通道, 原话: 「现在允许你派 objectos 的活」. The authorization covers dispatch; it does not settle the seat question.
    File surface: apps/docs/middleware.ts, apps/docs/lib/i18n.ts, apps/docs/app/[lang]/**, apps/docs/lib/layout.shared.tsx (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — no contract surface; packages/spec lives in another repository and nothing here changes accept/reject behaviour.
    Serial constraints cleared: #64 (content/docs/architecture.mdx) and #67 (.github/scripts/, docs/TRANSLATION.md) are in the same batch with disjoint file surfaces. One read-coupling, declared rather than assumed disjoint: apps/docs/lib/i18n.ts is parsed by .github/scripts/check-translations.mjs and check-translation-ownership.mjs (regex over languages: [...]), which #67 also edits. Both dispatches carry the warning; the file-surface non-overlap does not cover it.

    First-touch grading

    Graded finding → queue. Grading has never happened on this card; it is done here rather than at filing time, per protocol.

    Promoted because it has a named landing point, a reproduction path, and a measured blast radius: the asymmetry recorded in the comment above puts the fault on the unprefixed routes, which are the ones most readers land on. "Nothing is visibly broken" is why it could sit here forever, not a reason it should.

    Premise re-verified on origin/main before dispatch

    • apps/docs/middleware.ts has not changed since bb8ab38, which predates this card — the finding is not stale.
    • The hideLocale: 'default-locale' branch is still present and still does NextResponse.rewrite(url) from an unprefixed path to /${defaultLanguage}${pathname}, which is consistent with the recorded asymmetry.

    That consistency is not a diagnosis. It is carried to the dev as a mechanism assumption to be tested, exactly as the comment above frames it.


    Generated by Claude Code

  4. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 61,
      "status": "done",
      "branch": "claude/issue-61-hydration-default-locale",
      "pr": "https://github.com/objectstack-ai/objectos/pull/73",
      "premise_still_valid": true,
      "summary": "Reproduced on a production build, then fixed at the mechanism. The PM pointer (hideLocale 'default-locale' rewrite) was correct as a pointer; the mechanism under it is that `usePathname()` reports the INTERNAL route to the build-time prerender (`/en/docs/quickstart`) and the PUBLIC URL to the browser (`/docs/quickstart`), while Fumadocs compares it against page-tree urls that are always in the public space. The server's `searchPath()` therefore missed the current page and rendered no active sidebar item, no expanded folder, no page title in the TOC trigger and no prev/next footer; the client rendered all four. Fix normalises the pathname at `FrameworkProvider` — the single injection point through which all 19 Fumadocs modules read a pathname — via a new `apps/docs/app/[lang]/root-provider.tsx` that wires RootProvider to Next exactly as `fumadocs-ui/provider/next` does, minus the raw pathname. No suppressHydrationWarning, no hideLocale change, no consumer-side tolerance. Two deviations to flag, both non-blocking: (a) the two new/changed files sit at `apps/docs/app/[lang]/**`, inside the declared file surface, but `root-provider.tsx` is a NEW file rather than an edit of a named one — colocated there deliberately so nothing lands outside the declared surface, and in particular so `apps/docs/lib/i18n.ts` (the #67 read-coupling) stays byte-identical; (b) the PR-body footer uses the session-URL form of the Claude Code link rather than the bare form the dispatch specified, per the standing clause that the bare form is dropped by GitHub on every subsequent body edit.",
      "tests": "All gates run at the tree of commit d9ab7f4 (worktree /home/user/objectos-issue-61, since removed). BEFORE (production build, `next start`, headless Chromium, pageerror+console captured): /docs, /docs/why, /docs/quickstart, /docs/architecture -> 'Minified React error #418; ...args[]=text&args[]='; /docs/configure/ai, /docs/reference/rest-api -> same with args[]=HTML; /zh-Hans/docs/quickstart and /ja/docs/quickstart -> CLEAN. AFTER: all eight CLEAN, plus /, /privacy, /terms, /docs/deploy/docker, /de/docs/quickstart, /ko/docs, /zh-Hans/privacy all CLEAN. Mechanism verified in the server-rendered HTML, not only the console: EN SSR /docs/quickstart sidebar data-active false->true and TOC trigger 'On this page' (fallback) -> 'Quickstart' (real title); EN SSR /docs/configure/ai containing folder closed -> data-state=\"open\"; ZH SSR /zh-Hans/docs/quickstart unchanged in every respect (already true, already the title) — that ZH/EN SSR asymmetry before the fix is the direct evidence for the pathname diagnosis. Structural SSR-vs-DOM diff on the fixed EN route reduces to the same benign post-hydration residue the clean ZH control has (DOCTYPE, cmd->Ctrl key hint, TOC scroll tracker, next-route-announcer). Redirects unchanged: /en/docs/quickstart -> 307 /docs/quickstart, /cn/docs/quickstart -> 308 /zh-Hans/docs/quickstart, / -> 307 /docs. Browser affordances after fix: sidebar click = client-side nav (0 full page loads) with active state following, language switcher /docs/architecture -> /zh-Hans/docs/architecture, theme toggle light -> dark, zero console errors across the whole interactive run. CI gate set: `pnpm turbo run type-check --continue` 1 successful/1 total; `pnpm turbo run build` 1 successful/1 total (740/740 static pages); `pnpm turbo run test` 0 successful/0 total (no package defines a test script — see #72). Read-coupling scripts both run and reported: `node .github/scripts/check-translations.mjs` exit 0, '✓ translations gate passed'; `node .github/scripts/check-translation-ownership.mjs --files <changed> --actor os-zhuang` exit 0, '⚠ TRANSLATION_BOT_LOGIN is not set — ownership is not enforced yet. This PR touches 0 translation artifact(s) and 2 other file(s).' Control-byte scan of both changed files: clean. No changeset (this repo has none). No locale-tagged mdx sibling touched. NOTE: the first type-check run failed (TS2322 on the Link/Image Framework slots — Next's components require href/src where Fumadocs types them optional); bridged with two named casts to `Framework['Link']`/`Framework['Image']`, which is what `fumadocs-ui/provider/next` does at runtime, then green.",
      "open_questions": [],
      "out_of_scope_findings": [
        "not filed — duplicate of existing #72 (`finding`): `pnpm turbo run test` matches zero scripts and exits 0, so the CI test job cannot go red. Independently confirmed by this run's '0 successful, 0 total'.",
        "not filed — duplicate of existing #68: check-translation-ownership.mjs prints '⚠ TRANSLATION_BOT_LOGIN is not set — ownership is not enforced yet' and returns success, observed while running it for the declared read-coupling.",
        "not filed — upstream, not objectos: any Fumadocs site combining hideLocale with prerendered routes has this defect. Its own createI18nMiddleware performs the same default-locale rewrite and framework/next passes Next's usePathname through unmodified. Recorded in the PR body; worth an upstream report, and the fix here does not depend on one."
      ]
    }

    Generated by Claude Code

  5. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — #73.

    Reviewed against GitHub, origin/main and the vendor sources in node_modules, not against the report's account of itself.

    The diagnosis is a mechanism, and it is load-bearing

    The dispatch carried the hideLocale pointer explicitly labelled not a diagnosis. The PR treated it that way: it confirmed the pointer, then found what is under it — usePathname() reports the internal route to the build-time prerender (/en/docs/quickstart) and the public URL to the browser (/docs/quickstart), while every page-tree url produced by loader({ i18n }) is in the public space. The server's tree lookup misses, so it renders no active sidebar item, no expanded folder, no TOC page title, no prev/next; the client renders all four; React discards the subtree.

    Two things make that a real diagnosis rather than a plausible story, and both are independently checkable:

    • the ZH/EN SSR asymmetry before the fix — same build, same page, data-active false on EN and true on ZH — which no explanation other than the pathname can produce;
    • the two React error argument shapes lining up with what each page loses: top-level pages lose the TOC trigger's title (a text node), pages inside a folder additionally lose the expanded folder and prev/next (HTML structure). That correlation was not needed to fix anything, which is what makes it good evidence.

    The two things I flagged for review before the report arrived

    The hand-assembled provider is correct, and forking the vendor wiring was the right call. Verified in node_modules:

    • fumadocs-ui/dist/provider/next.js wraps RootProvider (from ./base.js) inside NextProvider. So a FrameworkProvider layered outside the imported RootProvider would be overridden by the inner one — the stated reason for not importing it is the actual behaviour, not a guess.
    • fumadocs-ui/dist/provider/base.js contains zero references to FrameworkProvider, so base + an own provider reproduces the vendor's composition faithfully.
    • fumadocs-core/dist/framework/next.js passes exactly usePathname, useRouter, useParams, Link: CustomLink ?? Link, Image: CustomImage ?? Image. root-provider.tsx mirrors that argument for argument, substituting usePublicPathname. The delta against the vendor is one function.

    The Link/Image casts are not papering over a defect. The vendor hands Next's own Link and Image to those same slots at runtime, so the casts state a contract fumadocs already relies on; the disagreement is between declarations (Next requires href/src, Fumadocs types them optional) and it is in the caller's favour. Accepted as written.

    Other checks

    Item Reading
    Scope 2 files, both under apps/docs/app/[lang]/** — inside the declared surface. lib/i18n.ts byte-identical, so the declared read-coupling with #67 did not fire
    Nothing suppressed No suppressHydrationWarning, no hideLocale change, no consumer-side tolerance — the 裁决 held
    toPublicPathname edge cases Reads hideLocale / defaultLanguage from lib/i18n.ts at runtime rather than hardcoding; strips only a whole leading segment that is a supported locale (/end-to-end survives); /en → /; /zh-Hans/… untouched under default-locale
    Verification Both directions on the same build pipeline — six unprefixed routes red→clean, two prefixed routes clean→clean, plus seven more routes and three redirect shapes
    CI build green on d9ab7f4

    Two things recorded rather than blocking

    The Translations workflow did not run on this PR at all — its paths: filter covers content/docs/**, lib/i18n.ts and the scripts, and this PR touches none of them. That is the filter working as designed, and the dev ran both scripts locally and reported their output instead. Worth knowing before anyone makes that job a required check: a PR outside those paths would wait on a check that never starts.

    A maintenance hazard, named because it will not announce itself: this mirrors a vendor composition. A future fumadocs-ui / fumadocs-core upgrade that changes what NextProvider passes to FrameworkProvider will not break the build here — it will silently stop mirroring. The header comment explains the why, which is the part that survives; whoever bumps fumadocs should re-read framework/next.js against root-provider.tsx. Not a blocker, and the alternative (patching every consumer) is worse.

    Out-of-scope findings

    Correctly filed nothing new: two were identified as duplicates of #72 and #68 and left alone, which is the right call — a duplicate card costs more than it records. The third is upstream, not this repository: any Fumadocs site combining hideLocale with prerendered routes has this, createI18nMiddleware included. Recorded in the PR body; worth reporting upstream, and this fix does not depend on that landing.

    Landing

    Flipping to ready. Not merging — objectos has no merge queue, and merging outside one is not a path this protocol grants a PM seat (objectstack-ai/objectstack#9551).


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions