@pylonsync/functions 0.3.328 → 0.3.330

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.328",
3
+ "version": "0.3.330",
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",
@@ -2864,6 +2864,15 @@ export async function handleRenderRoute(
2864
2864
  : pageLeaf;
2865
2865
  tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
2866
2866
  const element = tree;
2867
+ // Tracks whether the HTTP head (response_start) has actually gone out. A
2868
+ // redirect()/notFound() from a suspended subtree is only genuinely IGNORED
2869
+ // once the head is committed. Before that — every buffered render, and a
2870
+ // streaming render up until response_start — the control signal rejects the
2871
+ // render and the outer catch below emits the real 3xx/404, so the redirect
2872
+ // IS honored. Gating the onError warning on this stops a false "head already
2873
+ // sent" error from firing on every correct synchronous-shell auth-guard
2874
+ // redirect (the buffered path is the overwhelmingly common case).
2875
+ let headCommitted = false;
2867
2876
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
2868
2877
  element,
2869
2878
  {
@@ -2877,18 +2886,24 @@ export async function handleRenderRoute(
2877
2886
  // their error through the catch/boundary path below.
2878
2887
  const ctrl = asRouteControl(err);
2879
2888
  if (ctrl) {
2880
- // A redirect()/notFound() thrown from BELOW a <Suspense> boundary:
2881
- // the shell already committed the head, so React swallowed it and
2882
- // it can't change the response. This is a known limitation on BOTH
2883
- // the buffered and streamed paths (response.* must fire in the
2884
- // synchronous shell). Surface it loudly instead of silently losing.
2885
- // eslint-disable-next-line no-console
2886
- console.error(
2887
- `[ssr] ${ctrl.kind}() called below a <Suspense> boundary was ignored — ` +
2888
- `the HTTP head was already sent. Call response.redirect()/notFound() (or ` +
2889
- `notFound() from @pylonsync/react) in the synchronous shell render, before ` +
2890
- `any await/<Suspense>.`,
2891
- );
2889
+ // A redirect()/notFound() control signal surfaced during render. It
2890
+ // was only truly ignored if the head is already committed: past
2891
+ // response_start on a STREAMING render, a signal from a suspended
2892
+ // subtree can't change the already-sent response — surface it
2893
+ // loudly. Otherwise (every buffered render, and a streaming render
2894
+ // pre-commit) the signal rejects the render and the outer catch
2895
+ // turns it into the real 3xx/404, so the redirect is honored and
2896
+ // nothing was lost — warning there would fire on every correct
2897
+ // auth-guard redirect.
2898
+ if (headCommitted) {
2899
+ // eslint-disable-next-line no-console
2900
+ console.error(
2901
+ `[ssr] ${ctrl.kind}() called below a <Suspense> boundary was ignored — ` +
2902
+ `the HTTP head was already sent. Call response.redirect()/notFound() (or ` +
2903
+ `notFound() from @pylonsync/react) in the synchronous shell render, before ` +
2904
+ `any await/<Suspense>.`,
2905
+ );
2906
+ }
2892
2907
  return;
2893
2908
  }
2894
2909
  // eslint-disable-next-line no-console
@@ -3067,6 +3082,10 @@ export async function handleRenderRoute(
3067
3082
  : {}),
3068
3083
  }),
3069
3084
  });
3085
+ // The head is on the wire now — from here a redirect()/notFound() thrown by
3086
+ // a suspended subtree (streaming render) is genuinely too late to change the
3087
+ // response, so onError above will (correctly) warn.
3088
+ headCommitted = true;
3070
3089
 
3071
3090
  // Pre-load the manifest BEFORE the React stream starts emitting
3072
3091
  // so we know which `<link rel="stylesheet">` and
@@ -17,6 +17,7 @@ import { describe, expect, test } from "bun:test";
17
17
  import React, { Suspense, use } from "react";
18
18
  import { renderToReadableStream } from "react-dom/server.browser";
19
19
  import {
20
+ asRouteControl,
20
21
  buildHydrationTail,
21
22
  computeBucketVerdict,
22
23
  computeCacheVerdict,
@@ -25,6 +26,7 @@ import {
25
26
  describeCacheVerdict,
26
27
  diffCommittedResponse,
27
28
  isDevMode,
29
+ PylonRouteControl,
28
30
  } from "./ssr-runtime";
29
31
 
30
32
  // ---------------------------------------------------------------------------
@@ -405,6 +407,106 @@ describe("react streaming mechanism", () => {
405
407
  });
406
408
  });
407
409
 
