From e3db66050abf1bc3958d28eb35dbc1eef8f39bcf Mon Sep 17 00:00:00 2001 From: Alaister Young <10985857+alaister@users.noreply.github.com> Date: Fri, 8 May 2026 15:33:07 +0800 Subject: [PATCH] fix(studio): rewrite next/head shim on top of React 19 metadata hoisting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation portaled children into document.head on the client and returned null during SSR — meaning contents were absent from the prerendered HTML. React 19 ships native "document metadata" hoisting: / <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. --- apps/studio/compat/next/head.tsx | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/studio/compat/next/head.tsx b/apps/studio/compat/next/head.tsx index 2782aaa2a59..4724a4511df 100644 --- a/apps/studio/compat/next/head.tsx +++ b/apps/studio/compat/next/head.tsx @@ -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}</> }