@oomfware/jsx 0.1.4 → 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.
- package/dist/index.d.mts +12 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +142 -57
- package/dist/index.mjs.map +1 -1
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/{jsx-runtime-CpxZaJu6.d.mts → jsx-runtime-CcUWZKzW.d.mts} +2 -2
- package/dist/{jsx-runtime-CpxZaJu6.d.mts.map → jsx-runtime-CcUWZKzW.d.mts.map} +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/package.json +4 -1
- package/src/index.ts +8 -1
- package/src/lib/context.ts +1 -50
- package/src/lib/intrinsic-elements.ts +2 -2
- package/src/lib/render-context.ts +115 -0
- package/src/lib/render.ts +142 -63
- package/src/lib/stream.test.tsx +407 -1
- package/src/lib/suspense.ts +15 -1
package/src/lib/stream.test.tsx
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
});
|
package/src/lib/suspense.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment, jsx } from '../jsx-runtime.js';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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>,
|