@alepha/react 0.14.4 → 0.15.1
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/README.md +10 -0
- package/dist/auth/index.browser.js +603 -242
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1317 -952
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +17 -17
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +20 -20
- package/dist/core/index.js.map +1 -1
- package/dist/form/index.d.ts +9 -10
- package/dist/form/index.d.ts.map +1 -1
- package/dist/form/index.js +15 -15
- package/dist/form/index.js.map +1 -1
- package/dist/head/index.browser.js +20 -0
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +62 -64
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +20 -0
- package/dist/head/index.js.map +1 -1
- package/dist/i18n/index.d.ts +9 -9
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js.map +1 -1
- package/dist/router/index.browser.js +605 -244
- package/dist/router/index.browser.js.map +1 -1
- package/dist/router/index.d.ts +100 -111
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +1317 -952
- package/dist/router/index.js.map +1 -1
- package/dist/websocket/index.d.ts +0 -1
- package/dist/websocket/index.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/auth/__tests__/$auth.spec.ts +164 -150
- package/src/auth/index.ts +9 -3
- package/src/auth/services/ReactAuth.ts +15 -5
- package/src/core/hooks/useAction.ts +1 -2
- package/src/core/index.ts +4 -4
- package/src/form/errors/FormValidationError.ts +4 -6
- package/src/form/hooks/useFormState.ts +1 -1
- package/src/form/index.ts +1 -1
- package/src/form/services/FormModel.ts +31 -25
- package/src/head/helpers/SeoExpander.ts +2 -1
- package/src/head/hooks/useHead.spec.tsx +2 -2
- package/src/head/index.browser.ts +2 -2
- package/src/head/index.ts +4 -4
- package/src/head/interfaces/Head.ts +15 -3
- package/src/head/primitives/$head.ts +2 -5
- package/src/head/providers/BrowserHeadProvider.ts +55 -0
- package/src/head/providers/HeadProvider.ts +4 -1
- package/src/i18n/__tests__/integration.spec.tsx +1 -1
- package/src/i18n/components/Localize.spec.tsx +2 -2
- package/src/i18n/hooks/useI18n.browser.spec.tsx +2 -2
- package/src/i18n/index.ts +1 -1
- package/src/i18n/primitives/$dictionary.ts +1 -1
- package/src/i18n/providers/I18nProvider.spec.ts +1 -1
- package/src/i18n/providers/I18nProvider.ts +1 -1
- package/src/router/__tests__/page-head-browser.browser.spec.ts +5 -1
- package/src/router/__tests__/page-head.spec.ts +11 -7
- package/src/router/__tests__/seo-head.spec.ts +7 -3
- package/src/router/atoms/ssrManifestAtom.ts +2 -11
- package/src/router/components/ErrorViewer.tsx +626 -167
- package/src/router/components/Link.tsx +4 -2
- package/src/router/components/NestedView.tsx +7 -9
- package/src/router/components/NotFound.tsx +2 -2
- package/src/router/hooks/useQueryParams.ts +1 -1
- package/src/router/hooks/useRouter.ts +1 -1
- package/src/router/hooks/useRouterState.ts +1 -1
- package/src/router/index.browser.ts +10 -11
- package/src/router/index.shared.ts +7 -7
- package/src/router/index.ts +10 -7
- package/src/router/primitives/$page.browser.spec.tsx +6 -1
- package/src/router/primitives/$page.spec.tsx +7 -1
- package/src/router/primitives/$page.ts +5 -9
- package/src/router/providers/ReactBrowserProvider.ts +17 -6
- package/src/router/providers/ReactBrowserRouterProvider.ts +1 -1
- package/src/router/providers/ReactPageProvider.ts +4 -3
- package/src/router/providers/ReactServerProvider.ts +32 -50
- package/src/router/providers/ReactServerTemplateProvider.ts +336 -155
- package/src/router/providers/SSRManifestProvider.ts +17 -60
- package/src/router/services/ReactPageService.ts +4 -1
- package/src/router/services/ReactRouter.ts +6 -5
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { AlephaContext } from "@alepha/react";
|
|
2
|
+
import type { SimpleHead } from "@alepha/react/head";
|
|
1
3
|
import { $inject, Alepha, AlephaError } from "alepha";
|
|
2
4
|
import { $logger } from "alepha/logger";
|
|
3
|
-
import type
|
|
5
|
+
import { createElement, type ReactNode } from "react";
|
|
6
|
+
import { renderToString } from "react-dom/server";
|
|
7
|
+
import ErrorViewer from "../components/ErrorViewer.tsx";
|
|
8
|
+
import { Redirection } from "../errors/Redirection.ts";
|
|
4
9
|
import type { ReactRouterState } from "./ReactPageProvider.ts";
|
|
5
10
|
|
|
6
11
|
/**
|
|
@@ -39,7 +44,6 @@ export class ReactServerTemplateProvider {
|
|
|
39
44
|
*/
|
|
40
45
|
protected slots: TemplateSlots | null = null;
|
|
41
46
|
|
|
42
|
-
|
|
43
47
|
/**
|
|
44
48
|
* Root element ID for React mounting.
|
|
45
49
|
*/
|
|
@@ -93,8 +97,6 @@ export class ReactServerTemplateProvider {
|
|
|
93
97
|
*
|
|
94
98
|
* This should be called once during server startup/configuration.
|
|
95
99
|
* The parsed slots are cached and reused for all requests.
|
|
96
|
-
*
|
|
97
|
-
* @param template - The HTML template string (typically index.html)
|
|
98
100
|
*/
|
|
99
101
|
public parseTemplate(template: string): TemplateSlots {
|
|
100
102
|
this.log.debug("Parsing template into slots");
|
|
@@ -168,7 +170,7 @@ export class ReactServerTemplateProvider {
|
|
|
168
170
|
|
|
169
171
|
this.slots = {
|
|
170
172
|
// Pre-encoded static parts
|
|
171
|
-
doctype: this.encoder.encode(doctype
|
|
173
|
+
doctype: this.encoder.encode(`${doctype}\n`),
|
|
172
174
|
htmlOpen: this.encoder.encode("<html"),
|
|
173
175
|
htmlClose: this.encoder.encode(">\n"),
|
|
174
176
|
headOpen: this.encoder.encode("<head>"),
|
|
@@ -209,9 +211,8 @@ export class ReactServerTemplateProvider {
|
|
|
209
211
|
|
|
210
212
|
// Match: key="value", key='value', key=value, or just key (boolean)
|
|
211
213
|
const attrRegex = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
212
|
-
let match: RegExpExecArray | null;
|
|
213
214
|
|
|
214
|
-
|
|
215
|
+
for (const match of attrStr.matchAll(attrRegex)) {
|
|
215
216
|
const key = match[1];
|
|
216
217
|
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
217
218
|
attrs[key] = value;
|
|
@@ -331,10 +332,14 @@ export class ReactServerTemplateProvider {
|
|
|
331
332
|
protected renderLinkTag(link: {
|
|
332
333
|
rel: string;
|
|
333
334
|
href: string;
|
|
335
|
+
type?: string;
|
|
334
336
|
as?: string;
|
|
335
337
|
crossorigin?: string;
|
|
336
338
|
}): string {
|
|
337
339
|
let tag = `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`;
|
|
340
|
+
if (link.type) {
|
|
341
|
+
tag += ` type="${this.escapeHtml(link.type)}"`;
|
|
342
|
+
}
|
|
338
343
|
if (link.as) {
|
|
339
344
|
tag += ` as="${this.escapeHtml(link.as)}"`;
|
|
340
345
|
}
|
|
@@ -348,14 +353,30 @@ export class ReactServerTemplateProvider {
|
|
|
348
353
|
/**
|
|
349
354
|
* Render a script tag.
|
|
350
355
|
*/
|
|
351
|
-
protected renderScriptTag(
|
|
352
|
-
|
|
353
|
-
|
|
356
|
+
protected renderScriptTag(
|
|
357
|
+
script:
|
|
358
|
+
| string
|
|
359
|
+
| (Record<string, string | boolean | undefined> & { content?: string }),
|
|
360
|
+
): string {
|
|
361
|
+
// Handle plain string as inline script
|
|
362
|
+
if (typeof script === "string") {
|
|
363
|
+
return `<script>${script}</script>\n`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const { content, ...rest } = script;
|
|
367
|
+
const attrs = Object.entries(rest)
|
|
368
|
+
.filter(([, value]) => value !== false && value !== undefined)
|
|
354
369
|
.map(([key, value]) => {
|
|
355
370
|
if (value === true) return key;
|
|
356
371
|
return `${key}="${this.escapeHtml(String(value))}"`;
|
|
357
372
|
})
|
|
358
373
|
.join(" ");
|
|
374
|
+
|
|
375
|
+
if (content) {
|
|
376
|
+
return attrs
|
|
377
|
+
? `<script ${attrs}>${content}</script>\n`
|
|
378
|
+
: `<script>${content}</script>\n`;
|
|
379
|
+
}
|
|
359
380
|
return `<script ${attrs}></script>\n`;
|
|
360
381
|
}
|
|
361
382
|
|
|
@@ -392,47 +413,115 @@ export class ReactServerTemplateProvider {
|
|
|
392
413
|
const { request, context, ...store } =
|
|
393
414
|
this.alepha.context.als?.getStore() ?? {};
|
|
394
415
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
route: undefined,
|
|
413
|
-
})),
|
|
416
|
+
const layers = state.layers.map((layer) => ({
|
|
417
|
+
part: layer.part, // mandatory for previous-checking
|
|
418
|
+
name: layer.name, // mandatory for previous-checking
|
|
419
|
+
config: layer.config, // mandatory for previous-checking (contains 'query' & 'params')
|
|
420
|
+
props: layer.props, // our not-so-secret data cache
|
|
421
|
+
error: layer.error
|
|
422
|
+
? {
|
|
423
|
+
...layer.error,
|
|
424
|
+
name: layer.error.name,
|
|
425
|
+
message: layer.error.message,
|
|
426
|
+
stack: !this.alepha.isProduction() ? layer.error.stack : undefined,
|
|
427
|
+
}
|
|
428
|
+
: undefined,
|
|
429
|
+
}));
|
|
430
|
+
|
|
431
|
+
const hydrationData: HydrationData = {
|
|
432
|
+
layers,
|
|
414
433
|
};
|
|
415
|
-
}
|
|
416
434
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
435
|
+
for (const [key, value] of Object.entries(store)) {
|
|
436
|
+
if (
|
|
437
|
+
key.charAt(0) !== "_" &&
|
|
438
|
+
key !== "alepha.react.router.state" &&
|
|
439
|
+
key !== "registry"
|
|
440
|
+
) {
|
|
441
|
+
hydrationData[key] = value;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
423
444
|
|
|
424
|
-
|
|
425
|
-
* Get the pre-encoded hydration script prefix.
|
|
426
|
-
*/
|
|
427
|
-
public get hydrationPrefix(): Uint8Array {
|
|
428
|
-
return this.ENCODED.HYDRATION_PREFIX;
|
|
445
|
+
return hydrationData;
|
|
429
446
|
}
|
|
430
447
|
|
|
431
448
|
/**
|
|
432
|
-
*
|
|
449
|
+
* Stream the body content: body tag, root div, React content, hydration, and closing tags.
|
|
450
|
+
*
|
|
451
|
+
* If an error occurs during React streaming, it injects error HTML instead of aborting,
|
|
452
|
+
* ensuring users see an error message rather than a white screen.
|
|
433
453
|
*/
|
|
434
|
-
|
|
435
|
-
|
|
454
|
+
protected async streamBodyContent(
|
|
455
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
456
|
+
reactStream: ReadableStream<Uint8Array>,
|
|
457
|
+
state: ReactRouterState,
|
|
458
|
+
hydration: boolean,
|
|
459
|
+
): Promise<void> {
|
|
460
|
+
const slots = this.getSlots();
|
|
461
|
+
const encoder = this.encoder;
|
|
462
|
+
const head = state.head;
|
|
463
|
+
|
|
464
|
+
// <body ...>
|
|
465
|
+
controller.enqueue(slots.bodyOpen);
|
|
466
|
+
controller.enqueue(
|
|
467
|
+
encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)),
|
|
468
|
+
);
|
|
469
|
+
controller.enqueue(slots.bodyClose);
|
|
470
|
+
|
|
471
|
+
// Content before root (if any)
|
|
472
|
+
if (slots.beforeRoot) {
|
|
473
|
+
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// <div id="root">
|
|
477
|
+
controller.enqueue(slots.rootOpen);
|
|
478
|
+
|
|
479
|
+
// Stream React content - catch errors from the React stream
|
|
480
|
+
const reader = reactStream.getReader();
|
|
481
|
+
let streamError: unknown = null;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
while (true) {
|
|
485
|
+
const { done, value } = await reader.read();
|
|
486
|
+
if (done) break;
|
|
487
|
+
controller.enqueue(value);
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
// React stream errored - save for error HTML injection
|
|
491
|
+
streamError = error;
|
|
492
|
+
this.log.error("Error during React stream reading", error);
|
|
493
|
+
} finally {
|
|
494
|
+
reader.releaseLock();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// If React stream errored, inject error HTML inside the root div
|
|
498
|
+
if (streamError) {
|
|
499
|
+
this.injectErrorHtml(controller, encoder, slots, streamError, state, {
|
|
500
|
+
headClosed: true,
|
|
501
|
+
bodyStarted: true,
|
|
502
|
+
});
|
|
503
|
+
// injectErrorHtml already closes the document, so return early
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// </div>
|
|
508
|
+
controller.enqueue(slots.rootClose);
|
|
509
|
+
|
|
510
|
+
// Content after root (if any)
|
|
511
|
+
if (slots.afterRoot) {
|
|
512
|
+
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Hydration script
|
|
516
|
+
if (hydration) {
|
|
517
|
+
const hydrationData = this.buildHydrationData(state);
|
|
518
|
+
controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
|
|
519
|
+
controller.enqueue(encoder.encode(this.safeJsonSerialize(hydrationData)));
|
|
520
|
+
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// </body></html>
|
|
524
|
+
controller.enqueue(slots.scriptClose);
|
|
436
525
|
}
|
|
437
526
|
|
|
438
527
|
/**
|
|
@@ -463,72 +552,31 @@ export class ReactServerTemplateProvider {
|
|
|
463
552
|
return new ReadableStream<Uint8Array>({
|
|
464
553
|
start: async (controller) => {
|
|
465
554
|
try {
|
|
466
|
-
//
|
|
555
|
+
// DOCTYPE
|
|
467
556
|
controller.enqueue(slots.doctype);
|
|
468
557
|
|
|
469
|
-
//
|
|
558
|
+
// <html ...>
|
|
470
559
|
controller.enqueue(slots.htmlOpen);
|
|
471
560
|
controller.enqueue(
|
|
472
561
|
encoder.encode(this.renderMergedHtmlAttrs(head?.htmlAttributes)),
|
|
473
562
|
);
|
|
474
563
|
controller.enqueue(slots.htmlClose);
|
|
475
564
|
|
|
476
|
-
//
|
|
565
|
+
// <head>...</head>
|
|
477
566
|
controller.enqueue(slots.headOpen);
|
|
478
|
-
// Include early head content (entry.js, CSS) if set
|
|
479
567
|
if (this.earlyHeadContent) {
|
|
480
568
|
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
481
569
|
}
|
|
482
570
|
controller.enqueue(encoder.encode(this.renderHeadContent(head)));
|
|
483
571
|
controller.enqueue(slots.headClose);
|
|
484
572
|
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
573
|
+
// Body content (body, root, React, hydration, closing tags)
|
|
574
|
+
await this.streamBodyContent(
|
|
575
|
+
controller,
|
|
576
|
+
reactStream,
|
|
577
|
+
state,
|
|
578
|
+
hydration,
|
|
489
579
|
);
|
|
490
|
-
controller.enqueue(slots.bodyClose);
|
|
491
|
-
|
|
492
|
-
// 5. Content before root (if any)
|
|
493
|
-
if (slots.beforeRoot) {
|
|
494
|
-
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// 6. <div id="root">
|
|
498
|
-
controller.enqueue(slots.rootOpen);
|
|
499
|
-
|
|
500
|
-
// 7. Stream React content
|
|
501
|
-
const reader = reactStream.getReader();
|
|
502
|
-
try {
|
|
503
|
-
while (true) {
|
|
504
|
-
const { done, value } = await reader.read();
|
|
505
|
-
if (done) break;
|
|
506
|
-
controller.enqueue(value);
|
|
507
|
-
}
|
|
508
|
-
} finally {
|
|
509
|
-
reader.releaseLock();
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// 8. </div>
|
|
513
|
-
controller.enqueue(slots.rootClose);
|
|
514
|
-
|
|
515
|
-
// 9. Content after root (if any)
|
|
516
|
-
if (slots.afterRoot) {
|
|
517
|
-
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// 10. Hydration script
|
|
521
|
-
if (hydration) {
|
|
522
|
-
const hydrationData = this.buildHydrationData(state);
|
|
523
|
-
controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
|
|
524
|
-
controller.enqueue(
|
|
525
|
-
encoder.encode(this.safeJsonSerialize(hydrationData)),
|
|
526
|
-
);
|
|
527
|
-
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// 11. </body></html>
|
|
531
|
-
controller.enqueue(slots.scriptClose);
|
|
532
580
|
|
|
533
581
|
controller.close();
|
|
534
582
|
} catch (error) {
|
|
@@ -613,29 +661,37 @@ export class ReactServerTemplateProvider {
|
|
|
613
661
|
*/
|
|
614
662
|
public createEarlyHtmlStream(
|
|
615
663
|
globalHead: SimpleHead,
|
|
616
|
-
asyncWork: () => Promise<
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
664
|
+
asyncWork: () => Promise<
|
|
665
|
+
| {
|
|
666
|
+
state: ReactRouterState;
|
|
667
|
+
reactStream: ReadableStream<Uint8Array>;
|
|
668
|
+
}
|
|
669
|
+
| { redirect: string }
|
|
670
|
+
| null
|
|
671
|
+
>,
|
|
620
672
|
options: {
|
|
621
673
|
hydration?: boolean;
|
|
622
674
|
onError?: (error: unknown) => void;
|
|
623
|
-
onRedirect?: (url: string) => void;
|
|
624
675
|
} = {},
|
|
625
676
|
): ReadableStream<Uint8Array> {
|
|
626
|
-
const { hydration = true, onError
|
|
677
|
+
const { hydration = true, onError } = options;
|
|
627
678
|
const slots = this.getSlots();
|
|
628
679
|
const encoder = this.encoder;
|
|
629
680
|
|
|
681
|
+
// Track streaming state for error recovery
|
|
682
|
+
let headClosed = false;
|
|
683
|
+
let bodyStarted = false;
|
|
684
|
+
let routerState: ReactRouterState | undefined;
|
|
685
|
+
|
|
630
686
|
return new ReadableStream<Uint8Array>({
|
|
631
687
|
start: async (controller) => {
|
|
632
688
|
try {
|
|
633
689
|
// === EARLY PHASE (before async work) ===
|
|
634
690
|
|
|
635
|
-
//
|
|
691
|
+
// DOCTYPE
|
|
636
692
|
controller.enqueue(slots.doctype);
|
|
637
693
|
|
|
638
|
-
//
|
|
694
|
+
// <html ...> with global htmlAttributes only
|
|
639
695
|
controller.enqueue(slots.htmlOpen);
|
|
640
696
|
controller.enqueue(
|
|
641
697
|
encoder.encode(
|
|
@@ -644,7 +700,7 @@ export class ReactServerTemplateProvider {
|
|
|
644
700
|
);
|
|
645
701
|
controller.enqueue(slots.htmlClose);
|
|
646
702
|
|
|
647
|
-
//
|
|
703
|
+
// <head> open + entry preloads
|
|
648
704
|
controller.enqueue(slots.headOpen);
|
|
649
705
|
if (this.earlyHeadContent) {
|
|
650
706
|
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
@@ -653,9 +709,19 @@ export class ReactServerTemplateProvider {
|
|
|
653
709
|
// === ASYNC WORK (createLayers, etc.) ===
|
|
654
710
|
const result = await asyncWork();
|
|
655
711
|
|
|
656
|
-
// Handle redirect -
|
|
657
|
-
if (!result) {
|
|
658
|
-
|
|
712
|
+
// Handle redirect - inject meta refresh since headers already sent
|
|
713
|
+
if (!result || "redirect" in result) {
|
|
714
|
+
if (result && "redirect" in result) {
|
|
715
|
+
this.log.debug(
|
|
716
|
+
"Loader redirect detected after streaming started, using meta refresh",
|
|
717
|
+
{ redirect: result.redirect },
|
|
718
|
+
);
|
|
719
|
+
controller.enqueue(
|
|
720
|
+
encoder.encode(
|
|
721
|
+
`<meta http-equiv="refresh" content="0; url=${this.escapeHtml(result.redirect)}">\n`,
|
|
722
|
+
),
|
|
723
|
+
);
|
|
724
|
+
}
|
|
659
725
|
controller.enqueue(slots.headClose);
|
|
660
726
|
controller.enqueue(encoder.encode("<body></body></html>"));
|
|
661
727
|
controller.close();
|
|
@@ -663,73 +729,188 @@ export class ReactServerTemplateProvider {
|
|
|
663
729
|
}
|
|
664
730
|
|
|
665
731
|
const { state, reactStream } = result;
|
|
666
|
-
|
|
732
|
+
routerState = state;
|
|
667
733
|
|
|
668
734
|
// === LATE PHASE (after async work) ===
|
|
669
735
|
|
|
670
|
-
//
|
|
671
|
-
controller.enqueue(encoder.encode(this.renderHeadContent(head)));
|
|
672
|
-
controller.enqueue(slots.headClose);
|
|
673
|
-
|
|
674
|
-
// 5. <body ...> with merged bodyAttributes
|
|
675
|
-
controller.enqueue(slots.bodyOpen);
|
|
736
|
+
// Rest of head content (title, meta, links from loaders)
|
|
676
737
|
controller.enqueue(
|
|
677
|
-
encoder.encode(this.
|
|
738
|
+
encoder.encode(this.renderHeadContent(state.head)),
|
|
739
|
+
);
|
|
740
|
+
controller.enqueue(slots.headClose);
|
|
741
|
+
headClosed = true;
|
|
742
|
+
|
|
743
|
+
// Body content (body, root, React, hydration, closing tags)
|
|
744
|
+
bodyStarted = true;
|
|
745
|
+
await this.streamBodyContent(
|
|
746
|
+
controller,
|
|
747
|
+
reactStream,
|
|
748
|
+
state,
|
|
749
|
+
hydration,
|
|
678
750
|
);
|
|
679
|
-
controller.enqueue(slots.bodyClose);
|
|
680
|
-
|
|
681
|
-
// 6. Content before root (if any)
|
|
682
|
-
if (slots.beforeRoot) {
|
|
683
|
-
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
684
|
-
}
|
|
685
751
|
|
|
686
|
-
|
|
687
|
-
|
|
752
|
+
controller.close();
|
|
753
|
+
} catch (error) {
|
|
754
|
+
onError?.(error);
|
|
688
755
|
|
|
689
|
-
//
|
|
690
|
-
|
|
756
|
+
// Instead of aborting the stream, inject error HTML so user sees
|
|
757
|
+
// an error message instead of white screen.
|
|
758
|
+
// React 19 streaming SSR doesn't reliably trigger ErrorBoundary,
|
|
759
|
+
// so we must handle it at the stream level.
|
|
691
760
|
try {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
761
|
+
this.injectErrorHtml(
|
|
762
|
+
controller,
|
|
763
|
+
encoder,
|
|
764
|
+
slots,
|
|
765
|
+
error,
|
|
766
|
+
routerState,
|
|
767
|
+
{ headClosed, bodyStarted },
|
|
768
|
+
);
|
|
769
|
+
controller.close();
|
|
770
|
+
} catch {
|
|
771
|
+
// If error injection fails, abort as last resort
|
|
772
|
+
controller.error(error);
|
|
699
773
|
}
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
}
|
|
700
778
|
|
|
701
|
-
|
|
702
|
-
|
|
779
|
+
/**
|
|
780
|
+
* Inject error HTML into the stream when an error occurs during streaming.
|
|
781
|
+
*
|
|
782
|
+
* Uses the router state's onError handler to render the error component,
|
|
783
|
+
* falling back to ErrorViewer if no custom handler is defined.
|
|
784
|
+
* Renders using renderToString to produce static HTML.
|
|
785
|
+
*
|
|
786
|
+
* Since we may have already sent partial HTML (DOCTYPE, <html>, <head>),
|
|
787
|
+
* we need to complete the document with an error message instead of aborting.
|
|
788
|
+
*
|
|
789
|
+
* Handles different states:
|
|
790
|
+
* - headClosed=false, bodyStarted=false: Need to add head content, close head, open body, add error, close all
|
|
791
|
+
* - headClosed=true, bodyStarted=false: Need to open body, add error, close all
|
|
792
|
+
* - headClosed=true, bodyStarted=true: Already inside root div, add error, close all
|
|
793
|
+
*/
|
|
794
|
+
protected injectErrorHtml(
|
|
795
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
796
|
+
encoder: TextEncoder,
|
|
797
|
+
slots: TemplateSlots,
|
|
798
|
+
error: unknown,
|
|
799
|
+
routerState: ReactRouterState | undefined,
|
|
800
|
+
streamState: { headClosed: boolean; bodyStarted: boolean },
|
|
801
|
+
): void {
|
|
802
|
+
// If head not closed, add remaining head content first
|
|
803
|
+
if (!streamState.headClosed) {
|
|
804
|
+
// Include original head content (CSS, scripts) and any head from router state
|
|
805
|
+
const headContent = this.renderHeadContent(routerState?.head);
|
|
806
|
+
if (headContent) {
|
|
807
|
+
controller.enqueue(encoder.encode(headContent));
|
|
808
|
+
}
|
|
809
|
+
controller.enqueue(slots.headClose);
|
|
810
|
+
}
|
|
703
811
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
812
|
+
// If body hasn't started, we need to open body and root div
|
|
813
|
+
if (!streamState.bodyStarted) {
|
|
814
|
+
// Open body with any body attributes from state
|
|
815
|
+
controller.enqueue(slots.bodyOpen);
|
|
816
|
+
controller.enqueue(
|
|
817
|
+
encoder.encode(
|
|
818
|
+
this.renderMergedBodyAttrs(routerState?.head?.bodyAttributes),
|
|
819
|
+
),
|
|
820
|
+
);
|
|
821
|
+
controller.enqueue(slots.bodyClose);
|
|
708
822
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
controller.enqueue(
|
|
714
|
-
encoder.encode(this.safeJsonSerialize(hydrationData)),
|
|
715
|
-
);
|
|
716
|
-
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
717
|
-
}
|
|
823
|
+
// Content before root (if any)
|
|
824
|
+
if (slots.beforeRoot) {
|
|
825
|
+
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
826
|
+
}
|
|
718
827
|
|
|
719
|
-
|
|
720
|
-
|
|
828
|
+
controller.enqueue(slots.rootOpen);
|
|
829
|
+
}
|
|
721
830
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
831
|
+
// Try to render error using router state's error handler
|
|
832
|
+
const errorHtml = this.renderErrorToString(
|
|
833
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
834
|
+
routerState,
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
controller.enqueue(encoder.encode(errorHtml));
|
|
838
|
+
|
|
839
|
+
// Close root div
|
|
840
|
+
controller.enqueue(slots.rootClose);
|
|
841
|
+
|
|
842
|
+
// Content after root (if any)
|
|
843
|
+
if (!streamState.bodyStarted && slots.afterRoot) {
|
|
844
|
+
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Close document
|
|
848
|
+
controller.enqueue(slots.scriptClose);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Render an error to HTML string using the router's error handler.
|
|
853
|
+
*
|
|
854
|
+
* Falls back to ErrorViewer if:
|
|
855
|
+
* - No router state is available
|
|
856
|
+
* - The error handler returns null/undefined
|
|
857
|
+
* - The error handler itself throws
|
|
858
|
+
*/
|
|
859
|
+
protected renderErrorToString(
|
|
860
|
+
error: Error,
|
|
861
|
+
routerState: ReactRouterState | undefined,
|
|
862
|
+
): string {
|
|
863
|
+
// Log the error with stack trace for debugging
|
|
864
|
+
this.log.error("SSR rendering error", error);
|
|
865
|
+
|
|
866
|
+
let errorElement: ReactNode;
|
|
867
|
+
|
|
868
|
+
// Try to use the router state's error handler
|
|
869
|
+
if (routerState?.onError) {
|
|
870
|
+
try {
|
|
871
|
+
const result = routerState.onError(error, routerState);
|
|
872
|
+
|
|
873
|
+
// If handler returns a Redirection, we can't handle it (headers already sent)
|
|
874
|
+
// Log and fall through to default error viewer
|
|
875
|
+
if (result instanceof Redirection) {
|
|
876
|
+
this.log.warn(
|
|
877
|
+
"Error handler returned Redirection but headers already sent",
|
|
878
|
+
{ redirect: result.redirect },
|
|
879
|
+
);
|
|
880
|
+
} else if (result !== null && result !== undefined) {
|
|
881
|
+
errorElement = result;
|
|
726
882
|
}
|
|
727
|
-
}
|
|
728
|
-
|
|
883
|
+
} catch (handlerError) {
|
|
884
|
+
this.log.error("Error handler threw an exception", handlerError);
|
|
885
|
+
// Fall through to default error viewer
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Fall back to ErrorViewer if no element was produced
|
|
890
|
+
if (!errorElement) {
|
|
891
|
+
errorElement = createElement(ErrorViewer, {
|
|
892
|
+
error,
|
|
893
|
+
alepha: this.alepha,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Wrap in AlephaContext.Provider so any components that need it can access it
|
|
898
|
+
const wrappedElement = createElement(
|
|
899
|
+
AlephaContext.Provider,
|
|
900
|
+
{ value: this.alepha },
|
|
901
|
+
errorElement,
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
try {
|
|
905
|
+
return renderToString(wrappedElement);
|
|
906
|
+
} catch (renderError) {
|
|
907
|
+
// If renderToString fails, return minimal fallback HTML
|
|
908
|
+
this.log.error("Failed to render error component", renderError);
|
|
909
|
+
return error.message;
|
|
910
|
+
}
|
|
729
911
|
}
|
|
730
912
|
}
|
|
731
913
|
|
|
732
|
-
|
|
733
914
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
734
915
|
|
|
735
916
|
/**
|