@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.
Files changed (82) hide show
  1. package/README.md +10 -0
  2. package/dist/auth/index.browser.js +603 -242
  3. package/dist/auth/index.browser.js.map +1 -1
  4. package/dist/auth/index.d.ts +2 -2
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/index.js +1317 -952
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/core/index.d.ts +17 -17
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +20 -20
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/form/index.d.ts +9 -10
  13. package/dist/form/index.d.ts.map +1 -1
  14. package/dist/form/index.js +15 -15
  15. package/dist/form/index.js.map +1 -1
  16. package/dist/head/index.browser.js +20 -0
  17. package/dist/head/index.browser.js.map +1 -1
  18. package/dist/head/index.d.ts +62 -64
  19. package/dist/head/index.d.ts.map +1 -1
  20. package/dist/head/index.js +20 -0
  21. package/dist/head/index.js.map +1 -1
  22. package/dist/i18n/index.d.ts +9 -9
  23. package/dist/i18n/index.d.ts.map +1 -1
  24. package/dist/i18n/index.js.map +1 -1
  25. package/dist/router/index.browser.js +605 -244
  26. package/dist/router/index.browser.js.map +1 -1
  27. package/dist/router/index.d.ts +100 -111
  28. package/dist/router/index.d.ts.map +1 -1
  29. package/dist/router/index.js +1317 -952
  30. package/dist/router/index.js.map +1 -1
  31. package/dist/websocket/index.d.ts +0 -1
  32. package/dist/websocket/index.d.ts.map +1 -1
  33. package/package.json +6 -6
  34. package/src/auth/__tests__/$auth.spec.ts +164 -150
  35. package/src/auth/index.ts +9 -3
  36. package/src/auth/services/ReactAuth.ts +15 -5
  37. package/src/core/hooks/useAction.ts +1 -2
  38. package/src/core/index.ts +4 -4
  39. package/src/form/errors/FormValidationError.ts +4 -6
  40. package/src/form/hooks/useFormState.ts +1 -1
  41. package/src/form/index.ts +1 -1
  42. package/src/form/services/FormModel.ts +31 -25
  43. package/src/head/helpers/SeoExpander.ts +2 -1
  44. package/src/head/hooks/useHead.spec.tsx +2 -2
  45. package/src/head/index.browser.ts +2 -2
  46. package/src/head/index.ts +4 -4
  47. package/src/head/interfaces/Head.ts +15 -3
  48. package/src/head/primitives/$head.ts +2 -5
  49. package/src/head/providers/BrowserHeadProvider.ts +55 -0
  50. package/src/head/providers/HeadProvider.ts +4 -1
  51. package/src/i18n/__tests__/integration.spec.tsx +1 -1
  52. package/src/i18n/components/Localize.spec.tsx +2 -2
  53. package/src/i18n/hooks/useI18n.browser.spec.tsx +2 -2
  54. package/src/i18n/index.ts +1 -1
  55. package/src/i18n/primitives/$dictionary.ts +1 -1
  56. package/src/i18n/providers/I18nProvider.spec.ts +1 -1
  57. package/src/i18n/providers/I18nProvider.ts +1 -1
  58. package/src/router/__tests__/page-head-browser.browser.spec.ts +5 -1
  59. package/src/router/__tests__/page-head.spec.ts +11 -7
  60. package/src/router/__tests__/seo-head.spec.ts +7 -3
  61. package/src/router/atoms/ssrManifestAtom.ts +2 -11
  62. package/src/router/components/ErrorViewer.tsx +626 -167
  63. package/src/router/components/Link.tsx +4 -2
  64. package/src/router/components/NestedView.tsx +7 -9
  65. package/src/router/components/NotFound.tsx +2 -2
  66. package/src/router/hooks/useQueryParams.ts +1 -1
  67. package/src/router/hooks/useRouter.ts +1 -1
  68. package/src/router/hooks/useRouterState.ts +1 -1
  69. package/src/router/index.browser.ts +10 -11
  70. package/src/router/index.shared.ts +7 -7
  71. package/src/router/index.ts +10 -7
  72. package/src/router/primitives/$page.browser.spec.tsx +6 -1
  73. package/src/router/primitives/$page.spec.tsx +7 -1
  74. package/src/router/primitives/$page.ts +5 -9
  75. package/src/router/providers/ReactBrowserProvider.ts +17 -6
  76. package/src/router/providers/ReactBrowserRouterProvider.ts +1 -1
  77. package/src/router/providers/ReactPageProvider.ts +4 -3
  78. package/src/router/providers/ReactServerProvider.ts +32 -50
  79. package/src/router/providers/ReactServerTemplateProvider.ts +336 -155
  80. package/src/router/providers/SSRManifestProvider.ts +17 -60
  81. package/src/router/services/ReactPageService.ts +4 -1
  82. 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 { SimpleHead } from "@alepha/react/head";
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 + "\n"),
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
- while ((match = attrRegex.exec(attrStr))) {
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(script: Record<string, string | boolean>): string {
352
- const attrs = Object.entries(script)
353
- .filter(([, value]) => value !== false)
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
- return {
396
- ...store,
397
- "alepha.react.router.state": undefined,
398
- layers: state.layers.map((layer) => ({
399
- ...layer,
400
- error: layer.error
401
- ? {
402
- ...layer.error,
403
- name: layer.error.name,
404
- message: layer.error.message,
405
- stack: !this.alepha.isProduction() ? layer.error.stack : undefined,
406
- }
407
- : undefined,
408
- // Remove non-serializable properties
409
- index: undefined,
410
- path: undefined,
411
- element: undefined,
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
- * Encode a string to Uint8Array using the shared encoder.
419
- */
420
- public encode(str: string): Uint8Array {
421
- return this.encoder.encode(str);
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
- * Get the pre-encoded hydration script suffix.
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
- public get hydrationSuffix(): Uint8Array {
435
- return this.ENCODED.HYDRATION_SUFFIX;
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
- // 1. DOCTYPE
555
+ // DOCTYPE
467
556
  controller.enqueue(slots.doctype);
468
557
 
469
- // 2. <html ...>
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
- // 3. <head>...</head>
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
- // 4. <body ...>
486
- controller.enqueue(slots.bodyOpen);
487
- controller.enqueue(
488
- encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)),
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
- state: ReactRouterState;
618
- reactStream: ReadableStream<Uint8Array>;
619
- } | null>,
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, onRedirect } = options;
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
- // 1. DOCTYPE
691
+ // DOCTYPE
636
692
  controller.enqueue(slots.doctype);
637
693
 
638
- // 2. <html ...> with global htmlAttributes only
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
- // 3. <head> open + entry preloads
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 - can't undo what we've sent, but caller handles it
657
- if (!result) {
658
- // Redirect happened - close with minimal valid HTML
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
- const head = state.head;
732
+ routerState = state;
667
733
 
668
734
  // === LATE PHASE (after async work) ===
669
735
 
670
- // 4. Rest of head content (title, meta, links from loaders)
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.renderMergedBodyAttrs(head?.bodyAttributes)),
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
- // 7. <div id="root">
687
- controller.enqueue(slots.rootOpen);
752
+ controller.close();
753
+ } catch (error) {
754
+ onError?.(error);
688
755
 
689
- // 8. Stream React content
690
- const reader = reactStream.getReader();
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
- while (true) {
693
- const { done, value } = await reader.read();
694
- if (done) break;
695
- controller.enqueue(value);
696
- }
697
- } finally {
698
- reader.releaseLock();
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
- // 9. </div>
702
- controller.enqueue(slots.rootClose);
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
- // 10. Content after root (if any)
705
- if (slots.afterRoot) {
706
- controller.enqueue(encoder.encode(slots.afterRoot));
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
- // 11. Hydration script
710
- if (hydration) {
711
- const hydrationData = this.buildHydrationData(state);
712
- controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
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
- // 12. </body></html>
720
- controller.enqueue(slots.scriptClose);
828
+ controller.enqueue(slots.rootOpen);
829
+ }
721
830
 
722
- controller.close();
723
- } catch (error) {
724
- onError?.(error);
725
- controller.error(error);
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
  /**