@pylonsync/functions 0.3.293 → 0.3.294

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.3.293",
3
+ "version": "0.3.294",
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",
@@ -160,6 +160,38 @@ describe("ssr-client-bundler (Phase 1.5e)", () => {
160
160
  expect(chunkHasReact).toBe(true);
161
161
  });
162
162
 
163
+ test("client runtime wires the nav-fallback guard (uncaught render error → full page load)", async () => {
164
+ // Regression: a page that renders React-19-hoisted <title>/<meta>/<link> in
165
+ // its own tree (use the `metadata` export instead) makes a client-side nav
166
+ // re-render throw in React's commit phase — the URL changes but the page
167
+ // can't swap (white screen). hydrateRoot must carry an onUncaughtError that
168
+ // falls back to a full page load of the in-flight destination so nav
169
+ // degrades gracefully. The distinctive console string is preserved through
170
+ // minification, so we assert the guard actually ships in the bundle.
171
+ tempDir = makeFixture(
172
+ { "page.tsx": PAGE_BODY("Home") },
173
+ { "layout.tsx": LAYOUT_BODY },
174
+ );
175
+ originalCwd = process.cwd();
176
+ process.chdir(tempDir);
177
+
178
+ const { outdir } = await buildClientBundle();
179
+ // The runtime (hydrateRoot + guard) lands in the shared chunk with multiple
180
+ // entries, or inlined in the entry with one — read every emitted .js.
181
+ const readJsRecursive = (dir: string): string[] =>
182
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((d) => {
183
+ const full = path.join(dir, d.name);
184
+ if (d.isDirectory()) return readJsRecursive(full);
185
+ return d.name.endsWith(".js") ? [fs.readFileSync(full, "utf8")] : [];
186
+ });
187
+ const bundled = readJsRecursive(outdir).join("\n");
188
+
189
+ // onUncaughtError is a React hydrateRoot option (property name preserved).
190
+ expect(bundled).toMatch(/onUncaughtError/);
191
+ // The fallback path: a full page load of the pending destination.
192
+ expect(bundled).toMatch(/falling back to a full page load/);
193
+ });
194
+
163
195
  test("manifest names every route, each with a non-empty imports list", async () => {
164
196
  tempDir = makeFixture(
165
197
  {
@@ -212,6 +212,10 @@ import { createPylonBoundary } from "./client-boundary";
212
212
 
213
213
  const routeCache = Object.create(null);
214
214
  let activeRoot = null;
215
+ // Destination of an in-flight client navigation. Read by hydrateRoot's
216
+ // onUncaughtError so a re-render that throws mid-nav degrades to a full page
217
+ // load instead of a white screen. Null when no nav is in flight.
218
+ let pendingNav = null;
215
219
  let manifestPromise = null;
216
220
  const prefetchedChunks = new Set();
217
221
 
@@ -466,7 +470,30 @@ export function hydrate(component, Page, Layouts) {
466
470
  data.component,
467
471
  navEpoch,
468
472
  );
469
- activeRoot = hydrateRoot(document, tree);
473
+ activeRoot = hydrateRoot(document, tree, {
474
+ // Safety net for client navigation. If a nav re-render throws an uncaught
475
+ // error in React's commit phase, the URL has already changed but the page
476
+ // can't swap — a white/stale screen. The classic trigger is a page that
477
+ // renders hoisted <title>/<meta>/<link> in its own tree (use the
478
+ // \`metadata\` export instead); React 19 owns those head nodes on the client
479
+ // and reconciling them across routes can throw. Rather than strand the
480
+ // user, fall back to a full page load of the pending destination, which
481
+ // re-renders it cleanly from SSR. Non-navigation errors keep React's
482
+ // default reporting.
483
+ onUncaughtError(error) {
484
+ if (pendingNav) {
485
+ const dest = pendingNav;
486
+ pendingNav = null;
487
+ console.error(
488
+ "[pylon ssr] client navigation failed to render; falling back to a full page load:",
489
+ error,
490
+ );
491
+ window.location.href = dest;
492
+ return;
493
+ }
494
+ console.error(error);
495
+ },
496
+ });
470
497
  installNavHandlers();
471
498
  return;
472
499
  }
@@ -544,13 +571,21 @@ async function navigate(href, opts) {
544
571
  setNavParams(data);
545
572
  navEpoch++;
546
573
  currentPageProps = withClientProps(data);
574
+ const target = url.pathname + url.search;
547
575
  const tree = withBoundary(
548
576
  buildTree(route.Page, route.Layouts, currentPageProps),
549
577
  data.component,
550
578
  navEpoch,
551
579
  );
580
+ // Track the in-flight destination so hydrateRoot's onUncaughtError can fall
581
+ // back to a full page load if this re-render throws in React's commit phase
582
+ // (instead of leaving the URL changed but the page unswapped). Cleared on the
583
+ // next macrotask once the commit has settled with no error.
584
+ pendingNav = target;
552
585
  activeRoot.render(tree);
553
- const target = url.pathname + url.search;
586
+ setTimeout(() => {
587
+ if (pendingNav === target) pendingNav = null;
588
+ }, 0);
554
589
  if (opts && opts.replace) {
555
590
  history.replaceState({ component: data.component }, "", target);
556
591
  } else if (push) {