@pylonsync/functions 0.22.41 → 0.22.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ type CreateElement = (type: any, props: any, ...children: any[]) => any;
2
+ /** The page with its metadata slot. `meta` is null in the browser. */
3
+ export declare function withMetaSlot(createElement: CreateElement, Fragment: any, meta: unknown, page: unknown): any;
4
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.22.41",
3
+ "version": "0.22.44",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -794,6 +794,13 @@ describe("loading.tsx is wired into the client build", () => {
794
794
  // (see ssr-hydration.test.ts for why a missing boundary breaks hydration).
795
795
  expect(runtime).toContain("buildTree(Page, Layouts, currentPageProps, data.component)");
796
796
  expect(runtime).toContain("createElement(Suspense, { fallback: createElement(Loading, props) }, tree)");
797
+ // The metadata slot the server renders beside every page, empty in the
798
+ // browser, from the same module the server uses (ssr-page-tree.ts).
799
+ expect(runtime).toContain('import { withMetaSlot } from "./page-tree";');
800
+ expect(runtime).toContain("tree = withMetaSlot(createElement, Fragment, null, tree);");
801
+ expect(fs.readFileSync(path.join(tempDir, ".pylon", "page-tree.ts"), "utf8")).toBe(
802
+ fs.readFileSync(path.join(import.meta.dir, "ssr-page-tree.ts"), "utf8"),
803
+ );
797
804
  });
798
805
 
