@alepha/react 0.15.0 → 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 (81) hide show
  1. package/dist/auth/index.browser.js +603 -242
  2. package/dist/auth/index.browser.js.map +1 -1
  3. package/dist/auth/index.d.ts +6 -6
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +1296 -922
  6. package/dist/auth/index.js.map +1 -1
  7. package/dist/core/index.d.ts +128 -128
  8. package/dist/core/index.d.ts.map +1 -1
  9. package/dist/core/index.js +20 -20
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/form/index.d.ts +36 -36
  12. package/dist/form/index.d.ts.map +1 -1
  13. package/dist/form/index.js +15 -15
  14. package/dist/form/index.js.map +1 -1
  15. package/dist/head/index.browser.js +20 -0
  16. package/dist/head/index.browser.js.map +1 -1
  17. package/dist/head/index.d.ts +73 -65
  18. package/dist/head/index.d.ts.map +1 -1
  19. package/dist/head/index.js +20 -0
  20. package/dist/head/index.js.map +1 -1
  21. package/dist/i18n/index.d.ts +37 -37
  22. package/dist/i18n/index.d.ts.map +1 -1
  23. package/dist/i18n/index.js.map +1 -1
  24. package/dist/router/index.browser.js +605 -244
  25. package/dist/router/index.browser.js.map +1 -1
  26. package/dist/router/index.d.ts +539 -550
  27. package/dist/router/index.d.ts.map +1 -1
  28. package/dist/router/index.js +1296 -922
  29. package/dist/router/index.js.map +1 -1
  30. package/dist/websocket/index.d.ts +38 -38
  31. package/dist/websocket/index.d.ts.map +1 -1
  32. package/package.json +6 -6
  33. package/src/auth/__tests__/$auth.spec.ts +162 -147
  34. package/src/auth/index.ts +9 -3
  35. package/src/auth/services/ReactAuth.ts +15 -5
  36. package/src/core/hooks/useAction.ts +1 -2
  37. package/src/core/index.ts +4 -4
  38. package/src/form/errors/FormValidationError.ts +4 -6
  39. package/src/form/hooks/useFormState.ts +1 -1
  40. package/src/form/index.ts +1 -1
  41. package/src/form/services/FormModel.ts +31 -25
  42. package/src/head/helpers/SeoExpander.ts +2 -1
  43. package/src/head/hooks/useHead.spec.tsx +2 -2
  44. package/src/head/index.browser.ts +2 -2
  45. package/src/head/index.ts +4 -4
  46. package/src/head/interfaces/Head.ts +15 -3
  47. package/src/head/primitives/$head.ts +2 -5
  48. package/src/head/providers/BrowserHeadProvider.ts +55 -0
  49. package/src/head/providers/HeadProvider.ts +4 -1
  50. package/src/i18n/__tests__/integration.spec.tsx +1 -1
  51. package/src/i18n/components/Localize.spec.tsx +2 -2
  52. package/src/i18n/hooks/useI18n.browser.spec.tsx +2 -2
  53. package/src/i18n/index.ts +1 -1
  54. package/src/i18n/primitives/$dictionary.ts +1 -1
  55. package/src/i18n/providers/I18nProvider.spec.ts +1 -1
  56. package/src/i18n/providers/I18nProvider.ts +1 -1
  57. package/src/router/__tests__/page-head-browser.browser.spec.ts +5 -1
  58. package/src/router/__tests__/page-head.spec.ts +11 -7
  59. package/src/router/__tests__/seo-head.spec.ts +7 -3
  60. package/src/router/atoms/ssrManifestAtom.ts +2 -11
  61. package/src/router/components/ErrorViewer.tsx +626 -167
  62. package/src/router/components/Link.tsx +4 -2
  63. package/src/router/components/NestedView.tsx +7 -9
  64. package/src/router/components/NotFound.tsx +2 -2
  65. package/src/router/hooks/useQueryParams.ts +1 -1
  66. package/src/router/hooks/useRouter.ts +1 -1
  67. package/src/router/hooks/useRouterState.ts +1 -1
  68. package/src/router/index.browser.ts +10 -11
  69. package/src/router/index.shared.ts +7 -7
  70. package/src/router/index.ts +10 -7
  71. package/src/router/primitives/$page.browser.spec.tsx +6 -1
  72. package/src/router/primitives/$page.spec.tsx +7 -1
  73. package/src/router/primitives/$page.ts +5 -9
  74. package/src/router/providers/ReactBrowserProvider.ts +17 -6
  75. package/src/router/providers/ReactBrowserRouterProvider.ts +1 -1
  76. package/src/router/providers/ReactPageProvider.ts +4 -3
  77. package/src/router/providers/ReactServerProvider.ts +29 -37
  78. package/src/router/providers/ReactServerTemplateProvider.ts +300 -137
  79. package/src/router/providers/SSRManifestProvider.ts +17 -60
  80. package/src/router/services/ReactPageService.ts +4 -1
  81. 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
 