410
+ // ---------------------------------------------------------------------------
411
+ // onError gating: a redirect()/notFound() is only "ignored" once the HTTP head
412
+ // is committed. The runtime tracks a `headCommitted` flag and only logs the
413
+ // scary "[ssr] … was ignored — head already sent" warning when it's true.
414
+ // Before that (every BUFFERED render, and a streaming render pre-commit) the
415
+ // control signal rejects the render and the outer catch turns it into the real
416
+ // 3xx/404 — so warning there would fire on every CORRECT synchronous-shell
417
+ // auth-guard redirect. These tests lock the React behavior that makes the gate
418
+ // correct, using the real PylonRouteControl + asRouteControl classifier.
419
+ // ---------------------------------------------------------------------------
420
+
421
+ function makeRedirect(url: string): PylonRouteControl {
422
+ const e = new PylonRouteControl("redirect");
423
+ e.url = url;
424
+ e.redirectStatus = 307;
425
+ return e;
426
+ }
427
+
428
+ describe("SSR onError gating (redirect only 'ignored' after head commit)", () => {
429
+ test("BUFFERED shell redirect: render REJECTS (→ outer catch emits 307); no warning fires pre-commit", async () => {
430
+ // The auth-guard shape: response.redirect() throws a PylonRouteControl in
431
+ // the synchronous shell, before any await/<Suspense>.
432
+ function GuardedPage(): any {
433
+ throw makeRedirect("/login");
434
+ }
435
+ let headCommitted = false;
436
+ const warnings: string[] = [];
437
+ let rejectedWith: PylonRouteControl | null = null;
438
+ try {
439
+ const stream = await renderToReadableStream(
440
+ React.createElement(GuardedPage, {}),
441
+ {
442
+ onError(err: unknown) {
443
+ const ctrl = asRouteControl(err);
444
+ // Mirror the runtime's gate exactly: only warn once head is out.
445
+ if (ctrl && headCommitted) warnings.push(ctrl.kind);
446
+ },
447
+ },
448
+ );
449
+ // Buffered path: the runtime awaits allReady, THEN sends response_start
450
+ // (commits the head). A shell throw never gets here.
451
+ await (stream as any).allReady;
452
+ headCommitted = true;
453
+ } catch (err) {
454
+ // This is the runtime's outer catch — where the real 307 is emitted.
455
+ rejectedWith = asRouteControl(err);
456
+ }
457
+ expect(rejectedWith?.kind).toBe("redirect"); // honored, not lost
458
+ expect(rejectedWith?.url).toBe("/login");
459
+ expect(headCommitted).toBe(false); // head never committed → NOT ignored
460
+ expect(warnings).toEqual([]); // ← the fix: no false warning on a correct redirect
461
+ });
462
+
463
+ test("STREAMING below-<Suspense> redirect: onError fires AFTER head commit → warns", async () => {
464
+ const d = makeDeferred<string[]>();
465
+ // Throws a redirect from BELOW the boundary, only once its data resolves —
466
+ // i.e. after the shell (and head) have already been flushed.
467
+ function LateRedirect({ p }: { p: Promise<string[]> }): any {
468
+ use(p);
469
+ throw makeRedirect("/login");
470
+ }
471
+ function StreamPage({ p }: { p: Promise<string[]> }) {
472
+ return React.createElement(
473
+ "div",
474
+ null,
475
+ React.createElement("h1", null, "Shell"),
476
+ React.createElement(
477
+ Suspense,
478
+ { fallback: React.createElement("p", null, "L") },
479
+ React.createElement(LateRedirect, { p }),
480
+ ),
481
+ );
482
+ }
483
+ let headCommitted = false;
484
+ const warnings: string[] = [];
485
+ const stream = await renderToReadableStream(
486
+ React.createElement(StreamPage, { p: d.promise }),
487
+ {
488
+ onError(err: unknown) {
489
+ const ctrl = asRouteControl(err);
490
+ if (ctrl && headCommitted) warnings.push(ctrl.kind);
491
+ },
492
+ },
493
+ );
494
+ const reader = stream.getReader();
495
+ await reader.read(); // first flush = shell + fallback → head is on the wire
496
+ headCommitted = true; // runtime sends response_start at this point
497
+ d.resolve(["x"]); // resolve → LateRedirect throws below the committed boundary
498
+ try {
499
+ for (;;) {
500
+ const { done } = await reader.read();
501
+ if (done) break;
502
+ }
503
+ } catch {
504
+ /* the stream may error after the late throw; irrelevant here */
505
+ }
506
+ expect(warnings).toEqual(["redirect"]); // genuinely ignored → correctly warned
507
+ });
508
+ });
509
+
408
510
  describe("hydration tail ordering (#278: data blob before entry script)", () => {
409
511
  test("__PYLON_DATA__ carries ssrData and the entry <script type=module> is LAST", () => {
410
512
  const ssrData = { 'list:["Note"]': [{ id: "1", body: "hi" }] };