799
806
  test("an app with no loading.tsx still builds and navigates", async () => {
@@ -355,13 +355,14 @@ ${entries}
355
355
  const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
356
356
  // DO NOT EDIT — overwritten on every pylon dev / build.
357
357
 
358
- import { createElement, Suspense } from "react";
358
+ import { createElement, Fragment, Suspense } from "react";
359
359
  import { hydrateRoot } from "react-dom/client";
360
360
  import { configureClient } from "@pylonsync/react";
361
361
  import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
362
362
  import { LOADING_MODULES } from "./loading-registry";
363
363
  import { createNavPayloadCache } from "./nav-cache";
364
364
  import { matchRoute, prefetchTargets } from "./route-match";
365
+ import { withMetaSlot } from "./page-tree";
365
366
 
366
367
  const routeCache = Object.create(null);
367
368
  let activeRoot = null;
@@ -387,6 +388,9 @@ function buildTree(Page, Layouts, props, loadingFor) {
387
388
  if (Loading) {
388
389
  tree = createElement(Suspense, { fallback: createElement(Loading, props) }, tree);
389
390
  }
391
+ // The server renders the page's metadata in this slot; the browser leaves
392
+ // it empty. Same shape on both sides, so useId values match (page-tree.ts).
393
+ tree = withMetaSlot(createElement, Fragment, null, tree);
390
394
  for (let i = Layouts.length - 1; i >= 0; i--) {
391
395
  const Layout = Layouts[i];
392
396
  if (!Layout) continue;
@@ -1568,6 +1572,14 @@ export async function _doBuildInner(
1568
1572
  "utf8",
1569
1573
  );
1570
1574
 
1575
+ // Same pattern for the page's tree shape, which the server render
1576
+ // shares (ssr-page-tree.ts): the runtime imports it as `./page-tree`.
1577
+ fs.writeFileSync(
1578
+ path.join(stageDir, "page-tree.ts"),
1579
+ fs.readFileSync(path.join(here, "ssr-page-tree.ts"), "utf8"),
1580
+ "utf8",
1581
+ );
1582
+
1571
1583
  // Same pattern for the optimistic-navigation route matcher — the runtime
1572
1584
  // imports it as `./route-match`; its source of truth is ssr-route-match.ts,
1573
1585
  // unit-tested directly.
@@ -21,6 +21,7 @@ import { afterEach, describe, expect, test } from "bun:test";
21
21
  import { Window } from "happy-dom";
22
22
  import React, { Suspense, use } from "react";
23
23
  import { renderToReadableStream } from "react-dom/server.browser";
24
+ import { withMetaSlot } from "./ssr-page-tree";
24
25
 
25
26
  // Mirrors `fulfilledThenable` in ssr-client-bundler.ts's CLIENT_RUNTIME_SOURCE:
26
27
  // a React-recognized fulfilled thenable so use() reads `.value` synchronously
@@ -238,3 +239,93 @@ describe("route-level loading.tsx hydration", () => {
238
239
  expect(errors.length).toBeGreaterThan(0);
239
240
  });
240
241
  });
242
+
243
+ // Every page with metadata had different useId values on the server and in
244
+ // the browser: the server put the metadata beside the page, the browser
245
+ // hydrated the page alone. Radix menus, dialogs and form labels then
246
+ // mismatched. Both sides now share withMetaSlot (ssr-page-tree.ts).
247
+ describe("metadata slot keeps useId values stable", () => {
248
+ function Layout({ children }: { children?: any }) {
249
+ return React.createElement("main", { id: "layout" }, children);
250
+ }
251
+ function Control() {
252
+ const id = React.useId();
253
+ return React.createElement("button", { id: `menu-${id}`, "data-probe": "" }, "Menu");
254
+ }
255
+ function MenuPage() {
256
+ return React.createElement("div", { id: "page" }, React.createElement(Control));
257
+ }
258
+ const meta = React.createElement("meta", { name: "description", content: "A page" });
259
+
260
+ async function render(tree: any): Promise<string> {
261
+ const stream = await renderToReadableStream(tree);
262
+ await (stream as any).allReady;
263
+ const reader = stream.getReader();
264
+ let html = "";
265
+ for (;;) {
266
+ const { value, done } = await reader.read();
267
+ if (done) break;
268
+ html += dec.decode(value);
269
+ }
270
+ return html;
271
+ }
272
+
273
+ async function hydrate(serverTree: any, clientTree: any) {
274
+ const html = await render(serverTree);
275
+ const serverId = /id="(menu-[^"]+)"/.exec(html)?.[1];
276
+ const win = new Window({ url: "http://localhost/" });
277
+ const root = win.document.createElement("div");
278
+ root.insertAdjacentHTML("afterbegin", html);
279
+ win.document.body.appendChild(root);
280
+ registerDom(win);
281
+ const errors: string[] = [];
282
+ const origErr = console.error;
283
+ console.error = (...a: any[]) => {
284
+ errors.push(a.map(String).join(" "));
285
+ };
286
+ try {
287
+ const { hydrateRoot } = await import("react-dom/client");
288
+ hydrateRoot(root as any, clientTree, {
289
+ onRecoverableError: (e: any) => errors.push(String(e?.message ?? e)),
290
+ });
291
+ await new Promise((r) => setTimeout(r, 50));
292
+ } finally {
293
+ console.error = origErr;
294
+ }
295
+ return {
296
+ serverId,
297
+ errors: errors.filter((e) => /hydrat|did not match|mismatch/i.test(e)),
298
+ };
299
+ }
300
+
301
+ const e = React.createElement;
302
+
303
+ test("the server's slot with metadata and the browser's empty slot give the same ids", async () => {
304
+ // What the browser's ids are, rendered alone with the client shape.
305
+ const clientHtml = await render(e(Layout, null, withMetaSlot(e, React.Fragment, null, e(MenuPage))));
306
+ const clientId = /id="(menu-[^"]+)"/.exec(clientHtml)?.[1];
307
+ const { serverId, errors } = await hydrate(
308
+ e(Layout, null, withMetaSlot(e, React.Fragment, meta, e(MenuPage))),
309
+ e(Layout, null, withMetaSlot(e, React.Fragment, null, e(MenuPage))),
310
+ );
311
+ expect(serverId).toBeDefined();
312
+ expect(serverId).toBe(clientId);
313
+ expect(errors).toEqual([]);
314
+ });
315
+
316
+ test("a page without metadata has the same shape", async () => {
317
+ const { errors } = await hydrate(
318
+ e(Layout, null, withMetaSlot(e, React.Fragment, null, e(MenuPage))),
319
+ e(Layout, null, withMetaSlot(e, React.Fragment, null, e(MenuPage))),
320
+ );
321
+ expect(errors).toEqual([]);
322
+ });
323
+
324
+ test("hydrating the page without the slot mismatches (the bug)", async () => {
325
+ const { errors } = await hydrate(
326
+ e(Layout, null, e(React.Fragment, null, meta, e(MenuPage))),
327
+ e(Layout, null, e(MenuPage)),
328
+ );
329
+ expect(errors.length).toBeGreaterThan(0);
330
+ });
331
+ });
@@ -0,0 +1,21 @@
1
+ // The shape of the tree around a route's page, shared by the server render
2
+ // (ssr-runtime.ts) and the browser's hydration (the generated client runtime
3
+ // imports this file as `./page-tree`).
4
+ //
5
+ // React derives `useId` values from the tree's shape, including how many
6
+ // children each element has. The server renders the page's metadata (<title>,
7
+ // <meta>, <link>, which React hoists into <head>) beside the page; the browser
8
+ // never renders those tags. So both sides give the page a fragment with two
9
+ // slots: on the server the first slot holds the metadata, in the browser it
10
+ // holds null. React counts a null slot, so every id inside the page is the
11
+ // same on both sides. Without the slot, every Radix menu, dialog and label id
12
+ // on a page with metadata mismatched on hydration.
13
+ //
14
+ // No imports: the client bundler copies this file next to the runtime.
15
+
16
+ type CreateElement = (type: any, props: any, ...children: any[]) => any;
17
+
18
+ /** The page with its metadata slot. `meta` is null in the browser. */
19
+ export function withMetaSlot(createElement: CreateElement, Fragment: any, meta: unknown, page: unknown): any {
20
+ return createElement(Fragment, null, meta ?? null, page);
21
+ }
@@ -7,6 +7,7 @@
7
7
  // the dispatch arm) so projects without SSR routes pay nothing — no
