fix(studio): rewrite next/head shim on top of React 19 metadata hoisting

The previous implementation portaled children into document.head on
the client and returned null during SSR — meaning <Head> contents were
absent from the prerendered HTML. React 19 ships native "document
metadata" hoisting: <title> / <meta> / <link> / <style> / <script>
rendered anywhere in the tree are hoisted to <head> automatically and
work for both client and SSR. Studio uses <Head> exclusively for those
metadata tags (verified across pages/), so the shim can simply render
children and let React do the hoisting.

Side effects:
- SSR head content (titles etc.) now lands in the prerendered HTML.
- Dedup is by element shape rather than Next's explicit key prop;
  observable output is equivalent for the metadata tags studio uses.
- Non-metadata children (arbitrary tags) would render in place rather
  than in <head>. No such consumers exist today; if one shows up, swap
  to a portal + SSR collector. Documented in the file header.
This commit is contained in:
Alaister Young committed 2026-05-08 15:33:07 +08:00
1 parent 47b91d66d9
commit e3db66050a
1 file changed
+26 -3
+26 -3
View File
@@ -1,7 +1,30 @@
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
// Next/Head's job is to inject children into the document `<head>` and
// deduplicate them by `key` prop. React 19 ships native "document
// metadata" hoisting: any `<title>`, `<meta>`, `<link>`, `<style>`, or
// `<script>` rendered anywhere in the tree is hoisted to `<head>`
// automatically and works for both client render and SSR. Studio uses
// `<Head>` exclusively for those elements (`<title>`, `<meta>`,
// `<link>` — see `pages/maintenance.tsx`, `pages/claim-project.tsx`,
// etc.), so the shim can be a passthrough that lets React do the
// hoisting.
//
// Trade-offs vs the previous `createPortal(children, document.head)`
// approach:
// - SSR: the portal returned `null` on the server, so head content
// from `<Head>` was never in the prerendered HTML. Native hoisting
// emits the metadata in the prerendered output.
// - Deduplication: React 19 dedupes `<title>` (last one wins, same
// as `document.title`) and merges `<meta>`/`<link>` by their
// attributes. Next dedupes by an explicit `key` prop. The
// observable output is equivalent for the consumer set we have.
// - Non-metadata children: React 19 only hoists the metadata tags
// listed above. If a consumer ever renders an arbitrary element
// inside `<Head>`, it'll render in place rather than in `<head>`.
// We have no such consumers today; if one shows up, swap to a
// portal+SSR-collector setup.
export default function Head({ children }: { children?: ReactNode }) {
if (typeof document === 'undefined') return null
return createPortal(<>{children}</>, document.head)
return <>{children}</>
}