@oomfware/jsx 0.1.3 → 0.1.5

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.
@@ -1,6 +1,14 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
 
3
- import { createContext, render, renderToStream, renderToString, Suspense, use } from '../index.ts';
3
+ import {
4
+ createContext,
5
+ ErrorBoundary,
6
+ render,
7
+ renderToStream,
8
+ renderToString,
9
+ Suspense,
10
+ use,
11
+ } from '../index.ts';
4
12
 
5
13
  // suspense runtime script (injected once before first resolution)
6
14
  const SR = '$sr';
@@ -621,5 +629,403 @@ describe('stream', () => {
621
629
  expect(e).toBe(error);
622
630
  }
623
631
  });
632
+
633
+ it('component throwing multiple sequential promises', async () => {
634
+ const { promise: p1, resolve: r1 } = Promise.withResolvers<string>();
635
+ const { promise: p2, resolve: r2 } = Promise.withResolvers<string>();
636
+
637
+ let callCount = 0;
638
+ function MultiAsyncComponent() {
639
+ callCount++;
640
+ const first = use(p1);
641
+ const second = use(p2);
642
+ return (
643
+ <div>
644
+ {first} {second}
645
+ </div>
646
+ );
647
+ }
648
+
649
+ const stream = renderToStream(
650
+ <Suspense fallback={<span>loading...</span>}>
651
+ <MultiAsyncComponent />
652
+ </Suspense>,
653
+ );
654
+
655
+ // resolve first promise, component will re-render and throw second
656
+ r1('hello');
657
+ await Promise.resolve();
658
+
659
+ // resolve second promise, component will complete
660
+ r2('world');
661
+
662
+ const html = await drain(stream);
663
+ expect(html).toBe(
664
+ '<!--$s:s1--><span>loading...</span><!--/$s:s1-->' +
665
+ SUSPENSE_RUNTIME +
666
+ '<template data-suspense="s1"><div>hello world</div></template>' +
667
+ SUSPENSE_CALL,
668
+ );
669
+ // component should be called multiple times as it re-renders after each promise
670
+ expect(callCount).toBeGreaterThan(1);
671
+ });
672
+
673
+ it('parallel renders have isolated context', async () => {
674
+ const ThemeContext = createContext('default');
675
+
676
+ function ThemedComponent() {
677
+ const theme = use(ThemeContext);
678
+ return <div class={theme}>content</div>;
679
+ }
680
+
681
+ // run two renders in parallel with different context values
682
+ const [html1, html2] = await Promise.all([
683
+ renderToString(
684
+ <ThemeContext.Provider value="dark">
685
+ <ThemedComponent />
686
+ </ThemeContext.Provider>,
687
+ ),
688
+ renderToString(
689
+ <ThemeContext.Provider value="light">
690
+ <ThemedComponent />
691
+ </ThemeContext.Provider>,
692
+ ),
693
+ ]);
694
+
695
+ expect(html1).toBe('<div class="dark">content</div>');
696
+ expect(html2).toBe('<div class="light">content</div>');
697
+ });
698
+
699
+ it('parallel renders with suspense have isolated context', async () => {
700
+ const ThemeContext = createContext('default');
701
+ const { promise: p1, resolve: r1 } = Promise.withResolvers<string>();
702
+ const { promise: p2, resolve: r2 } = Promise.withResolvers<string>();
703
+
704
+ function AsyncThemedComponent({ promise }: { promise: Promise<string> }) {
705
+ const theme = use(ThemeContext);
706
+ const data = use(promise);
707
+ return <div class={theme}>{data}</div>;
708
+ }
709
+
710
+ // start both renders
711
+ const render1 = renderToString(
712
+ <ThemeContext.Provider value="dark">
713
+ <Suspense fallback={<span>loading dark...</span>}>
714
+ <AsyncThemedComponent promise={p1} />
715
+ </Suspense>
716
+ </ThemeContext.Provider>,
717
+ );
718
+
719
+ const render2 = renderToString(
720
+ <ThemeContext.Provider value="light">
721
+ <Suspense fallback={<span>loading light...</span>}>
722
+ <AsyncThemedComponent promise={p2} />
723
+ </Suspense>
724
+ </ThemeContext.Provider>,
725
+ );
726
+
727
+ // resolve in reverse order to test isolation
728
+ r2('second');
729
+ r1('first');
730
+
731
+ const [html1, html2] = await Promise.all([render1, render2]);
732
+
733
+ expect(html1).toBe(
734
+ '<!--$s:s1--><span>loading dark...</span><!--/$s:s1-->' +
735
+ SUSPENSE_RUNTIME +
736
+ '<template data-suspense="s1"><div class="dark">first</div></template>' +
737
+ SUSPENSE_CALL,
738
+ );
739
+ expect(html2).toBe(
740
+ '<!--$s:s1--><span>loading light...</span><!--/$s:s1-->' +
741
+ SUSPENSE_RUNTIME +
742
+ '<template data-suspense="s1"><div class="light">second</div></template>' +
743
+ SUSPENSE_CALL,
744
+ );
745
+ });
746
+
747
+ it('throws error when suspense exceeds max retry attempts', async () => {
748
+ let throwCount = 0;
749
+ function InfiniteThrowComponent(): never {
750
+ throwCount++;
751
+ // always throw a new promise
752
+ throw Promise.resolve();
753
+ }
754
+
755
+ const errors: unknown[] = [];
756
+ try {
757
+ await renderToString(
758
+ <Suspense fallback={<div>loading...</div>}>
759
+ <InfiniteThrowComponent />
760
+ </Suspense>,
761
+ { onError: (e) => errors.push(e) },
762
+ );
763
+ } catch {
764
+ // expected - error propagates through stream
765
+ }
766
+
767
+ expect(throwCount).toBe(20); // 1 initial + 19 retries, error thrown before 20th retry
768
+ expect(errors.length).toBe(1);
769
+ expect((errors[0] as Error).message).toBe('suspense boundary exceeded maximum retry attempts (20)');
770
+ });
771
+ });
772
+
773
+ describe('error boundary', () => {
774
+ it('catches sync render errors', async () => {
775
+ function ThrowingComponent(): never {
776
+ throw new Error('sync error');
777
+ }
778
+
779
+ const html = await renderToString(
780
+ <ErrorBoundary fallback={(e) => <div>caught: {(e as Error).message}</div>}>
781
+ <ThrowingComponent />
782
+ </ErrorBoundary>,
783
+ );
784
+
785
+ expect(html).toBe('<div>caught: sync error</div>');
786
+ });
787
+
788
+ it('catches async suspense errors (max attempts)', async () => {
789
+ function InfiniteThrowComponent(): never {
790
+ throw Promise.resolve();
791
+ }
792
+
793
+ const html = await renderToString(
794
+ <ErrorBoundary fallback={(e) => <div>caught: {(e as Error).message}</div>}>
795
+ <Suspense fallback={<div>loading...</div>}>
796
+ <InfiniteThrowComponent />
797
+ </Suspense>
798
+ </ErrorBoundary>,
799
+ );
800
+
801
+ expect(html).toBe(
802
+ '<div>caught: suspense boundary exceeded maximum retry attempts (20)</div>' + SUSPENSE_RUNTIME,
803
+ );
804
+ });
805
+
806
+ it('passes error object to fallback function', async () => {
807
+ const testError = new Error('test error');
808
+ function ThrowingComponent(): never {
809
+ throw testError;
810
+ }
811
+
812
+ let receivedError: unknown;
813
+ const html = await renderToString(
814
+ <ErrorBoundary
815
+ fallback={(e) => {
816
+ receivedError = e;
817
+ return <div>error</div>;
818
+ }}
819
+ >
820
+ <ThrowingComponent />
821
+ </ErrorBoundary>,
822
+ );
823
+
824
+ expect(receivedError).toBe(testError);
825
+ expect(html).toBe('<div>error</div>');
826
+ });
827
+
828
+ it('nested ErrorBoundary catches at nearest boundary', async () => {
829
+ function ThrowingComponent(): never {
830
+ throw new Error('inner error');
831
+ }
832
+
833
+ const html = await renderToString(
834
+ <ErrorBoundary fallback={() => <div>outer</div>}>
835
+ <div>
836
+ <ErrorBoundary fallback={() => <div>inner</div>}>
837
+ <ThrowingComponent />
838
+ </ErrorBoundary>
839
+ </div>
840
+ </ErrorBoundary>,
841
+ );
842
+
843
+ expect(html).toBe('<div><div>inner</div></div>');
844
+ });
845
+
846
+ it('renders children when no error', async () => {
847
+ const html = await renderToString(
848
+ <ErrorBoundary fallback={() => <div>error</div>}>
849
+ <div>success</div>
850
+ </ErrorBoundary>,
851
+ );
852
+
853
+ expect(html).toBe('<div>success</div>');
854
+ });
855
+
856
+ it('lets promises pass through to Suspense', async () => {
857
+ const { promise, resolve } = Promise.withResolvers<string>();
858
+
859
+ function AsyncComponent() {
860
+ const data = use(promise);
861
+ return <div>{data}</div>;
862
+ }
863
+
864
+ const stream = renderToStream(
865
+ <ErrorBoundary fallback={() => <div>error</div>}>
866
+ <Suspense fallback={<div>loading...</div>}>
867
+ <AsyncComponent />
868
+ </Suspense>
869
+ </ErrorBoundary>,
870
+ );
871
+
872
+ resolve('loaded');
873
+
874
+ const html = await drain(stream);
875
+ expect(html).toBe(
876
+ '<!--$s:s1--><div>loading...</div><!--/$s:s1-->' +
877
+ SUSPENSE_RUNTIME +
878
+ '<template data-suspense="s1"><div>loaded</div></template>' +
879
+ SUSPENSE_CALL,
880
+ );
881
+ });
882
+
883
+ it('fallback error caught by parent ErrorBoundary (sync)', async () => {
884
+ function ThrowingComponent(): never {
885
+ throw new Error('child error');
886
+ }
887
+
888
+ function ThrowingFallback(): never {
889
+ throw new Error('fallback error');
890
+ }
891
+
892
+ const html = await renderToString(
893
+ <ErrorBoundary fallback={(e) => <div>outer: {(e as Error).message}</div>}>
894
+ <ErrorBoundary fallback={() => <ThrowingFallback />}>
895
+ <ThrowingComponent />
896
+ </ErrorBoundary>
897
+ </ErrorBoundary>,
898
+ );
899
+
900
+ expect(html).toBe('<div>outer: fallback error</div>');
901
+ });
902
+
903
+ it('fallback error caught by parent ErrorBoundary (async)', async () => {
904
+ function InfiniteThrowComponent(): never {
905
+ throw Promise.resolve();
906
+ }
907
+
908
+ function ThrowingFallback(): never {
909
+ throw new Error('fallback error');
910
+ }
911
+
912
+ const html = await renderToString(
913
+ <ErrorBoundary fallback={(e) => <div>outer: {(e as Error).message}</div>}>
914
+ <ErrorBoundary fallback={() => <ThrowingFallback />}>
915
+ <Suspense fallback={<div>loading...</div>}>
916
+ <InfiniteThrowComponent />
917
+ </Suspense>
918
+ </ErrorBoundary>
919
+ </ErrorBoundary>,
920
+ );
921
+
922
+ expect(html).toBe('<div>outer: fallback error</div>' + SUSPENSE_RUNTIME);
923
+ });
924
+
925
+ it('fallback error goes to onError when no parent ErrorBoundary (sync)', async () => {
926
+ function ThrowingComponent(): never {
927
+ throw new Error('child error');
928
+ }
929
+
930
+ function ThrowingFallback(): never {
931
+ throw new Error('fallback error');
932
+ }
933
+
934
+ const errors: unknown[] = [];
935
+ try {
936
+ await renderToString(
937
+ <ErrorBoundary fallback={() => <ThrowingFallback />}>
938
+ <ThrowingComponent />
939
+ </ErrorBoundary>,
940
+ { onError: (e) => errors.push(e) },
941
+ );
942
+ } catch {
943
+ // expected
944
+ }
945
+
946
+ expect(errors.length).toBe(1);
947
+ expect((errors[0] as Error).message).toBe('fallback error');
948
+ });
949
+
950
+ it('fallback error goes to onError when no parent ErrorBoundary (async)', async () => {
951
+ function InfiniteThrowComponent(): never {
952
+ throw Promise.resolve();
953
+ }
954
+
955
+ function ThrowingFallback(): never {
956
+ throw new Error('fallback error');
957
+ }
958
+
959
+ const errors: unknown[] = [];
960
+ try {
961
+ await renderToString(
962
+ <ErrorBoundary fallback={() => <ThrowingFallback />}>
963
+ <Suspense fallback={<div>loading...</div>}>
964
+ <InfiniteThrowComponent />
965
+ </Suspense>
966
+ </ErrorBoundary>,
967
+ { onError: (e) => errors.push(e) },
968
+ );
969
+ } catch {
970
+ // expected
971
+ }
972
+
973
+ expect(errors.length).toBe(1);
974
+ expect((errors[0] as Error).message).toBe('fallback error');
975
+ });
976
+
977
+ it('fallback error goes to onError during streaming', async () => {
978
+ // this tests the onError path in streamPendingSuspense specifically:
979
+ // 1. outer error boundary catches and renders fallback with suspense
980
+ // 2. that suspense is processed during streaming
981
+ // 3. its content has an error boundary whose fallback throws
982
+
983
+ function InfiniteThrowComponent(): never {
984
+ throw Promise.resolve();
985
+ }
986
+
987
+ function ThrowingFallback(): never {
988
+ throw new Error('streaming fallback error');
989
+ }
990
+
991
+ const { promise, resolve } = Promise.withResolvers<void>();
992
+
993
+ function AsyncThenErrorBoundary() {
994
+ use(promise);
995
+ return (
996
+ <ErrorBoundary fallback={() => <ThrowingFallback />}>
997
+ <Suspense fallback={<div>inner loading</div>}>
998
+ <InfiniteThrowComponent />
999
+ </Suspense>
1000
+ </ErrorBoundary>
1001
+ );
1002
+ }
1003
+
1004
+ function FallbackWithSuspense() {
1005
+ return (
1006
+ <Suspense fallback={<div>fallback loading</div>}>
1007
+ <AsyncThenErrorBoundary />
1008
+ </Suspense>
1009
+ );
1010
+ }
1011
+
1012
+ const errors: unknown[] = [];
1013
+ const streamPromise = renderToString(
1014
+ <ErrorBoundary fallback={() => <FallbackWithSuspense />}>
1015
+ <Suspense fallback={<div>outer loading</div>}>
1016
+ <InfiniteThrowComponent />
1017
+ </Suspense>
1018
+ </ErrorBoundary>,
1019
+ { onError: (e) => errors.push(e) },
1020
+ );
1021
+
1022
+ // resolve the inner async component to trigger the streaming error path
1023
+ resolve();
1024
+
1025
+ await streamPromise;
1026
+
1027
+ expect(errors.length).toBe(1);
1028
+ expect((errors[0] as Error).message).toBe('streaming fallback error');
1029
+ });
624
1030
  });
625
1031
  });
@@ -1,6 +1,7 @@
1
1
  import { Fragment, jsx } from '../jsx-runtime.js';
2
2
 
3
- import { inject, type Context } from './context.js';
3
+ import type { Context } from './context.js';
4
+ import { inject } from './render-context.js';
4
5
  import type { JSXElement, JSXNode } from './types.js';
5
6
 
6
7
  export interface SuspenseProps {
@@ -16,6 +17,19 @@ export function Suspense({ children }: SuspenseProps): JSXElement {
16
17
  return jsx(Fragment, { children });
17
18
  }
18
19
 
20
+ export interface ErrorBoundaryProps {
21
+ fallback: (error: unknown) => JSXNode;
22
+ children?: JSXNode;
23
+ }
24
+
25
+ /**
26
+ * error boundary - catches render errors and displays fallback
27
+ */
28
+ export function ErrorBoundary({ children }: ErrorBoundaryProps): JSXElement {
29
+ // ErrorBoundary is handled specially in buildSegment, this is just for typing
30
+ return jsx(Fragment, { children });
31
+ }
32
+
19
33
  /** cache for resolved/rejected promise values */
20
34
  const promiseCache = new WeakMap<
21
35
  Promise<unknown>,