8
8
  // react-dom dependency requirement, no startup cost.
9
9
 
10
+ import { withMetaSlot } from "./ssr-page-tree";
10
11
  import { moduleKey, serverBundle } from "./server-bundle";
11
12
 
12
13
  // Bun runtime global. This module runs under Bun but is type-checked by
@@ -2561,15 +2562,14 @@ async function tryRenderBoundary(
2561
2562
  );
2562
2563
  boundaryMeta = applyAutoIcons(rel, boundaryMeta);
2563
2564
  const boundaryMetaFragment = renderMetadata(React, boundaryMeta);
2564
- let tree = React.createElement(Comp, compProps);
2565
- if (boundaryMetaFragment) {
2566
- tree = React.createElement(
2567
- React.Fragment,
2568
- null,
2569
- boundaryMetaFragment,
2570
- tree,
2571
- );
2572
- }
2565
+ // The same metadata slot a page gets: the browser hydrates a boundary
2566
+ // with the page's tree shape (ssr-page-tree.ts).
2567
+ let tree = withMetaSlot(
2568
+ React.createElement,
2569
+ React.Fragment,
2570
+ boundaryMetaFragment,
2571
+ React.createElement(Comp, compProps),
2572
+ );
2573
2573
  tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
2574
2574
  await renderBoundaryToClient(
2575
2575
  React,
@@ -3563,11 +3563,10 @@ export async function handleRenderRoute(
3563
3563
  // root: start with the page component as `tree`, then for each
3564
3564
  // layout (innermost first) wrap it as the new tree. Result is
3565
3565
  // the outermost layout containing all nested layouts down to
3566
- // the page. The metadata fragment is the FIRST child so React hoists
3567
- // its <title>/<meta> into the <head> a layout renders.
3568
- let tree: any = metaFragment
3569
- ? React.createElement(React.Fragment, null, metaFragment, pageLeaf)
3570
- : pageLeaf;
3566
+ // the page. The metadata is the FIRST child so React hoists its
3567
+ // <title>/<meta> into the <head> a layout renders; the browser renders
3568
+ // the same slot empty (ssr-page-tree.ts), so useId values match.
3569
+ let tree: any = withMetaSlot(React.createElement, React.Fragment, metaFragment, pageLeaf);
3571
3570
  tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
3572
3571
  const element = tree;
3573
3572
  // Tracks whether the HTTP head (response_start) has actually gone out. A