@@ -393,25 +414,30 @@ export class ReactServerTemplateProvider {
393
414
  this.alepha.context.als?.getStore() ?? {};
394
415
 
395
416
  const layers = state.layers.map((layer) => ({
396
- name: layer.name,
397
- props: layer.props,
398
- config: layer.config,
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
399
421
  error: layer.error
400
422
  ? {
401
- ...layer.error,
402
- name: layer.error.name,
403
- message: layer.error.message,
404
- stack: !this.alepha.isProduction() ? layer.error.stack : undefined,
405
- }
423
+ ...layer.error,
424
+ name: layer.error.name,
425
+ message: layer.error.message,
426
+ stack: !this.alepha.isProduction() ? layer.error.stack : undefined,
427
+ }
406
428
  : undefined,
407
429
  }));
408
430
 
409
431
  const hydrationData: HydrationData = {
410
432
  layers,
411
- }
433
+ };
412
434
 
413
435
  for (const [key, value] of Object.entries(store)) {
414
- if (key.charAt(0) !== "_" && key !== "alepha.react.router.state" && key !== "registry") {
436
+ if (
437
+ key.charAt(0) !== "_" &&
438
+ key !== "alepha.react.router.state" &&
439
+ key !== "registry"
440
+ ) {
415
441
  hydrationData[key] = value;
416
442
  }
417
443
  }
@@ -420,24 +446,82 @@ export class ReactServerTemplateProvider {
420
446
  }
421
447
 
422
448
  /**
423
- * Encode a string to Uint8Array using the shared encoder.
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.
424
453
  */
425
- public encode(str: string): Uint8Array {
426
- return this.encoder.encode(str);
427
- }
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;
428
463
 
429
- /**
430
- * Get the pre-encoded hydration script prefix.
431
- */
432
- public get hydrationPrefix(): Uint8Array {
433
- return this.ENCODED.HYDRATION_PREFIX;
434
- }
464
+ // <body ...>
465
+ controller.enqueue(slots.bodyOpen);
466
+ controller.enqueue(
467
+ encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)),
468
+ );
469
+ controller.enqueue(slots.bodyClose);
435
470
 
436
- /**
437
- * Get the pre-encoded hydration script suffix.
438
- */
439
- public get hydrationSuffix(): Uint8Array {
440
- return this.ENCODED.HYDRATION_SUFFIX;
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);
441
525
  }
442
526
 
