@pylonsync/functions 0.3.327 → 0.3.329
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 +1 -1
- package/src/ssr-runtime.ts +31 -12
- package/src/ssr-streaming.test.ts +102 -0
package/package.json
CHANGED
package/src/ssr-runtime.ts
CHANGED
|
@@ -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()
|
|
2881
|
-
//
|
|
2882
|
-
//
|
|
2883
|
-
//
|
|
2884
|
-
//
|
|
2885
|
-
//
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
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" }] };
|