Files
zeekay 9c3123f160 react: a part that hydrates late draws what the server drew, then the reader's words; 0.6.1
A section in a chunk of its own hydrates after the provider may have
loaded the reader's catalog. useI18n handed it the Arabic words over
English HTML, and React threw the tree away and drew it again on the
client (hanzo.ai's landing in Arabic: "Hydration failed because the
server rendered text didn't match", #418).

useI18n answers the provider's starting locale with no catalog (Served)
while its component hydrates, through useSyncExternalStore's server
snapshot, and the reader's words a render later; a component mounted on
the client gets them at once. test:browser hydrates a page held up by a
section whose chunk arrives after the catalog, and reports the mismatch
without this change.
2026-10-30 07:23:40 -07:00

77 lines
2.7 KiB
TypeScript

/**
* A whole static-export page as a host renders it: React owns `<html>`, the head
* carries `bootScript()`, and the provider sits in the body. The server renders it
* to a string (English, as an export is) and `hydrate.ts` hydrates the document.
*/
import { Suspense, createElement as h, lazy, useEffect, type ReactNode } from 'react'
import { bootScript } from '../../src/boot.ts'
import { I18nProvider, LanguagePicker, useI18n } from '../../src/react.ts'
export interface Props {
user: string | null
account?: { base: string; token: string }
/** Leave out `suppressHydrationWarning`, to show React does report the changed `<html>`. */
strict: boolean
}
/** `?user=alice&iam&strict`, read the same way by the server and the browser. */
export function props(href: string): Props {
const url = new URL(href)
const user = url.searchParams.get('user')
return {
user,
account: user && url.searchParams.has('iam') ? { base: url.origin, token: 'test-only' } : undefined,
strict: url.searchParams.has('strict'),
}
}
async function loadCatalog(code: string) {
return code === 'ar' || code.startsWith('ar-') ? { Welcome: 'مرحبًا', Language: 'اللغة' } : { Welcome: `Welcome:${code}` }
}
/** A section in a chunk of its own, as a host splits one. */
function Words(): ReactNode {
// Marks the body once the section (and so the page around it) has hydrated.
useEffect(() => void (document.body.dataset.late = 'hydrated'), [])
return h('p', { 'data-late': '' }, useI18n().t('Language'))
}
/**
* On the client the section's chunk arrives after the catalog has. It holds up the
* boundary around the page (as a host's split section with no boundary of its own
* holds up the nearest one), so the page hydrates with the reader's language already
* loaded, and must still draw what the server wrote first.
*/
const Late =
typeof window === 'undefined'
? Words
: lazy(() => new Promise<{ default: typeof Words }>((done) => setTimeout(() => done({ default: Words }), 300)))
function Page(): ReactNode {
const { t, dir } = useI18n()
return h(
'main',
null,
h(LanguagePicker),
h('h1', null, t('Welcome')),
h('aside', { 'data-side': dir === 'rtl' ? 'right' : 'left' }),
h(Late),
)
}
export function Document({ user, account, strict }: Props): ReactNode {
return h(
'html',
{ lang: 'en-US', suppressHydrationWarning: !strict },
h(
'head',
null,
h('meta', { charSet: 'utf-8' }),
h('script', { dangerouslySetInnerHTML: { __html: bootScript() } }),
h('link', { rel: 'stylesheet', href: '/rtl.css' }),
),
h('body', null, h(I18nProvider, { userId: user, account, loadCatalog }, h(Suspense, { fallback: null }, h(Page))), h('script', { type: 'module', src: '/hydrate.js' })),
)
}