443
527
  /**
@@ -468,72 +552,31 @@ export class ReactServerTemplateProvider {
468
552
  return new ReadableStream<Uint8Array>({
469
553
  start: async (controller) => {
470
554
  try {
471
- // 1. DOCTYPE
555
+ // DOCTYPE
472
556
  controller.enqueue(slots.doctype);
473
557
 
474
- // 2. <html ...>
558
+ // <html ...>
475
559
  controller.enqueue(slots.htmlOpen);
476
560
  controller.enqueue(
477
561
  encoder.encode(this.renderMergedHtmlAttrs(head?.htmlAttributes)),
478
562
  );
479
563
  controller.enqueue(slots.htmlClose);
480
564
 
481
- // 3. <head>...</head>
565
+ // <head>...</head>
482
566
  controller.enqueue(slots.headOpen);
483
- // Include early head content (entry.js, CSS) if set
484
567
  if (this.earlyHeadContent) {
485
568
  controller.enqueue(encoder.encode(this.earlyHeadContent));
486
569
  }
487
570
  controller.enqueue(encoder.encode(this.renderHeadContent(head)));
488
571
  controller.enqueue(slots.headClose);
489
572
 
490
- // 4. <body ...>
491
- controller.enqueue(slots.bodyOpen);
492
- controller.enqueue(
493
- 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,
494
579
  );
495
- controller.enqueue(slots.bodyClose);
496
-
497
- // 5. Content before root (if any)
498
- if (slots.beforeRoot) {
499
- controller.enqueue(encoder.encode(slots.beforeRoot));
500
- }
501
-
502
- // 6. <div id="root">
503
- controller.enqueue(slots.rootOpen);
504
-
505
- // 7. Stream React content
506
- const reader = reactStream.getReader();
507
- try {
508
- while (true) {
509
- const { done, value } = await reader.read();
510
- if (done) break;
511
- controller.enqueue(value);
512
- }
513
- } finally {
514
- reader.releaseLock();
515
- }
516
-
517
- // 8. </div>
518
- controller.enqueue(slots.rootClose);
519
-
520
- // 9. Content after root (if any)
521
- if (slots.afterRoot) {
522
- controller.enqueue(encoder.encode(slots.afterRoot));
523
- }
524
-
525
- // 10. Hydration script
526
- if (hydration) {
527
- const hydrationData = this.buildHydrationData(state);
528
- controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
529
- controller.enqueue(
530
- encoder.encode(this.safeJsonSerialize(hydrationData)),
531
- );
532
- controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
533
- }
534
-
535
- // 11. </body></html>
536
- controller.enqueue(slots.scriptClose);
537
580
 
538
581
  controller.close();
539
582
  } catch (error) {
@@ -635,15 +678,20 @@ export class ReactServerTemplateProvider {
635
678
  const slots = this.getSlots();
636
679
  const encoder = this.encoder;
637
680
 
681
+ // Track streaming state for error recovery
682
+ let headClosed = false;
683
+ let bodyStarted = false;
684
+ let routerState: ReactRouterState | undefined;
685
+
638
686
  return new ReadableStream<Uint8Array>({
639
687
  start: async (controller) => {
640
688
  try {
641
689
  // === EARLY PHASE (before async work) ===
642
690
 
643
- // 1. DOCTYPE
691
+ // DOCTYPE
644
692
  controller.enqueue(slots.doctype);
645
693
 
646
- // 2. <html ...> with global htmlAttributes only
694
+ // <html ...> with global htmlAttributes only
647
695
  controller.enqueue(slots.htmlOpen);
648
696
  controller.enqueue(
649
697
  encoder.encode(
@@ -652,7 +700,7 @@ export class ReactServerTemplateProvider {
652
700
  );
653
701
  controller.enqueue(slots.htmlClose);
654
702
 
655
- // 3. <head> open + entry preloads
703
+ // <head> open + entry preloads
656
704
  controller.enqueue(slots.headOpen);
657
705
  if (this.earlyHeadContent) {
658
706
  controller.enqueue(encoder.encode(this.earlyHeadContent));
@@ -681,73 +729,188 @@ export class ReactServerTemplateProvider {
681
729
  }
682
730
 
683
731
  const { state, reactStream } = result;
684
- const head = state.head;
732
+ routerState = state;
685
733
 
686
734
  // === LATE PHASE (after async work) ===
687
735
 
688
- // 4. Rest of head content (title, meta, links from loaders)
689
- controller.enqueue(encoder.encode(this.renderHeadContent(head)));
690
- controller.enqueue(slots.headClose);
691
-
692
- // 5. <body ...> with merged bodyAttributes
693
- controller.enqueue(slots.bodyOpen);
736
+ // Rest of head content (title, meta, links from loaders)
694
737
  controller.enqueue(
695
- 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,
696
750
  );
697
- controller.enqueue(slots.bodyClose);
698
-
699
- // 6. Content before root (if any)
700
- if (slots.beforeRoot) {
701
- controller.enqueue(encoder.encode(slots.beforeRoot));
702
- }
703
751
 
704
- // 7. <div id="root">
705
- controller.enqueue(slots.rootOpen);
752
+ controller.close();
753
+ } catch (error) {
754
+ onError?.(error);
706
755
 
707
- // 8. Stream React content
708
- 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.
709
760
  try {
710
- while (true) {
711
- const { done, value } = await reader.read();
712
- if (done) break;
713
- controller.enqueue(value);
714
- }
715
- } finally {
716
- 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);
717
773
  }
774
+ }
775
+ },
776
+ });
777
+ }
718
778
 
719
- // 9. </div>
720
- 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
+ }
721
811
 
722
- // 10. Content after root (if any)
723
- if (slots.afterRoot) {
724
- controller.enqueue(encoder.encode(slots.afterRoot));
725
- }
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);
726
822
 
727
- // 11. Hydration script
728
- if (hydration) {
729
- const hydrationData = this.buildHydrationData(state);
730
- controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
731
- controller.enqueue(
732
- encoder.encode(this.safeJsonSerialize(hydrationData)),
733
- );
734
- controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
735
- }
823
+ // Content before root (if any)
824
+ if (slots.beforeRoot) {
825
+ controller.enqueue(encoder.encode(slots.beforeRoot));
826
+ }
736
827
 
737
- // 12. </body></html>
738
- controller.enqueue(slots.scriptClose);
828
+ controller.enqueue(slots.rootOpen);
829
+ }
739
830
 
740
- controller.close();
741
- } catch (error) {
742
- onError?.(error);
743
- 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;
744
882
  }
745
- },
746
- });
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
+ }
747
911
  }
748
912
  }
749
913
 
750
-
751
914
  // ---------------------------------------------------------------------------------------------------------------------
752
915
 
753
916
  /**