[]previous and next page

Hydration keys

The deterministic key algorithm that lets the client claim server-rendered DOM, the region markers in server HTML, resource keys, and why keys never depend on owners, effects or memos.

For agentsUpdated

Hydration attaches an application to HTML the server already produced, reusing the server's nodes. The client does not diff a tree against the HTML. Instead, every template root the server writes carries a key (data-hk), and the client's template() function, while hydrating, returns "the element with the next key" instead of cloning. Server and client compute keys with the same algorithm, so they agree as long as they run the same components in the same order.

The algorithm#

State is one register, scope = { id, count, resources }, restored in finally.

Step Rule
Root scope renderToString, renderToStringAsync, renderToStream and hydrate start from { id: "<renderId>-", count: 0 }. The default renderId is "0", so "0-".
Component call createComponent allocates a number from the current scope and runs the component in a child scope whose id is <parent id><number>- and whose count starts at 0.
Template root Each compiled JSX tree allocates <scope id><count> as its key and increments the count. The server writes it as data-hk="...".
Resource useResource asks for a key: k:<explicit key> when you pass key, otherwise <scope id>r<index> (index counts the auto-keyed resources created directly in that scope).
Suspense Allocates one number in the current scope (its boundary key). Its content runs in scope <key>-, its fallback in scope <key>f-.

Component calls and template roots share one counter in their scope; only the root of each template gets a key (elements nested inside static markup never do). Keys are a function of the order of component calls and template creations. They never depend on owners, effects or memos.

A worked example#

src/app.tsxtsx
import { useEffect, useMemo, useStore } from "@shadow-js/core";

export function Header(props: { title: string }) {
  return (
    <header>
      <h1>{props.title}</h1>
    </header>
  );
}

export function App(props: { extra?: boolean }) {
  const [clicks, setClicks] = useStore(0);
  useEffect(() => {
    document.title = `Clicked ${clicks()}`; // client only: effects never run on the server
  });
  const label = useMemo(() => `Clicked ${clicks()}`);
  return (
    <main>
      <Header title="Hello" />
      <button type="button" onClick={() => setClicks(clicks() + 1)}>
        {label()}
      </button>
      <div>{props.extra ? <p>extra</p> : null}</div>
    </main>
  );
}

The server renders it. This block is compiled with generate: "ssr" and run with __SERVER__ = true:

src/server.tsxtsx
import { renderToString } from "@shadow-js/web/server";
import { App } from "./app";

console.log(renderToString(() => <App />));
console.log(renderToString(() => <App />, { renderId: "page2" }));
Expected output
<main data-hk="0-0-0"><!--[--><header data-hk="0-0-1-0"><h1><!--[-->Hello<!--]--></h1></header><!--]--><button type="button"><!--[-->Clicked 0<!--]--></button><div><!--[--><!--]--></div></main>
<main data-hk="page2-0-0"><!--[--><header data-hk="page2-0-1-0"><h1><!--[-->Hello<!--]--></h1></header><!--]--><button type="button"><!--[-->Clicked 0<!--]--></button><div><!--[--><!--]--></div></main>

How the keys of the first line came about:

Event, in order Scope Key allocated
renderToString starts root 0-, count 0
<App /> is called root scope: number 0, count becomes 1 child scope 0-0-
<main> template is created scope 0-0-, count 0 data-hk="0-0-0"
<Header /> is called scope 0-0-: number 1 child scope 0-0-1-
<header> template is created scope 0-0-1-, count 0 data-hk="0-0-1-0"

The <button>, <h1>, and <div> are nested inside templates that already have a key, so they have none. renderId replaces the leading 0: the second line uses page2-0-0. hydrate(code, container, { renderId }) must receive the same string.

Region markers#

Every dynamic child slot is wrapped as <!--[--> ... <!--]--> in server HTML only. The markers tell the client where one slot's content starts and ends, because adjacent text from a template and from an expression would otherwise merge into one text node (Hello, and Ada become Hello, Ada). They are visible above around Hello, Clicked 0 and inside the empty <div>.

