@pylonsync/functions 0.22.20 → 0.22.21

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.22.20",
3
+ "version": "0.22.21",
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",
@@ -687,6 +687,28 @@ describe("loading.tsx is wired into the client build", () => {
687
687
  expect(sharedChunks).toContain("app/dashboard/loading");
688
688
  });
689
689
 
690
+ test("first hydration wraps the page in the same Suspense boundary the server renders", async () => {
691
+ tempDir = makeFixture(
692
+ {
693
+ "dashboard/page.tsx": PAGE_BODY("Dash"),
694
+ "dashboard/loading.tsx": LOADING_BODY,
695
+ },
696
+ { "layout.tsx": LAYOUT_BODY },
697
+ );
698
+ originalCwd = process.cwd();
699
+ process.chdir(tempDir);
700
+ await buildClientBundle();
701
+ const runtime = fs.readFileSync(
702
+ path.join(tempDir, ".pylon", "client-runtime.ts"),
703
+ "utf8",
704
+ );
705
+ // The hydrating tree is built with the page's component path, which
706
+ // resolves its loading module and wraps the page in <Suspense>
707
+ // (see ssr-hydration.test.ts for why a missing boundary breaks hydration).
708
+ expect(runtime).toContain("buildTree(Page, Layouts, currentPageProps, data.component)");
709
+ expect(runtime).toContain("createElement(Suspense, { fallback: createElement(Loading, props) }, tree)");
710
+ });
711
+
690
712
  test("an app with no loading.tsx still builds and navigates", async () => {
691
713
  // The runtime's import of ./loading-registry is unconditional, so the
692
714
  // staged module must exist even with nothing to put in it.
@@ -348,7 +348,7 @@ ${entries}
348
348
  const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
349
349
  // DO NOT EDIT — overwritten on every pylon dev / build.
350
350
 
351
- import { createElement } from "react";
351
+ import { createElement, Suspense } from "react";
352
352
  import { hydrateRoot } from "react-dom/client";
353
353
  import { configureClient } from "@pylonsync/react";
354
354
  import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
@@ -368,8 +368,18 @@ let pendingNav = null;
368
368
  let manifestPromise = null;
369
369
  const prefetchedChunks = new Set();
370
370
 
371
- function buildTree(Page, Layouts, props) {
371
+ function buildTree(Page, Layouts, props, loadingFor) {
372
372
  let tree = createElement(Page, props);
373
+ // The server wraps the page of a route with a loading.tsx in ONE Suspense
374
+ // boundary, fallback = that loading module (ssr-runtime.ts, #278). The
375
+ // tree that hydrates the server HTML must have the same boundary, or React
376
+ // finds <Suspense> where it expects the page's first element, throws a
377
+ // hydration mismatch (#418) and re-renders the whole document; clicks in
378
+ // that window are lost. \`loadingFor\` is the page's component path.
379
+ const Loading = loadingFor ? nearestLoading(loadingFor) : null;
380
+ if (Loading) {
381
+ tree = createElement(Suspense, { fallback: createElement(Loading, props) }, tree);
382
+ }
373
383
  for (let i = Layouts.length - 1; i >= 0; i--) {
374
384
  const Layout = Layouts[i];
375
385
  if (!Layout) continue;
@@ -378,6 +388,13 @@ function buildTree(Page, Layouts, props) {
378
388
  return tree;
379
389
  }
380
390
 
391
+ // The loading module the server wraps \`component\` in: the nearest
392
+ // loading.tsx walking up from the page's directory.
393
+ function nearestLoading(component) {
394
+ const key = nearestBoundaryComponent(component, "loading", Object.keys(LOADING_MODULES));
395
+ return key ? LOADING_MODULES[key] : null;
396
+ }
397
+
381
398
  // ---------------------------------------------------------------------------
382
399
  // Client error / not-found boundary. notFound() (from @pylonsync/react) and
383
400
  // any other error thrown DURING a client render — the normal case when an
@@ -714,7 +731,7 @@ export function hydrate(component, Page, Layouts) {
714
731
  currentPageProps = withClientProps(data);
715
732
  currentComponent = data.component;
716
733
  const tree = withBoundary(
717
- buildTree(Page, Layouts, currentPageProps),
734
+ buildTree(Page, Layouts, currentPageProps, data.component),
718
735
  data.component,
719
736
  navEpoch,
720
737
  );
@@ -164,3 +164,77 @@ describe("streaming hydration (#278)", () => {
164
164
  }
165
165
  });
166
166
  });
167
+
168
+ // A route with a loading.tsx: ssr-runtime.ts wraps the page in ONE Suspense
169
+ // boundary whose fallback is the loading module, inside the layouts. The
170
+ // client's first hydration must build the same tree (buildTree's
171
+ // `loadingFor` in ssr-client-bundler.ts). Without the boundary, React finds
172
+ // the server's Suspense marker where it expects the page's first element and
173
+ // throws a hydration mismatch (#418): Stack0 Cloud's dashboard did this on
174
+ // every load, since app/dashboard/loading.tsx exists.
175
+ describe("route-level loading.tsx hydration", () => {
176
+ function Layout({ children }: { children?: any }) {
177
+ return React.createElement("main", { id: "layout" }, children);
178
+ }
179
+ function Loading() {
180
+ return React.createElement("p", { id: "pending" }, "Loading…");
181
+ }
182
+ function DashPage() {
183
+ return React.createElement("div", { id: "page" }, "Dashboard");
184
+ }
185
+ const withBoundary = (page: any) =>
186
+ React.createElement(Suspense, { fallback: React.createElement(Loading) }, page);
187
+
188
+ async function serverHtml(): Promise<string> {
189
+ const stream = await renderToReadableStream(
190
+ React.createElement(Layout, null, withBoundary(React.createElement(DashPage))),
191
+ );
192
+ await (stream as any).allReady;
193
+ const reader = stream.getReader();
194
+ let html = "";
195
+ for (;;) {
196
+ const { value, done } = await reader.read();
197
+ if (done) break;
198
+ html += dec.decode(value);
199
+ }
200
+ return html;
201
+ }
202
+
203
+ async function hydrateErrors(clientTree: any): Promise<string[]> {
204
+ const html = await serverHtml();
205
+ const win = new Window({ url: "http://localhost/" });
206
+ const root = win.document.createElement("div");
207
+ root.insertAdjacentHTML("afterbegin", html);
208
+ win.document.body.appendChild(root);
209
+ registerDom(win);
210
+ const errors: string[] = [];
211
+ const origErr = console.error;
212
+ console.error = (...a: any[]) => {
213
+ errors.push(a.map(String).join(" "));
214
+ };
215
+ try {
216
+ const { hydrateRoot } = await import("react-dom/client");
217
+ hydrateRoot(root as any, clientTree, {
218
+ onRecoverableError: (e: any) => errors.push(String(e?.message ?? e)),
219
+ });
220
+ await new Promise((r) => setTimeout(r, 50));
221
+ } finally {
222
+ console.error = origErr;
223
+ }
224
+ return errors.filter((e) => /hydrat|did not match|mismatch/i.test(e));
225
+ }
226
+
227
+ test("the client tree with the same boundary hydrates without a mismatch", async () => {
228
+ const errors = await hydrateErrors(
229
+ React.createElement(Layout, null, withBoundary(React.createElement(DashPage))),
230
+ );
231
+ expect(errors).toEqual([]);
232
+ });
233
+
234
+ test("the client tree without the boundary mismatches (the bug)", async () => {
235
+ const errors = await hydrateErrors(
236
+ React.createElement(Layout, null, React.createElement(DashPage)),
237
+ );
238
+ expect(errors.length).toBeGreaterThan(0);
239
+ });
240
+ });