During hydration the claimed element is prepared like a freshly cloned template: the contents between the markers are lifted out and queued for the matching insert, the marker comments are removed, and adjacent text nodes are re-merged (normalize()). insert then adopts the queued nodes instead of creating new ones. After the pass, any marker comment left over is removed too, so the live DOM contains no comments. A streamed Suspense boundary uses an opening marker of the form <!--[b:KEY--> (shown below), handled the same way.

Hydrating: nodes are reused, no comments remain#

When the client asks for a key the server never wrote (here the <p> template, number 2 of scope 0-0-), claimElement warns HYDRATION_KEY_MISS in development, returns null, and template() clones a fresh node. The page is never left half hydrated, but the server's work for that region is wasted. See HYDRATION_KEY_MISS.

Why keys never depend on owners, effects or memos#

Server and client create very different graphs for the same component:

  • effects never run on the server, and useEffect/onMount create no node there;
  • memos are evaluated lazily on the server and eagerly on the client;
  • the owner tree on the client contains list rows, Show branches and context scopes that do not exist on the server.

A key derived from any of those would differ between the two runs. So the algorithm uses only the order of calls of createComponent, templates and Suspense/useResource, which are the same on both sides because the compiler emits them in the same order for the dom and ssr targets. The consequence for your code: make the same component calls in the same order on the server and the client. A condition that is true on one side only (isServer, typeof window, reading localStorage, Date.now(), Math.random()) shifts every later key. Defer client-only content to onMount or to an effect, which run after hydration.

Keys after hydration#

Keys are allocated only while a scope is active, which is during the synchronous hydrate() pass (and during server rendering). Rows, branches and components created by later updates run with no scope: nextKey() returns undefined, template() clones, and nothing consults the server's registry. Hydration therefore costs nothing after it ends.

Resource keys and the data block#

A resource created during a server render registers its promise with the render. renderToStringAsync repeats the render until nothing new resolves (at most maxPasses, default 10, then SSR_BOUNDARY_WATERFALL) and returns { html, data }, where data maps resource key to value. On the client, hydrate(code, container, { data }) serves those values to the same keys, so the fetcher is not called again.

src/profile.tsxtsx
import { useResource } from "@shadow-js/core";
import { Suspense } from "@shadow-js/web";

export interface Loaders {
  user: () => Promise<string>;
  stats: () => Promise<number>;
}

export function Profile(props: { loaders: Loaders }) {
  const user = useResource(() => props.loaders.user(), { key: "user" });
  const stats = useResource(() => props.loaders.stats());
  return (
    <Suspense fallback={<i>loading</i>}>
      <p>
        {user()} has {stats()} posts
      </p>
    </Suspense>
  );
}
src/profile-server.tsxtsx
import { renderToStringAsync } from "@shadow-js/web/server";
import { Profile } from "./profile";

const loaders = {
  user: async () => "Ada",
  stats: async () => 3,
};

const { html, data } = await renderToStringAsync(() => <Profile loaders={loaders} />);
console.log(html);
console.log(JSON.stringify(data));
Expected output
<p data-hk="0-0-0-0-0"><!--[-->Ada<!--]--> has <!--[-->3<!--]--> posts</p>
{"k:user":"Ada","0-0-r0":3}

The data keys: k:user is the explicit key; the second resource has no key, so it is 0-0-r0 (scope of the Profile call, r, auto-key index 0). serialize(data) from @shadow-js/web/server produces text that is safe to embed in a <script type="application/json"> tag.

A streamed render shows the same keys in the shell. The pending boundary is delimited by <!--[b:KEY--> ... <!--]-->, and the stream later replaces what is between them:

src/profile-stream.tsxtsx
import { renderToStream } from "@shadow-js/web/server";
import { Profile } from "./profile";

const loaders = {
  user: async () => "Ada",
  stats: async () => 3,
};

let shell = "";
const stream = renderToStream(() => <Profile loaders={loaders} />, {
  onShellReady: (html) => {
    shell = html;
  },
});
await stream.pipeTo(new WritableStream());
console.log(shell);
Expected output
<!--[b:0-0-0-0--><i data-hk="0-0-0-0f-0">loading</i><!--]-->

Notice the key of the <p>: Profile is scope 0-0-, Suspense is number 0 of that scope (its own call scope 0-0-0-), the boundary key is 0-0-0-0, and the content scope is 0-0-0-0-, whose first template root is 0-0-0-0-0. A pending boundary renders its fallback in scope 0-0-0-0f-, so fallback and content never shift each other's keys.

Summary#

  • Keys come from renderId, the order of createComponent calls and the order of template creations. Nothing else.
  • Server HTML has data-hk on template roots and <!--[-->...<!--]--> around dynamic slots. Client DOM has neither.
  • Make the same calls in the same order on both sides; defer client-only work to onMount.
  • A miss is repaired, with the HYDRATION_KEY_MISS warning in development.

Related: fine-grained rendering, SSR, hydration and streaming, hydrate, renderToString, renderToStringAsync, serialize.