ripple 0.3.11 → 0.3.13

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 (190) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +8 -2
  3. package/src/compiler/phases/1-parse/index.js +73 -30
  4. package/src/compiler/phases/2-analyze/index.js +28 -58
  5. package/src/compiler/phases/3-transform/client/index.js +127 -164
  6. package/src/compiler/phases/3-transform/segments.js +4 -8
  7. package/src/compiler/phases/3-transform/server/index.js +210 -360
  8. package/src/compiler/types/import.d.ts +0 -12
  9. package/src/compiler/types/index.d.ts +12 -5
  10. package/src/compiler/types/parse.d.ts +2 -0
  11. package/src/compiler/utils.js +39 -44
  12. package/src/helpers.d.ts +2 -0
  13. package/src/runtime/index-client.js +15 -13
  14. package/src/runtime/index-server.js +18 -11
  15. package/src/runtime/internal/client/blocks.js +19 -23
  16. package/src/runtime/internal/client/constants.js +20 -9
  17. package/src/runtime/internal/client/index.js +14 -4
  18. package/src/runtime/internal/client/runtime.js +435 -173
  19. package/src/runtime/internal/client/try.js +334 -156
  20. package/src/runtime/internal/client/types.d.ts +26 -0
  21. package/src/runtime/internal/server/blocks.js +183 -0
  22. package/src/runtime/internal/server/constants.js +7 -0
  23. package/src/runtime/internal/server/index.js +780 -148
  24. package/src/runtime/internal/server/types.d.ts +35 -0
  25. package/src/server/index.js +1 -1
  26. package/src/utils/async.js +35 -0
  27. package/src/utils/builders.js +3 -1
  28. package/tests/client/__snapshots__/computed-properties.test.rsrx.snap +49 -0
  29. package/tests/client/__snapshots__/for.test.rsrx.snap +319 -0
  30. package/tests/client/__snapshots__/html.test.rsrx.snap +40 -0
  31. package/tests/client/_etc.test.rsrx +7 -0
  32. package/tests/client/array/{array.static.test.ripple → array.static.test.rsrx} +18 -20
  33. package/tests/client/async-suspend.test.rsrx +662 -0
  34. package/tests/client/basic/__snapshots__/basic.attributes.test.rsrx.snap +60 -0
  35. package/tests/client/basic/__snapshots__/basic.rendering.test.rsrx.snap +59 -0
  36. package/tests/client/basic/{basic.errors.test.ripple → basic.errors.test.rsrx} +2 -2
  37. package/tests/client/compiler/__snapshots__/compiler.assignments.test.rsrx.snap +12 -0
  38. package/tests/client/compiler/__snapshots__/compiler.typescript.test.rsrx.snap +46 -0
  39. package/tests/client/compiler/{compiler.try-in-function.test.ripple → compiler.try-in-function.test.rsrx} +8 -6
  40. package/tests/client/composite/__snapshots__/composite.render.test.rsrx.snap +37 -0
  41. package/tests/client/{function-overload.test.ripple → function-overload.test.rsrx} +1 -1
  42. package/tests/client/try.test.rsrx +1702 -0
  43. package/tests/hydration/build-components.js +5 -3
  44. package/tests/hydration/compiled/client/head.js +11 -11
  45. package/tests/hydration/compiled/client/mixed-control-flow.js +55 -70
  46. package/tests/hydration/compiled/client/nested-control-flow.js +72 -88
  47. package/tests/hydration/compiled/client/try.js +42 -54
  48. package/tests/hydration/compiled/server/basic.js +491 -369
  49. package/tests/hydration/compiled/server/composite.js +153 -128
  50. package/tests/hydration/compiled/server/events.js +166 -145
  51. package/tests/hydration/compiled/server/for.js +821 -677
  52. package/tests/hydration/compiled/server/head.js +200 -165
  53. package/tests/hydration/compiled/server/hmr.js +62 -54
  54. package/tests/hydration/compiled/server/html-in-template.js +64 -55
  55. package/tests/hydration/compiled/server/html.js +1477 -1360
  56. package/tests/hydration/compiled/server/if-children.js +448 -408
  57. package/tests/hydration/compiled/server/if.js +204 -171
  58. package/tests/hydration/compiled/server/mixed-control-flow.js +237 -195
  59. package/tests/hydration/compiled/server/nested-control-flow.js +533 -467
  60. package/tests/hydration/compiled/server/portal.js +94 -107
  61. package/tests/hydration/compiled/server/reactivity.js +87 -64
  62. package/tests/hydration/compiled/server/return.js +1424 -1174
  63. package/tests/hydration/compiled/server/switch.js +268 -238
  64. package/tests/hydration/compiled/server/try.js +98 -87
  65. package/tests/hydration/components/{mixed-control-flow.ripple → mixed-control-flow.rsrx} +2 -2
  66. package/tests/hydration/components/{try.ripple → try.rsrx} +4 -2
  67. package/tests/hydration/mixed-control-flow.test.js +14 -0
  68. package/tests/hydration/nested-control-flow.test.js +50 -48
  69. package/tests/hydration/try.test.js +25 -0
  70. package/tests/server/__snapshots__/compiler.test.ripple.snap +0 -32
  71. package/tests/server/__snapshots__/compiler.test.rsrx.snap +95 -0
  72. package/tests/server/{compiler.test.ripple → compiler.test.rsrx} +0 -17
  73. package/tests/server/{html-nesting-validation.test.ripple → html-nesting-validation.test.rsrx} +3 -3
  74. package/tests/server/streaming-ssr.test.rsrx +115 -0
  75. package/tests/server/try.test.rsrx +503 -0
  76. package/tests/utils/compiler-compat-config.test.js +3 -3
  77. package/tests/utils/vite-plugin-config.test.js +1 -1
  78. package/tests/utils/vite-plugin-hmr.test.js +5 -5
  79. package/tsconfig.json +4 -0
  80. package/types/index.d.ts +13 -23
  81. package/types/server.d.ts +43 -16
  82. package/tests/client/_etc.test.ripple +0 -5
  83. package/tests/client/async-suspend.test.ripple +0 -94
  84. package/tests/client/try.test.ripple +0 -196
  85. package/tests/server/streaming-ssr.test.ripple +0 -68
  86. package/tests/server/try.test.ripple +0 -82
  87. /package/tests/client/array/{array.copy-within.test.ripple → array.copy-within.test.rsrx} +0 -0
  88. /package/tests/client/array/{array.derived.test.ripple → array.derived.test.rsrx} +0 -0
  89. /package/tests/client/array/{array.iteration.test.ripple → array.iteration.test.rsrx} +0 -0
  90. /package/tests/client/array/{array.mutations.test.ripple → array.mutations.test.rsrx} +0 -0
  91. /package/tests/client/array/{array.to-methods.test.ripple → array.to-methods.test.rsrx} +0 -0
  92. /package/tests/client/basic/{basic.attributes.test.ripple → basic.attributes.test.rsrx} +0 -0
  93. /package/tests/client/basic/{basic.collections.test.ripple → basic.collections.test.rsrx} +0 -0
  94. /package/tests/client/basic/{basic.components.test.ripple → basic.components.test.rsrx} +0 -0
  95. /package/tests/client/basic/{basic.events.test.ripple → basic.events.test.rsrx} +0 -0
  96. /package/tests/client/basic/{basic.get-set.test.ripple → basic.get-set.test.rsrx} +0 -0
  97. /package/tests/client/basic/{basic.hmr.test.ripple → basic.hmr.test.rsrx} +0 -0
  98. /package/tests/client/basic/{basic.reactivity.test.ripple → basic.reactivity.test.rsrx} +0 -0
  99. /package/tests/client/basic/{basic.rendering.test.ripple → basic.rendering.test.rsrx} +0 -0
  100. /package/tests/client/basic/{basic.styling.test.ripple → basic.styling.test.rsrx} +0 -0
  101. /package/tests/client/basic/{basic.utilities.test.ripple → basic.utilities.test.rsrx} +0 -0
  102. /package/tests/client/{boundaries.test.ripple → boundaries.test.rsrx} +0 -0
  103. /package/tests/client/compiler/{compiler.assignments.test.ripple → compiler.assignments.test.rsrx} +0 -0
  104. /package/tests/client/compiler/{compiler.attributes.test.ripple → compiler.attributes.test.rsrx} +0 -0
  105. /package/tests/client/compiler/{compiler.basic.test.ripple → compiler.basic.test.rsrx} +0 -0
  106. /package/tests/client/compiler/{compiler.regex.test.ripple → compiler.regex.test.rsrx} +0 -0
  107. /package/tests/client/compiler/{compiler.tracked-access.test.ripple → compiler.tracked-access.test.rsrx} +0 -0
  108. /package/tests/client/compiler/{compiler.typescript.test.ripple → compiler.typescript.test.rsrx} +0 -0
  109. /package/tests/client/composite/{composite.dynamic-components.test.ripple → composite.dynamic-components.test.rsrx} +0 -0
  110. /package/tests/client/composite/{composite.generics.test.ripple → composite.generics.test.rsrx} +0 -0
  111. /package/tests/client/composite/{composite.props.test.ripple → composite.props.test.rsrx} +0 -0
  112. /package/tests/client/composite/{composite.reactivity.test.ripple → composite.reactivity.test.rsrx} +0 -0
  113. /package/tests/client/composite/{composite.render.test.ripple → composite.render.test.rsrx} +0 -0
  114. /package/tests/client/{computed-properties.test.ripple → computed-properties.test.rsrx} +0 -0
  115. /package/tests/client/{context.test.ripple → context.test.rsrx} +0 -0
  116. /package/tests/client/css/{global-additional-cases.test.ripple → global-additional-cases.test.rsrx} +0 -0
  117. /package/tests/client/css/{global-advanced-selectors.test.ripple → global-advanced-selectors.test.rsrx} +0 -0
  118. /package/tests/client/css/{global-at-rules.test.ripple → global-at-rules.test.rsrx} +0 -0
  119. /package/tests/client/css/{global-basic.test.ripple → global-basic.test.rsrx} +0 -0
  120. /package/tests/client/css/{global-classes-ids.test.ripple → global-classes-ids.test.rsrx} +0 -0
  121. /package/tests/client/css/{global-combinators.test.ripple → global-combinators.test.rsrx} +0 -0
  122. /package/tests/client/css/{global-complex-nesting.test.ripple → global-complex-nesting.test.rsrx} +0 -0
  123. /package/tests/client/css/{global-edge-cases.test.ripple → global-edge-cases.test.rsrx} +0 -0
  124. /package/tests/client/css/{global-keyframes.test.ripple → global-keyframes.test.rsrx} +0 -0
  125. /package/tests/client/css/{global-nested.test.ripple → global-nested.test.rsrx} +0 -0
  126. /package/tests/client/css/{global-pseudo.test.ripple → global-pseudo.test.rsrx} +0 -0
  127. /package/tests/client/css/{global-scoping.test.ripple → global-scoping.test.rsrx} +0 -0
  128. /package/tests/client/css/{style-identifier.test.ripple → style-identifier.test.rsrx} +0 -0
  129. /package/tests/client/{date.test.ripple → date.test.rsrx} +0 -0
  130. /package/tests/client/{dynamic-elements.test.ripple → dynamic-elements.test.rsrx} +0 -0
  131. /package/tests/client/{events.test.ripple → events.test.rsrx} +0 -0
  132. /package/tests/client/{for.test.ripple → for.test.rsrx} +0 -0
  133. /package/tests/client/{function-overload-import.ripple → function-overload-import.rsrx} +0 -0
  134. /package/tests/client/{head.test.ripple → head.test.rsrx} +0 -0
  135. /package/tests/client/{html.test.ripple → html.test.rsrx} +0 -0
  136. /package/tests/client/{input-value.test.ripple → input-value.test.rsrx} +0 -0
  137. /package/tests/client/{lazy-destructuring.test.ripple → lazy-destructuring.test.rsrx} +0 -0
  138. /package/tests/client/{map.test.ripple → map.test.rsrx} +0 -0
  139. /package/tests/client/{media-query.test.ripple → media-query.test.rsrx} +0 -0
  140. /package/tests/client/{object.test.ripple → object.test.rsrx} +0 -0
  141. /package/tests/client/{portal.test.ripple → portal.test.rsrx} +0 -0
  142. /package/tests/client/{ref.test.ripple → ref.test.rsrx} +0 -0
  143. /package/tests/client/{return.test.ripple → return.test.rsrx} +0 -0
  144. /package/tests/client/{set.test.ripple → set.test.rsrx} +0 -0
  145. /package/tests/client/{svg.test.ripple → svg.test.rsrx} +0 -0
  146. /package/tests/client/{switch.test.ripple → switch.test.rsrx} +0 -0
  147. /package/tests/client/{tsx.test.ripple → tsx.test.rsrx} +0 -0
  148. /package/tests/client/{typescript-generics.test.ripple → typescript-generics.test.rsrx} +0 -0
  149. /package/tests/client/url/{url.derived.test.ripple → url.derived.test.rsrx} +0 -0
  150. /package/tests/client/url/{url.parsing.test.ripple → url.parsing.test.rsrx} +0 -0
  151. /package/tests/client/url/{url.partial-removal.test.ripple → url.partial-removal.test.rsrx} +0 -0
  152. /package/tests/client/url/{url.reactivity.test.ripple → url.reactivity.test.rsrx} +0 -0
  153. /package/tests/client/url/{url.serialization.test.ripple → url.serialization.test.rsrx} +0 -0
  154. /package/tests/client/url-search-params/{url-search-params.derived.test.ripple → url-search-params.derived.test.rsrx} +0 -0
  155. /package/tests/client/url-search-params/{url-search-params.initialization.test.ripple → url-search-params.initialization.test.rsrx} +0 -0
  156. /package/tests/client/url-search-params/{url-search-params.iteration.test.ripple → url-search-params.iteration.test.rsrx} +0 -0
  157. /package/tests/client/url-search-params/{url-search-params.mutation.test.ripple → url-search-params.mutation.test.rsrx} +0 -0
  158. /package/tests/client/url-search-params/{url-search-params.retrieval.test.ripple → url-search-params.retrieval.test.rsrx} +0 -0
  159. /package/tests/client/url-search-params/{url-search-params.serialization.test.ripple → url-search-params.serialization.test.rsrx} +0 -0
  160. /package/tests/client/url-search-params/{url-search-params.tracked-url.test.ripple → url-search-params.tracked-url.test.rsrx} +0 -0
  161. /package/tests/hydration/components/{basic.ripple → basic.rsrx} +0 -0
  162. /package/tests/hydration/components/{composite.ripple → composite.rsrx} +0 -0
  163. /package/tests/hydration/components/{events.ripple → events.rsrx} +0 -0
  164. /package/tests/hydration/components/{for.ripple → for.rsrx} +0 -0
  165. /package/tests/hydration/components/{head.ripple → head.rsrx} +0 -0
  166. /package/tests/hydration/components/{hmr.ripple → hmr.rsrx} +0 -0
  167. /package/tests/hydration/components/{html-in-template.ripple → html-in-template.rsrx} +0 -0
  168. /package/tests/hydration/components/{html.ripple → html.rsrx} +0 -0
  169. /package/tests/hydration/components/{if-children.ripple → if-children.rsrx} +0 -0
  170. /package/tests/hydration/components/{if.ripple → if.rsrx} +0 -0
  171. /package/tests/hydration/components/{nested-control-flow.ripple → nested-control-flow.rsrx} +0 -0
  172. /package/tests/hydration/components/{portal.ripple → portal.rsrx} +0 -0
  173. /package/tests/hydration/components/{reactivity.ripple → reactivity.rsrx} +0 -0
  174. /package/tests/hydration/components/{return.ripple → return.rsrx} +0 -0
  175. /package/tests/hydration/components/{switch.ripple → switch.rsrx} +0 -0
  176. /package/tests/server/{await.test.ripple → await.test.rsrx} +0 -0
  177. /package/tests/server/{basic.attributes.test.ripple → basic.attributes.test.rsrx} +0 -0
  178. /package/tests/server/{basic.components.test.ripple → basic.components.test.rsrx} +0 -0
  179. /package/tests/server/{basic.test.ripple → basic.test.rsrx} +0 -0
  180. /package/tests/server/{composite.props.test.ripple → composite.props.test.rsrx} +0 -0
  181. /package/tests/server/{composite.test.ripple → composite.test.rsrx} +0 -0
  182. /package/tests/server/{context.test.ripple → context.test.rsrx} +0 -0
  183. /package/tests/server/{dynamic-elements.test.ripple → dynamic-elements.test.rsrx} +0 -0
  184. /package/tests/server/{for.test.ripple → for.test.rsrx} +0 -0
  185. /package/tests/server/{head.test.ripple → head.test.rsrx} +0 -0
  186. /package/tests/server/{if.test.ripple → if.test.rsrx} +0 -0
  187. /package/tests/server/{lazy-destructuring.test.ripple → lazy-destructuring.test.rsrx} +0 -0
  188. /package/tests/server/{return.test.ripple → return.test.rsrx} +0 -0
  189. /package/tests/server/{style-identifier.test.ripple → style-identifier.test.rsrx} +0 -0
  190. /package/tests/server/{switch.test.ripple → switch.test.rsrx} +0 -0
@@ -0,0 +1,1702 @@
1
+ import type { Tracked } from 'ripple';
2
+ import {
3
+ bindValue,
4
+ effect,
5
+ flushSync,
6
+ track,
7
+ trackAsync,
8
+ peek,
9
+ UNINITIALIZED,
10
+ SUSPENSE_REJECTED,
11
+ SUSPENSE_PENDING,
12
+ } from 'ripple';
13
+
14
+ describe('try block with catch and pending', () => {
15
+ it('catch block works when a child throws synchronously', async () => {
16
+ component App() {
17
+ try {
18
+ <ThrowingChild />
19
+ } pending {
20
+ <p>{'loading...'}</p>
21
+ } catch (err) {
22
+ <p>{'caught error'}</p>
23
+ }
24
+ }
25
+
26
+ component ThrowingChild() {
27
+ throw new Error('sync error');
28
+ }
29
+
30
+ render(App);
31
+ await new Promise((resolve) => setTimeout(resolve, 0));
32
+ flushSync();
33
+
34
+ expect(container.innerHTML).toContain('caught error');
35
+ expect(container.innerHTML).not.toContain('loading...');
36
+ });
37
+
38
+ it('catch block works when component throws after trackAsync with pending block', async () => {
39
+ component App() {
40
+ try {
41
+ <ThrowingAfterAsync />
42
+ } pending {
43
+ <p>{'loading...'}</p>
44
+ } catch (err) {
45
+ <p>{'caught error'}</p>
46
+ }
47
+ }
48
+
49
+ component ThrowingAfterAsync() {
50
+ let &[data] = trackAsync(() => Promise.resolve('hello'));
51
+ throw new Error('error after await');
52
+ <p>{data}</p>
53
+ }
54
+
55
+ render(App);
56
+
57
+ await new Promise((resolve) => setTimeout(resolve, 0));
58
+ flushSync();
59
+
60
+ expect(container.innerHTML).toContain('caught error');
61
+ expect(container.innerHTML).not.toContain('loading...');
62
+ });
63
+
64
+ it('catch block works when trackAsync rejects', async () => {
65
+ component App() {
66
+ try {
67
+ let &[data] = trackAsync(() => Promise.reject(new Error('rejected')));
68
+ <p>{data}</p>
69
+ } pending {
70
+ <p>{'loading...'}</p>
71
+ } catch (err) {
72
+ <p>{'caught rejection'}</p>
73
+ }
74
+ }
75
+
76
+ render(App);
77
+ await new Promise((resolve) => setTimeout(resolve, 0));
78
+ flushSync();
79
+
80
+ expect(container.innerHTML).toContain('caught rejection');
81
+ expect(container.innerHTML).not.toContain('loading...');
82
+ });
83
+
84
+ it('catch replaces the retained resolved branch when a subsequent request rejects', async () => {
85
+ const requests = new Map<
86
+ number,
87
+ { resolve: (value: string) => void; reject: (error: Error) => void }
88
+ >();
89
+
90
+ function createRequest(version: number) {
91
+ let resolve_value: (value: string) => void = () => {};
92
+ let reject_value: (error: Error) => void = () => {};
93
+
94
+ const promise = new Promise<string>((resolve, reject) => {
95
+ resolve_value = resolve;
96
+ reject_value = reject;
97
+ });
98
+
99
+ requests.set(version, { resolve: resolve_value, reject: reject_value });
100
+
101
+ return promise;
102
+ }
103
+
104
+ component App() {
105
+ let &[version] = track(0);
106
+
107
+ try {
108
+ let &[data] = trackAsync(() => createRequest(version));
109
+ <p class="resolved">{data}</p>
110
+ } pending {
111
+ <p class="pending">{'loading...'}</p>
112
+ } catch (err) {
113
+ <p class="caught">{(err as Error).message}</p>
114
+ }
115
+
116
+ <button class="retry" onClick={() => version++}>{'Retry'}</button>
117
+ }
118
+
119
+ render(App);
120
+
121
+ await new Promise((resolve) => setTimeout(resolve, 0));
122
+ flushSync();
123
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
124
+
125
+ (requests.get(0) as { resolve: (value: string) => void }).resolve('resolved value');
126
+ await new Promise((resolve) => setTimeout(resolve, 0));
127
+ flushSync();
128
+
129
+ expect(container.querySelector('.resolved')?.textContent).toBe('resolved value');
130
+ expect(container.querySelector('.pending')).toBeNull();
131
+ expect(container.querySelector('.caught')).toBeNull();
132
+
133
+ (container.querySelector('.retry') as HTMLButtonElement).click();
134
+ flushSync();
135
+
136
+ expect(container.querySelector('.resolved')?.textContent).toBe('resolved value');
137
+ expect(container.querySelector('.pending')).toBeNull();
138
+
139
+ (requests.get(1) as { reject: (error: Error) => void }).reject(new Error('failed retry'));
140
+ await new Promise((resolve) => setTimeout(resolve, 0));
141
+ flushSync();
142
+
143
+ expect(container.querySelector('.caught')?.textContent).toBe('failed retry');
144
+ expect(container.querySelector('.resolved')).toBeNull();
145
+ expect(container.querySelector('.pending')).toBeNull();
146
+ });
147
+
148
+ it('retrying from catch shows pending before resolving back to the resolved branch', async () => {
149
+ const requests = new Map<
150
+ number,
151
+ { resolve: (value: string) => void; reject: (error: Error) => void }
152
+ >();
153
+
154
+ function createRequest(version: number) {
155
+ let resolve_value: (value: string) => void = () => {};
156
+ let reject_value: (error: Error) => void = () => {};
157
+
158
+ const promise = new Promise<string>((resolve, reject) => {
159
+ resolve_value = resolve;
160
+ reject_value = reject;
161
+ });
162
+
163
+ requests.set(version, { resolve: resolve_value, reject: reject_value });
164
+
165
+ return promise;
166
+ }
167
+
168
+ component App() {
169
+ let &[version] = track(0);
170
+
171
+ try {
172
+ let &[data] = trackAsync(() => {
173
+ version;
174
+ return createRequest(version);
175
+ });
176
+ <p class="resolved">{data}</p>
177
+ } pending {
178
+ <p class="pending">{'loading...'}</p>
179
+ } catch (err, reset) {
180
+ <p class="caught">{(err as Error).message}</p>
181
+ <button
182
+ class="retry"
183
+ onClick={() => {
184
+ version++;
185
+ reset();
186
+ }}
187
+ >
188
+ {'Retry'}
189
+ </button>
190
+ }
191
+ }
192
+
193
+ render(App);
194
+
195
+ await new Promise((resolve) => setTimeout(resolve, 0));
196
+ flushSync();
197
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
198
+
199
+ (requests.get(0) as { reject: (error: Error) => void }).reject(new Error('initial failure'));
200
+ await new Promise((resolve) => setTimeout(resolve, 0));
201
+ flushSync();
202
+
203
+ expect(container.querySelector('.caught')?.textContent).toBe('initial failure');
204
+ expect(container.querySelector('.pending')).toBeNull();
205
+ expect(container.querySelector('.resolved')).toBeNull();
206
+
207
+ (container.querySelector('.retry') as HTMLButtonElement).click();
208
+ flushSync();
209
+ await new Promise((resolve) => setTimeout(resolve, 0));
210
+ flushSync();
211
+
212
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
213
+ expect(container.querySelector('.caught')).toBeNull();
214
+ expect(container.querySelector('.resolved')).toBeNull();
215
+
216
+ (requests.get(1) as { resolve: (value: string) => void }).resolve('recovered');
217
+ await new Promise((resolve) => setTimeout(resolve, 0));
218
+ flushSync();
219
+
220
+ expect(container.querySelector('.resolved')?.textContent).toBe('recovered');
221
+ expect(container.querySelector('.pending')).toBeNull();
222
+ expect(container.querySelector('.caught')).toBeNull();
223
+ });
224
+ });
225
+
226
+ describe('try block', () => {
227
+ it(
228
+ 'does not compile ref binds as async callbacks inside try/pending trackAsync branches',
229
+ async () => {
230
+ component App() {
231
+ let &[value, valueTracked] = track(1);
232
+
233
+ try {
234
+ let &[loaded] = trackAsync(() => Promise.resolve(value + 1));
235
+ <input type="number" {ref bindValue(valueTracked)} />
236
+ <span>{loaded}</span>
237
+ } pending {
238
+ <p>{'loading...'}</p>
239
+ }
240
+ }
241
+
242
+ render(App);
243
+
244
+ await new Promise((resolve) => setTimeout(resolve, 0));
245
+ flushSync();
246
+
247
+ const input = container.querySelector('input') as HTMLInputElement | null;
248
+ expect(input?.value).toBe('1');
249
+ expect(container.innerHTML).toContain('<span>2</span>');
250
+ expect(container.innerHTML).not.toContain('loading...');
251
+ },
252
+ );
253
+
254
+ it('does not crash when trackAsync is used to render a list inside try/pending', async () => {
255
+ component App() {
256
+ try {
257
+ <AsyncChild />
258
+ } pending {
259
+ <p>{'loading...'}</p>
260
+ }
261
+ }
262
+
263
+ component AsyncChild() {
264
+ let &[data] = trackAsync(() => Promise.resolve(['a', 'b', 'c']));
265
+
266
+ <ul>
267
+ for (let item of data) {
268
+ <li>{item}</li>
269
+ }
270
+ </ul>
271
+ }
272
+
273
+ render(App);
274
+ await new Promise((resolve) => setTimeout(resolve, 0));
275
+ flushSync();
276
+
277
+ const items = container.querySelectorAll('li');
278
+ expect(items.length).toBe(3);
279
+ expect(items[0].textContent).toBe('a');
280
+ expect(items[1].textContent).toBe('b');
281
+ expect(items[2].textContent).toBe('c');
282
+ });
283
+
284
+ it(
285
+ 'does not crash when async component with tracked state is used inside try/pending',
286
+ async () => {
287
+ component App() {
288
+ let &[query, queryTracked] = track('');
289
+
290
+ try {
291
+ <FilteredList {queryTracked} />
292
+ } pending {
293
+ <p>{'loading...'}</p>
294
+ }
295
+ }
296
+
297
+ component FilteredList({ queryTracked }: { queryTracked: Tracked<string> }) {
298
+ let &[items] = trackAsync(() => Promise.resolve(['apple', 'banana', 'cherry']));
299
+ let &[filtered] = track(
300
+ () => items.filter((item: string) => item.includes(queryTracked.value)),
301
+ );
302
+
303
+ <ul>
304
+ for (let item of filtered) {
305
+ <li>{item}</li>
306
+ }
307
+ </ul>
308
+ }
309
+
310
+ render(App);
311
+
312
+ await new Promise((resolve) => setTimeout(resolve, 0));
313
+ flushSync();
314
+
315
+ const listItems = container.querySelectorAll('li');
316
+ expect(listItems.length).toBe(3);
317
+ },
318
+ );
319
+
320
+ it('if test condition stays synchronous once trackAsync has resolved', async () => {
321
+ component App() {
322
+ try {
323
+ let &[items] = trackAsync(() => Promise.resolve(['apple', 'banana', 'cherry']));
324
+
325
+ if (items.includes('not-in-list')) {
326
+ <p>{'not-in-list is in the list!'}</p>
327
+ } else {
328
+ <p>{'not-in-list is not in the list.'}</p>
329
+ }
330
+ } pending {
331
+ <p>{'loading...'}</p>
332
+ }
333
+ }
334
+
335
+ render(App);
336
+
337
+ await new Promise((resolve) => setTimeout(resolve, 0));
338
+ flushSync();
339
+
340
+ expect(container.innerHTML).toContain('not-in-list is not in the list.');
341
+ });
342
+
343
+ it('destroying try block while in pending state cleans up pending branch', async () => {
344
+ let pending_effect_teardown_count = 0;
345
+
346
+ component PendingChild() {
347
+ effect(() => {
348
+ return () => {
349
+ pending_effect_teardown_count++;
350
+ };
351
+ });
352
+ <p class="pending">{'loading...'}</p>
353
+ }
354
+
355
+ component App() {
356
+ let &[show] = track(true);
357
+
358
+ if (show) {
359
+ try {
360
+ let &[data] = trackAsync(() => new Promise(() => {}));
361
+ <p class="resolved">{data}</p>
362
+ } pending {
363
+ <PendingChild />
364
+ }
365
+ }
366
+
367
+ <button class="toggle" onClick={() => (show = !show)}>{'toggle'}</button>
368
+ }
369
+
370
+ render(App);
371
+
372
+ // Wait for pending microtask to fire
373
+ await new Promise((resolve) => setTimeout(resolve, 0));
374
+ flushSync();
375
+
376
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
377
+ expect(pending_effect_teardown_count).toBe(0);
378
+
379
+ // Toggle if condition to false — should destroy try block and pending branch
380
+ (container.querySelector('.toggle') as HTMLButtonElement).click();
381
+ flushSync();
382
+
383
+ expect(container.querySelector('.pending')).toBeNull();
384
+ expect(container.querySelector('.resolved')).toBeNull();
385
+ // Effect teardown in the pending branch should have run
386
+ expect(pending_effect_teardown_count).toBe(1);
387
+ });
388
+
389
+ it('destroying try block while in resolved state cleans up resolved branch', async () => {
390
+ let resolved_effect_teardown_count = 0;
391
+
392
+ component ResolvedChild(&{ data }: { data: string }) {
393
+ effect(() => {
394
+ return () => {
395
+ resolved_effect_teardown_count++;
396
+ };
397
+ });
398
+ <p class="resolved">{data}</p>
399
+ }
400
+
401
+ component App() {
402
+ let &[show] = track(true);
403
+
404
+ if (show) {
405
+ try {
406
+ let &[data] = trackAsync(() => Promise.resolve('hello'));
407
+ <ResolvedChild {data} />
408
+ } pending {
409
+ <p class="pending">{'loading...'}</p>
410
+ } catch (err) {
411
+ <p class="caught">{(err as Error).message}</p>
412
+ }
413
+ }
414
+
415
+ <button class="toggle" onClick={() => (show = !show)}>{'toggle'}</button>
416
+ }
417
+
418
+ render(App);
419
+
420
+ // Wait for async to resolve
421
+ await new Promise((resolve) => setTimeout(resolve, 0));
422
+ flushSync();
423
+
424
+ expect(container.querySelector('.resolved')?.textContent).toBe('hello');
425
+ expect(resolved_effect_teardown_count).toBe(0);
426
+
427
+ // Toggle if condition to false — should destroy try block and resolved branch
428
+ (container.querySelector('.toggle') as HTMLButtonElement).click();
429
+ flushSync();
430
+
431
+ expect(container.querySelector('.resolved')).toBeNull();
432
+ expect(container.querySelector('.pending')).toBeNull();
433
+ expect(container.querySelector('.caught')).toBeNull();
434
+ // Effect teardown in the resolved branch should have run
435
+ expect(resolved_effect_teardown_count).toBe(1);
436
+ });
437
+
438
+ it('destroying try block while in catch state cleans up catch branch', async () => {
439
+ let catch_effect_teardown_count = 0;
440
+
441
+ component CatchChild({ error }: { error: Error }) {
442
+ effect(() => {
443
+ return () => {
444
+ catch_effect_teardown_count++;
445
+ };
446
+ });
447
+ <p class="caught">{error.message}</p>
448
+ }
449
+
450
+ component App() {
451
+ let &[show] = track(true);
452
+
453
+ if (show) {
454
+ try {
455
+ let &[data] = trackAsync(() => Promise.reject(new Error('fail')));
456
+ <p class="resolved">{data}</p>
457
+ } pending {
458
+ <p class="pending">{'loading...'}</p>
459
+ } catch (err) {
460
+ <CatchChild error={err as Error} />
461
+ }
462
+ }
463
+
464
+ <button class="toggle" onClick={() => (show = !show)}>{'toggle'}</button>
465
+ }
466
+
467
+ render(App);
468
+
469
+ await new Promise((resolve) => setTimeout(resolve, 0));
470
+ flushSync();
471
+
472
+ expect(container.querySelector('.caught')?.textContent).toBe('fail');
473
+ expect(catch_effect_teardown_count).toBe(0);
474
+
475
+ // Toggle if condition to false — should destroy try block and catch branch
476
+ (container.querySelector('.toggle') as HTMLButtonElement).click();
477
+ flushSync();
478
+
479
+ expect(container.querySelector('.caught')).toBeNull();
480
+ // Effect teardown in the catch branch should have run
481
+ expect(catch_effect_teardown_count).toBe(1);
482
+ });
483
+
484
+ it('pending block throwing renders catch block from the same try block', async () => {
485
+ component ThrowingPending() {
486
+ throw new Error('pending exploded');
487
+ <p>{'should not render'}</p>
488
+ }
489
+
490
+ component App() {
491
+ try {
492
+ let &[data] = trackAsync(() => new Promise(() => {}));
493
+ <p class="resolved">{data}</p>
494
+ } pending {
495
+ <ThrowingPending />
496
+ } catch (err) {
497
+ <p class="caught">{(err as Error).message}</p>
498
+ }
499
+ }
500
+
501
+ render(App);
502
+
503
+ // Wait for pending microtask to fire and render pending block
504
+ await new Promise((resolve) => setTimeout(resolve, 0));
505
+ flushSync();
506
+
507
+ // Pending block threw, so catch should be rendered
508
+ expect(container.querySelector('.caught')?.textContent).toBe('pending exploded');
509
+ expect(container.querySelector('.pending')).toBeNull();
510
+ expect(container.querySelector('.resolved')).toBeNull();
511
+ });
512
+
513
+ it('pending block throwing without catch bubbles to parent try/catch', async () => {
514
+ component ThrowingPending() {
515
+ throw new Error('pending exploded');
516
+ <p>{'should not render'}</p>
517
+ }
518
+
519
+ component App() {
520
+ try {
521
+ <Inner />
522
+ } catch (err) {
523
+ <p class="parent-caught">{(err as Error).message}</p>
524
+ }
525
+ }
526
+
527
+ component Inner() {
528
+ try {
529
+ let &[data] = trackAsync(() => new Promise(() => {}));
530
+ <p class="resolved">{data}</p>
531
+ } pending {
532
+ <ThrowingPending />
533
+ }
534
+ }
535
+
536
+ render(App);
537
+
538
+ // Wait for pending microtask to fire and render pending block
539
+ await new Promise((resolve) => setTimeout(resolve, 0));
540
+ flushSync();
541
+
542
+ // Inner try has no catch, error should bubble to parent's catch
543
+ expect(container.querySelector('.parent-caught')?.textContent).toBe('pending exploded');
544
+ expect(container.querySelector('.resolved')).toBeNull();
545
+ });
546
+
547
+ it('chained trackAsync keeps pending block until all deriveds resolve', async () => {
548
+ let first_resolve: (value: string) => void;
549
+ let second_resolve: (value: number) => void;
550
+
551
+ const first_promise = new Promise<string>((resolve) => {
552
+ first_resolve = resolve;
553
+ });
554
+
555
+ component App() {
556
+ try {
557
+ let &[name] = trackAsync(() => first_promise);
558
+ let &[length] = trackAsync(() => {
559
+ // Read name synchronously — throws ASYNC_DERIVED_READ_THROWN
560
+ // while name is still pending, triggering the deferred mechanism.
561
+ const n = name;
562
+ return new Promise<number>((resolve) => {
563
+ second_resolve = resolve;
564
+ }).then((val) => n.length + val);
565
+ });
566
+ <p class="resolved">
567
+ {name}
568
+ {' has length '}
569
+ {length}
570
+ </p>
571
+ } pending {
572
+ <p class="pending">{'loading...'}</p>
573
+ }
574
+ }
575
+
576
+ render(App);
577
+
578
+ // Wait for pending microtask
579
+ await new Promise((resolve) => setTimeout(resolve, 0));
580
+ flushSync();
581
+
582
+ // Both are pending — pending block should show
583
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
584
+ expect(container.querySelector('.resolved')).toBeNull();
585
+
586
+ // Resolve the first trackAsync
587
+ first_resolve!('hello');
588
+ await new Promise((resolve) => setTimeout(resolve, 0));
589
+ flushSync();
590
+
591
+ // First resolved, but second still pending — pending block must stay
592
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
593
+ expect(container.querySelector('.resolved')).toBeNull();
594
+
595
+ // Resolve the second trackAsync
596
+ second_resolve!(100);
597
+ await new Promise((resolve) => setTimeout(resolve, 0));
598
+ flushSync();
599
+
600
+ // Both resolved — pending removed, resolved content shown with both values
601
+ expect(container.querySelector('.pending')).toBeNull();
602
+ expect(container.querySelector('.resolved')?.textContent).toBe('hello has length 105');
603
+ });
604
+
605
+ it(
606
+ 'chained trackAsync does not recreate pending block when intermediate derived resolves',
607
+ async () => {
608
+ let first_resolve: (value: string) => void;
609
+ let second_resolve: (value: number) => void;
610
+ let pending_render_count = 0;
611
+
612
+ const first_promise = new Promise<string>((resolve) => {
613
+ first_resolve = resolve;
614
+ });
615
+
616
+ component PendingTracker() {
617
+ pending_render_count++;
618
+ <p class="pending">{'loading...'}</p>
619
+ }
620
+
621
+ component App() {
622
+ try {
623
+ let &[name] = trackAsync(() => first_promise);
624
+ let &[length] = trackAsync(() => {
625
+ const n = name;
626
+ return new Promise<number>((resolve) => {
627
+ second_resolve = resolve;
628
+ }).then((val) => n.length + val);
629
+ });
630
+ <p class="resolved">
631
+ {name}
632
+ {' has length '}
633
+ {length}
634
+ </p>
635
+ } pending {
636
+ <PendingTracker />
637
+ }
638
+ }
639
+
640
+ render(App);
641
+
642
+ // Wait for pending microtask to fire and render pending block
643
+ await new Promise((resolve) => setTimeout(resolve, 0));
644
+ flushSync();
645
+
646
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
647
+ expect(pending_render_count).toBe(1);
648
+
649
+ // Resolve the first — second starts its own async request
650
+ // Pending block should NOT be torn down and recreated
651
+ first_resolve!('hello');
652
+ await new Promise((resolve) => setTimeout(resolve, 0));
653
+ flushSync();
654
+
655
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
656
+ expect(pending_render_count).toBe(1); // Still 1 — no flicker
657
+
658
+ // Resolve the second — pending removed, resolved shown
659
+ second_resolve!(100);
660
+ await new Promise((resolve) => setTimeout(resolve, 0));
661
+ flushSync();
662
+
663
+ expect(container.querySelector('.pending')).toBeNull();
664
+ expect(container.querySelector('.resolved')?.textContent).toBe('hello has length 105');
665
+ expect(pending_render_count).toBe(1); // Never recreated
666
+ },
667
+ );
668
+
669
+ it(
670
+ 'chained trackAsync: first rejects, catch renders and stays after second resolves',
671
+ async () => {
672
+ let first_reject: (error: Error) => void;
673
+ let second_resolve: (value: number) => void;
674
+
675
+ const first_promise = new Promise<string>((_, reject) => {
676
+ first_reject = reject;
677
+ });
678
+
679
+ component App() {
680
+ try {
681
+ let &[name] = trackAsync(() => first_promise);
682
+ let &[length] = trackAsync(() => {
683
+ const n = name;
684
+ return new Promise<number>((resolve) => {
685
+ second_resolve = resolve;
686
+ }).then((val) => n.length + val);
687
+ });
688
+ <p class="resolved">
689
+ {name}
690
+ {' has length '}
691
+ {length}
692
+ </p>
693
+ } pending {
694
+ <p class="pending">{'loading...'}</p>
695
+ } catch (err) {
696
+ <p class="caught">{(err as Error).message}</p>
697
+ }
698
+ }
699
+
700
+ render(App);
701
+
702
+ await new Promise((resolve) => setTimeout(resolve, 0));
703
+ flushSync();
704
+
705
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
706
+ expect(container.querySelector('.resolved')).toBeNull();
707
+ expect(container.querySelector('.caught')).toBeNull();
708
+
709
+ // Reject the first trackAsync
710
+ first_reject!(new Error('first failed'));
711
+ await new Promise((resolve) => setTimeout(resolve, 0));
712
+ flushSync();
713
+
714
+ // Catch block should render
715
+ expect(container.querySelector('.caught')?.textContent).toBe('first failed');
716
+ expect(container.querySelector('.pending')).toBeNull();
717
+ expect(container.querySelector('.resolved')).toBeNull();
718
+
719
+ // Resolve the second trackAsync — catch block should stay
720
+ expect(second_resolve!).toBeUndefined();
721
+ },
722
+ );
723
+
724
+ it(
725
+ 'chained trackAsync: first succeeds, second rejects, pending stays then catch renders',
726
+ async () => {
727
+ let first_resolve: (value: string) => void;
728
+ let second_reject: (error: Error) => void;
729
+
730
+ const first_promise = new Promise<string>((resolve) => {
731
+ first_resolve = resolve;
732
+ });
733
+
734
+ component App() {
735
+ try {
736
+ let &[name] = trackAsync(() => first_promise);
737
+ let &[length] = trackAsync(() => {
738
+ const n = name;
739
+ return new Promise<number>((_, reject) => {
740
+ second_reject = reject;
741
+ });
742
+ });
743
+ <p class="resolved">
744
+ {name}
745
+ {' has length '}
746
+ {length}
747
+ </p>
748
+ } pending {
749
+ <p class="pending">{'loading...'}</p>
750
+ } catch (err) {
751
+ <p class="caught">{(err as Error).message}</p>
752
+ }
753
+ }
754
+
755
+ render(App);
756
+
757
+ await new Promise((resolve) => setTimeout(resolve, 0));
758
+ flushSync();
759
+
760
+ // Both pending — pending block shows
761
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
762
+ expect(container.querySelector('.resolved')).toBeNull();
763
+ expect(container.querySelector('.caught')).toBeNull();
764
+
765
+ // Resolve the first — second still pending, pending block stays
766
+ first_resolve!('hello');
767
+ await new Promise((resolve) => setTimeout(resolve, 0));
768
+ flushSync();
769
+
770
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
771
+ expect(container.querySelector('.resolved')).toBeNull();
772
+ expect(container.querySelector('.caught')).toBeNull();
773
+
774
+ // Reject the second — switch to catch
775
+ second_reject!(new Error('second failed'));
776
+ await new Promise((resolve) => setTimeout(resolve, 0));
777
+ flushSync();
778
+
779
+ expect(container.querySelector('.caught')?.textContent).toBe('second failed');
780
+ expect(container.querySelector('.pending')).toBeNull();
781
+ expect(container.querySelector('.resolved')).toBeNull();
782
+ },
783
+ );
784
+
785
+ it('resolved try block without catch bubbles error to parent try/catch', async () => {
786
+ component ThrowingChild() {
787
+ let &[data] = trackAsync(() => Promise.reject(new Error('async failed')));
788
+ <p class="resolved">{data}</p>
789
+ }
790
+
791
+ component App() {
792
+ try {
793
+ <Inner />
794
+ } catch (err) {
795
+ <p class="parent-caught">{(err as Error).message}</p>
796
+ }
797
+ }
798
+
799
+ component Inner() {
800
+ try {
801
+ <ThrowingChild />
802
+ } pending {
803
+ <p class="pending">{'loading...'}</p>
804
+ }
805
+ }
806
+
807
+ render(App);
808
+
809
+ await new Promise((resolve) => setTimeout(resolve, 0));
810
+ flushSync();
811
+
812
+ // Inner try has no catch, rejection should bubble to parent's catch
813
+ expect(container.querySelector('.parent-caught')?.textContent).toBe('async failed');
814
+ expect(container.querySelector('.pending')).toBeNull();
815
+ expect(container.querySelector('.resolved')).toBeNull();
816
+ });
817
+
818
+ it(
819
+ 'rejection sets derived value to SUSPENSE_REJECTED and promise is properly handled',
820
+ async () => {
821
+ let reject_fn: (error: Error) => void;
822
+ let promise_settled: { type: 'resolved' | 'rejected'; value: any } | null = null;
823
+
824
+ const user_promise = new Promise<string>((_, reject) => {
825
+ reject_fn = reject;
826
+ });
827
+ user_promise.then(
828
+ (v) => {
829
+ promise_settled = { type: 'resolved', value: v };
830
+ },
831
+ (e) => {
832
+ promise_settled = { type: 'rejected', value: e };
833
+ },
834
+ );
835
+
836
+ let data: any;
837
+
838
+ component App() {
839
+ try {
840
+ data = trackAsync(() => user_promise);
841
+ <p class="resolved">{data.value}</p>
842
+ } pending {
843
+ <p class="pending">{'loading...'}</p>
844
+ } catch (err) {
845
+ <p class="caught">{(err as Error).message}</p>
846
+ }
847
+ }
848
+
849
+ render(App);
850
+ await new Promise((resolve) => setTimeout(resolve, 0));
851
+ flushSync();
852
+
853
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
854
+
855
+ reject_fn!(new Error('test rejection'));
856
+ await new Promise((resolve) => setTimeout(resolve, 0));
857
+ flushSync();
858
+
859
+ expect(container.querySelector('.caught')?.textContent).toBe('test rejection');
860
+ expect(container.querySelector('.pending')).toBeNull();
861
+ expect(container.querySelector('.resolved')).toBeNull();
862
+
863
+ // The user promise was rejected and handled by the runtime
864
+ expect(promise_settled).toEqual({ type: 'rejected', value: expect.any(Error) });
865
+ expect((promise_settled as any).value.message).toBe('test rejection');
866
+ expect(peek(data)).toEqual(SUSPENSE_REJECTED);
867
+ },
868
+ );
869
+
870
+ it(
871
+ 'chained trackAsync: first rejects, dependent deferred is rejected and cleaned up',
872
+ async () => {
873
+ let first_reject: (error: Error) => void;
874
+ let first_settled: { type: 'resolved' | 'rejected'; value: any } | null = null;
875
+
876
+ const first_promise = new Promise<string>((_, reject) => {
877
+ first_reject = reject;
878
+ });
879
+ first_promise.then(
880
+ (v) => {
881
+ first_settled = { type: 'resolved', value: v };
882
+ },
883
+ (e) => {
884
+ first_settled = { type: 'rejected', value: e };
885
+ },
886
+ );
887
+
888
+ let name: any;
889
+ let length: any;
890
+
891
+ component App() {
892
+ try {
893
+ name = trackAsync(() => first_promise);
894
+ length = trackAsync(() => {
895
+ const n = name.value;
896
+ return new Promise<number>((resolve) => {
897
+ resolve(n.length);
898
+ });
899
+ });
900
+ <p class="resolved">
901
+ {name.value}
902
+ {' has length '}
903
+ {length.value}
904
+ </p>
905
+ } pending {
906
+ <p class="pending">{'loading...'}</p>
907
+ } catch (err) {
908
+ <p class="caught">{(err as Error).message}</p>
909
+ }
910
+ }
911
+
912
+ render(App);
913
+ await new Promise((resolve) => setTimeout(resolve, 0));
914
+ flushSync();
915
+
916
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
917
+
918
+ // Reject the first — should route to catch and clean up second's deferred
919
+ first_reject!(new Error('first failed'));
920
+ await new Promise((resolve) => setTimeout(resolve, 0));
921
+ flushSync();
922
+
923
+ expect(container.querySelector('.caught')?.textContent).toBe('first failed');
924
+ expect(container.querySelector('.pending')).toBeNull();
925
+ expect(container.querySelector('.resolved')).toBeNull();
926
+ expect(peek(name)).toEqual(SUSPENSE_REJECTED);
927
+
928
+ // First promise was rejected and handled
929
+ expect(first_settled).toEqual({ type: 'rejected', value: expect.any(Error) });
930
+
931
+ // Verify stability — catch stays, no further state changes
932
+ await new Promise((resolve) => setTimeout(resolve, 50));
933
+ flushSync();
934
+ expect(container.querySelector('.caught')?.textContent).toBe('first failed');
935
+ // length never has a chance to run because name access throws and exits the block
936
+ expect(peek(length)).toEqual(SUSPENSE_REJECTED);
937
+ },
938
+ );
939
+
940
+ it('deep chained trackAsync: A rejects, B and C deferreds are cleaned up', async () => {
941
+ let a_reject: (error: Error) => void;
942
+ let a_settled: { type: 'resolved' | 'rejected'; value: any } | null = null;
943
+
944
+ const a_promise = new Promise<string>((_, reject) => {
945
+ a_reject = reject;
946
+ });
947
+ a_promise.then(
948
+ (v) => {
949
+ a_settled = { type: 'resolved', value: v };
950
+ },
951
+ (e) => {
952
+ a_settled = { type: 'rejected', value: e };
953
+ },
954
+ );
955
+
956
+ let a: any;
957
+ let b: any;
958
+ let c: any;
959
+ component App() {
960
+ try {
961
+ a = trackAsync(() => a_promise);
962
+ b = trackAsync(() => {
963
+ const val = a.value;
964
+ return Promise.resolve(val.toUpperCase());
965
+ });
966
+ c = trackAsync(() => {
967
+ const val = b.value;
968
+ return Promise.resolve(val.length);
969
+ });
970
+ <p class="resolved">
971
+ {a.value}
972
+ {' → '}
973
+ {b.value}
974
+ {' → '}
975
+ {c.value}
976
+ </p>
977
+ } pending {
978
+ <p class="pending">{'loading...'}</p>
979
+ } catch (err) {
980
+ <p class="caught">{(err as Error).message}</p>
981
+ }
982
+ }
983
+
984
+ render(App);
985
+ await new Promise((resolve) => setTimeout(resolve, 0));
986
+ flushSync();
987
+
988
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
989
+
990
+ // Reject A — should cascade to catch, B and C cleaned up
991
+ a_reject!(new Error('chain failed'));
992
+ await new Promise((resolve) => setTimeout(resolve, 0));
993
+ flushSync();
994
+
995
+ expect(container.querySelector('.caught')?.textContent).toBe('chain failed');
996
+ expect(container.querySelector('.pending')).toBeNull();
997
+ expect(container.querySelector('.resolved')).toBeNull();
998
+
999
+ // A promise was rejected and handled
1000
+ expect(a_settled).toEqual({ type: 'rejected', value: expect.any(Error) });
1001
+ expect(peek(a)).toEqual(SUSPENSE_REJECTED);
1002
+ expect(peek(b)).toEqual(SUSPENSE_REJECTED);
1003
+ expect(peek(c)).toEqual(SUSPENSE_REJECTED);
1004
+
1005
+ // Verify stability — no further state changes from B/C deferreds settling
1006
+ await new Promise((resolve) => setTimeout(resolve, 50));
1007
+ flushSync();
1008
+ expect(container.querySelector('.caught')?.textContent).toBe('chain failed');
1009
+ });
1010
+
1011
+ it('multiple independent trackAsyncs, one rejects, catch shows first error', async () => {
1012
+ let first_reject: (error: Error) => void;
1013
+ let second_resolve: (value: number) => void;
1014
+ let first_settled: { type: 'resolved' | 'rejected'; value: any } | null = null;
1015
+ let second_settled: { type: 'resolved' | 'rejected'; value: any } | null = null;
1016
+
1017
+ const first_promise = new Promise<string>((_, reject) => {
1018
+ first_reject = reject;
1019
+ });
1020
+ first_promise.then(
1021
+ (v) => {
1022
+ first_settled = { type: 'resolved', value: v };
1023
+ },
1024
+ (e) => {
1025
+ first_settled = { type: 'rejected', value: e };
1026
+ },
1027
+ );
1028
+
1029
+ const second_promise = new Promise<number>((resolve) => {
1030
+ second_resolve = resolve;
1031
+ });
1032
+ second_promise.then(
1033
+ (v) => {
1034
+ second_settled = { type: 'resolved', value: v };
1035
+ },
1036
+ (e) => {
1037
+ second_settled = { type: 'rejected', value: e };
1038
+ },
1039
+ );
1040
+
1041
+ let name: any;
1042
+ let count: any;
1043
+ component App() {
1044
+ try {
1045
+ name = trackAsync(() => first_promise);
1046
+ count = trackAsync(() => second_promise);
1047
+ <p class="resolved">
1048
+ {name.value}
1049
+ {' count: '}
1050
+ {count.value}
1051
+ </p>
1052
+ } pending {
1053
+ <p class="pending">{'loading...'}</p>
1054
+ } catch (err) {
1055
+ <p class="caught">{(err as Error).message}</p>
1056
+ }
1057
+ }
1058
+
1059
+ render(App);
1060
+ await new Promise((resolve) => setTimeout(resolve, 0));
1061
+ flushSync();
1062
+
1063
+ expect(container.querySelector('.pending')?.textContent).toBe('loading...');
1064
+ expect(peek(name)).toEqual(SUSPENSE_PENDING);
1065
+ expect(peek(count)).toEqual(SUSPENSE_PENDING);
1066
+
1067
+ // Reject the first — catch should render
1068
+ first_reject!(new Error('name failed'));
1069
+ await new Promise((resolve) => setTimeout(resolve, 0));
1070
+ flushSync();
1071
+
1072
+ expect(container.querySelector('.caught')?.textContent).toBe('name failed');
1073
+ expect(container.querySelector('.pending')).toBeNull();
1074
+
1075
+ // First promise was rejected
1076
+ expect(first_settled).toEqual({ type: 'rejected', value: expect.any(Error) });
1077
+ expect(peek(name)).toEqual(SUSPENSE_REJECTED);
1078
+ expect(peek(count)).toEqual(SUSPENSE_PENDING);
1079
+
1080
+ // Resolve the second — catch should stay stable
1081
+ second_resolve!(42);
1082
+ await new Promise((resolve) => setTimeout(resolve, 0));
1083
+ flushSync();
1084
+
1085
+ expect(container.querySelector('.caught')?.textContent).toBe('name failed');
1086
+ expect(container.querySelector('.resolved')).toBeNull();
1087
+
1088
+ // Second promise was resolved (by the test), but the boundary already caught
1089
+ expect(second_settled).toEqual({ type: 'resolved', value: 42 });
1090
+ expect(peek(name)).toEqual(SUSPENSE_REJECTED);
1091
+ expect(peek(count)).toEqual(42);
1092
+ });
1093
+ });
1094
+
1095
+ describe('sync error while async deriveds are pending', () => {
1096
+ it(
1097
+ 'catch stays stable when sync error fires while promise is pending, then promise resolves',
1098
+ async () => {
1099
+ let resolve_fn: ((v: string) => void) | undefined;
1100
+ let user_promise = new Promise<string>((resolve) => {
1101
+ resolve_fn = resolve;
1102
+ });
1103
+ let promise_settled: { type: string; value: any } | null = null;
1104
+ user_promise.then(
1105
+ (v) => (promise_settled = { type: 'resolved', value: v }),
1106
+ (e) => (promise_settled = { type: 'rejected', value: e }),
1107
+ );
1108
+
1109
+ component App() {
1110
+ try {
1111
+ <AsyncChild />
1112
+ <ThrowingChild />
1113
+ } pending {
1114
+ <p class="pending">{'loading...'}</p>
1115
+ } catch (err: Error) {
1116
+ <p class="caught">{err.message}</p>
1117
+ }
1118
+ }
1119
+
1120
+ let data: any;
1121
+ component AsyncChild() {
1122
+ data = trackAsync(() => user_promise);
1123
+ <p class="async-resolved">{data.value}</p>
1124
+ }
1125
+
1126
+ component ThrowingChild() {
1127
+ throw new Error('sync boom');
1128
+ }
1129
+
1130
+ render(App);
1131
+ await new Promise((r) => setTimeout(r, 0));
1132
+ flushSync();
1133
+
1134
+ // The sync error from ThrowingChild should trigger catch
1135
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1136
+ expect(container.querySelector('.pending')).toBeNull();
1137
+ expect(container.querySelector('.async-resolved')).toBeNull();
1138
+
1139
+ expect(peek(data)).toEqual(SUSPENSE_PENDING);
1140
+
1141
+ // Now resolve the promise that was in-flight
1142
+ resolve_fn!('hello');
1143
+ await new Promise((r) => setTimeout(r, 0));
1144
+ flushSync();
1145
+
1146
+ // Catch should stay stable — boundary should NOT switch to resolved
1147
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1148
+ expect(container.querySelector('.async-resolved')).toBeNull();
1149
+ expect(promise_settled).toEqual({ type: 'resolved', value: 'hello' });
1150
+
1151
+ expect(peek(data)).toEqual('hello');
1152
+
1153
+ // Verify stability after all microtasks drain
1154
+ await new Promise((r) => setTimeout(r, 50));
1155
+ flushSync();
1156
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1157
+ expect(container.querySelector('.async-resolved')).toBeNull();
1158
+ },
1159
+ );
1160
+
1161
+ it(
1162
+ 'catch stays stable when sync error fires while promise is pending, then promise rejects',
1163
+ async () => {
1164
+ let reject_fn: ((e: any) => void) | undefined;
1165
+ let user_promise = new Promise<string>((_, reject) => {
1166
+ reject_fn = reject;
1167
+ });
1168
+ let promise_settled: { type: string; value: any } | null = null;
1169
+ user_promise.then(
1170
+ (v) => (promise_settled = { type: 'resolved', value: v }),
1171
+ (e) => (promise_settled = { type: 'rejected', value: e }),
1172
+ );
1173
+
1174
+ component App() {
1175
+ try {
1176
+ <AsyncChild />
1177
+ <ThrowingChild />
1178
+ } pending {
1179
+ <p class="pending">{'loading...'}</p>
1180
+ } catch (err: Error) {
1181
+ <p class="caught">{err.message}</p>
1182
+ }
1183
+ }
1184
+
1185
+ let data: any;
1186
+ component AsyncChild() {
1187
+ data = trackAsync(() => user_promise);
1188
+ <p class="async-resolved">{data.value}</p>
1189
+ }
1190
+
1191
+ component ThrowingChild() {
1192
+ throw new Error('sync boom');
1193
+ }
1194
+
1195
+ render(App);
1196
+ await new Promise((r) => setTimeout(r, 0));
1197
+ flushSync();
1198
+
1199
+ // Catch should be showing from sync error
1200
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1201
+
1202
+ expect(peek(data)).toEqual(SUSPENSE_PENDING);
1203
+
1204
+ // Now reject the in-flight promise
1205
+ reject_fn!(new Error('async failure'));
1206
+ await new Promise((r) => setTimeout(r, 0));
1207
+ flushSync();
1208
+
1209
+ // Catch should stay stable with the original sync error — no double-catch
1210
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1211
+ expect(promise_settled).toEqual({ type: 'rejected', value: expect.any(Error) });
1212
+
1213
+ expect(peek(data)).toEqual(SUSPENSE_REJECTED);
1214
+
1215
+ // Verify stability after all microtasks drain
1216
+ await new Promise((r) => setTimeout(r, 0));
1217
+ flushSync();
1218
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1219
+ expect(container.querySelector('.async-resolved')).toBeNull();
1220
+ },
1221
+ );
1222
+
1223
+ it(
1224
+ 'catch stays stable when sync error fires with multiple pending trackAsyncs, all later resolve',
1225
+ async () => {
1226
+ let resolve_a: ((v: string) => void) | undefined;
1227
+ let resolve_b: ((v: number) => void) | undefined;
1228
+ let promise_a = new Promise<string>((resolve) => {
1229
+ resolve_a = resolve;
1230
+ });
1231
+ let promise_b = new Promise<number>((resolve) => {
1232
+ resolve_b = resolve;
1233
+ });
1234
+
1235
+ let settled_a: { type: string; value: any } | null = null;
1236
+ let settled_b: { type: string; value: any } | null = null;
1237
+ promise_a.then(
1238
+ (v) => (settled_a = { type: 'resolved', value: v }),
1239
+ (e) => (settled_a = { type: 'rejected', value: e }),
1240
+ );
1241
+ promise_b.then(
1242
+ (v) => (settled_b = { type: 'resolved', value: v }),
1243
+ (e) => (settled_b = { type: 'rejected', value: e }),
1244
+ );
1245
+
1246
+ component App() {
1247
+ try {
1248
+ <ChildA />
1249
+ <ChildB />
1250
+ <ThrowingChild />
1251
+ } pending {
1252
+ <p class="pending">{'loading...'}</p>
1253
+ } catch (err: Error) {
1254
+ <p class="caught">{err.message}</p>
1255
+ }
1256
+ }
1257
+
1258
+ let dataA: any;
1259
+ component ChildA() {
1260
+ dataA = trackAsync(() => promise_a);
1261
+ <p class="a">{dataA.value}</p>
1262
+ }
1263
+
1264
+ let dataB: any;
1265
+ component ChildB() {
1266
+ dataB = trackAsync(() => promise_b);
1267
+ <p class="b">{dataB.value}</p>
1268
+ }
1269
+
1270
+ component ThrowingChild() {
1271
+ throw new Error('sync boom');
1272
+ }
1273
+
1274
+ render(App);
1275
+ await new Promise((r) => setTimeout(r, 0));
1276
+ flushSync();
1277
+
1278
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1279
+
1280
+ expect(peek(dataA)).toEqual(SUSPENSE_PENDING);
1281
+ expect(peek(dataB)).toEqual(SUSPENSE_PENDING);
1282
+
1283
+ // Resolve both promises
1284
+ resolve_a!('hello');
1285
+ resolve_b!(42);
1286
+ await new Promise((r) => setTimeout(r, 0));
1287
+ flushSync();
1288
+
1289
+ // Catch should stay — boundary must NOT switch to resolved
1290
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1291
+ expect(container.querySelector('.a')).toBeNull();
1292
+ expect(container.querySelector('.b')).toBeNull();
1293
+ expect(settled_a).toEqual({ type: 'resolved', value: 'hello' });
1294
+ expect(settled_b).toEqual({ type: 'resolved', value: 42 });
1295
+
1296
+ expect(peek(dataA)).toEqual('hello');
1297
+ expect(peek(dataB)).toEqual(42);
1298
+
1299
+ // Verify stability after all microtasks drain
1300
+ await new Promise((r) => setTimeout(r, 50));
1301
+ flushSync();
1302
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1303
+ expect(container.querySelector('.a')).toBeNull();
1304
+ expect(container.querySelector('.b')).toBeNull();
1305
+ },
1306
+ );
1307
+
1308
+ it(
1309
+ 'catch stays stable when sync error fires with multiple pending trackAsyncs, one resolves and one rejects',
1310
+ async () => {
1311
+ let resolve_a: ((v: string) => void) | undefined;
1312
+ let reject_b: ((e: any) => void) | undefined;
1313
+ let promise_a = new Promise<string>((resolve) => {
1314
+ resolve_a = resolve;
1315
+ });
1316
+ let promise_b = new Promise<number>((_, reject) => {
1317
+ reject_b = reject;
1318
+ });
1319
+
1320
+ let settled_a: { type: string; value: any } | null = null;
1321
+ let settled_b: { type: string; value: any } | null = null;
1322
+ promise_a.then(
1323
+ (v) => (settled_a = { type: 'resolved', value: v }),
1324
+ (e) => (settled_a = { type: 'rejected', value: e }),
1325
+ );
1326
+ promise_b.then(
1327
+ (v) => (settled_b = { type: 'resolved', value: v }),
1328
+ (e) => (settled_b = { type: 'rejected', value: e }),
1329
+ );
1330
+
1331
+ component App() {
1332
+ try {
1333
+ <ChildA />
1334
+ <ChildB />
1335
+ <ThrowingChild />
1336
+ } pending {
1337
+ <p class="pending">{'loading...'}</p>
1338
+ } catch (err: Error) {
1339
+ <p class="caught">{err.message}</p>
1340
+ }
1341
+ }
1342
+
1343
+ let dataA: any;
1344
+ component ChildA() {
1345
+ dataA = trackAsync(() => promise_a);
1346
+ <p class="a">{dataA.value}</p>
1347
+ }
1348
+
1349
+ let dataB: any;
1350
+ component ChildB() {
1351
+ dataB = trackAsync(() => promise_b);
1352
+ <p class="b">{dataB.value}</p>
1353
+ }
1354
+
1355
+ component ThrowingChild() {
1356
+ throw new Error('sync boom');
1357
+ }
1358
+
1359
+ render(App);
1360
+ await new Promise((r) => setTimeout(r, 0));
1361
+ flushSync();
1362
+
1363
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1364
+
1365
+ expect(peek(dataA)).toEqual(SUSPENSE_PENDING);
1366
+ expect(peek(dataB)).toEqual(SUSPENSE_PENDING);
1367
+
1368
+ // Resolve A, reject B
1369
+ resolve_a!('hello');
1370
+ reject_b!(new Error('async failure'));
1371
+ await new Promise((r) => setTimeout(r, 0));
1372
+ flushSync();
1373
+
1374
+ // Catch should stay with original sync error
1375
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1376
+ expect(container.querySelector('.a')).toBeNull();
1377
+ expect(container.querySelector('.b')).toBeNull();
1378
+ expect(settled_a).toEqual({ type: 'resolved', value: 'hello' });
1379
+ expect(settled_b).toEqual({ type: 'rejected', value: expect.any(Error) });
1380
+
1381
+ expect(peek(dataA)).toEqual('hello');
1382
+ expect(peek(dataB)).toEqual(SUSPENSE_REJECTED);
1383
+
1384
+ // Verify stability after all microtasks drain
1385
+ await new Promise((r) => setTimeout(r, 50));
1386
+ flushSync();
1387
+ expect(container.querySelector('.caught')?.textContent).toBe('sync boom');
1388
+ expect(container.querySelector('.a')).toBeNull();
1389
+ expect(container.querySelector('.b')).toBeNull();
1390
+ },
1391
+ );
1392
+
1393
+ it('try block without catch propagates sync error to upstream boundary', async () => {
1394
+ component ThrowingChild() {
1395
+ throw new Error('no catch here');
1396
+ <p>{'should not render'}</p>
1397
+ }
1398
+
1399
+ component App() {
1400
+ try {
1401
+ <Inner />
1402
+ } catch (err) {
1403
+ <p class="upstream-caught">{(err as Error).message}</p>
1404
+ }
1405
+ }
1406
+
1407
+ component Inner() {
1408
+ try {
1409
+ <ThrowingChild />
1410
+ } pending {
1411
+ <p class="inner-pending">{'loading...'}</p>
1412
+ }
1413
+ }
1414
+
1415
+ render(App);
1416
+ await new Promise((resolve) => setTimeout(resolve, 0));
1417
+ flushSync();
1418
+
1419
+ expect(container.querySelector('.upstream-caught')?.textContent).toBe('no catch here');
1420
+ });
1421
+
1422
+ it('try block without catch propagates async rejection to upstream boundary', async () => {
1423
+ component App() {
1424
+ try {
1425
+ <Inner />
1426
+ } catch (err) {
1427
+ <p class="upstream-caught">{(err as Error).message}</p>
1428
+ }
1429
+ }
1430
+
1431
+ component Inner() {
1432
+ try {
1433
+ let &[data] = trackAsync(() => Promise.reject(new Error('async no catch')));
1434
+ <p class="resolved">{data}</p>
1435
+ } pending {
1436
+ <p class="inner-pending">{'loading...'}</p>
1437
+ }
1438
+ }
1439
+
1440
+ render(App);
1441
+ await new Promise((resolve) => setTimeout(resolve, 0));
1442
+ flushSync();
1443
+
1444
+ expect(container.querySelector('.upstream-caught')?.textContent).toBe('async no catch');
1445
+ expect(container.querySelector('.resolved')).toBeNull();
1446
+ });
1447
+
1448
+ it(
1449
+ 'try block without catch and without pending propagates async rejection to upstream boundary',
1450
+ async () => {
1451
+ let reject_fn: (error: Error) => void;
1452
+ const user_promise = new Promise<string>((_, reject) => {
1453
+ reject_fn = reject;
1454
+ });
1455
+
1456
+ component App() {
1457
+ try {
1458
+ <Inner />
1459
+ } catch (err) {
1460
+ <p class="upstream-caught">{(err as Error).message}</p>
1461
+ }
1462
+ }
1463
+
1464
+ component Inner() {
1465
+ try {
1466
+ let &[data] = trackAsync(() => user_promise);
1467
+ <p class="resolved">{data}</p>
1468
+ } pending {
1469
+ <p class="inner-pending">{'loading...'}</p>
1470
+ }
1471
+ }
1472
+
1473
+ render(App);
1474
+ await new Promise((resolve) => setTimeout(resolve, 0));
1475
+ flushSync();
1476
+
1477
+ // Inner should show nothing yet (no pending block)
1478
+ expect(container.querySelector('.resolved')).toBeNull();
1479
+ expect(container.querySelector('.upstream-caught')).toBeNull();
1480
+
1481
+ // Reject the promise
1482
+ reject_fn!(new Error('deferred no catch'));
1483
+ await new Promise((resolve) => setTimeout(resolve, 0));
1484
+ flushSync();
1485
+
1486
+ expect(container.querySelector('.upstream-caught')?.textContent).toBe('deferred no catch');
1487
+ },
1488
+ );
1489
+
1490
+ it('nested try blocks without catch propagate error through multiple levels', async () => {
1491
+ component ThrowingChild() {
1492
+ throw new Error('deep error');
1493
+ <p>{'should not render'}</p>
1494
+ }
1495
+
1496
+ component App() {
1497
+ try {
1498
+ <Middle />
1499
+ } catch (err) {
1500
+ <p class="top-caught">{(err as Error).message}</p>
1501
+ }
1502
+ }
1503
+
1504
+ component Middle() {
1505
+ try {
1506
+ <Inner />
1507
+ } pending {
1508
+ <p class="mid-pending">{'loading...'}</p>
1509
+ }
1510
+ }
1511
+
1512
+ component Inner() {
1513
+ try {
1514
+ <ThrowingChild />
1515
+ } pending {
1516
+ <p class="inner-pending">{'loading...'}</p>
1517
+ }
1518
+ }
1519
+
1520
+ render(App);
1521
+ await new Promise((resolve) => setTimeout(resolve, 0));
1522
+ flushSync();
1523
+
1524
+ expect(container.querySelector('.top-caught')?.textContent).toBe('deep error');
1525
+ });
1526
+
1527
+ it('try block without catch throws to global when no upstream boundary exists', async () => {
1528
+ component ThrowingChild() {
1529
+ throw new Error('unhandled error');
1530
+ <p>{'should not render'}</p>
1531
+ }
1532
+
1533
+ component App() {
1534
+ try {
1535
+ <ThrowingChild />
1536
+ } pending {
1537
+ <p class="pending">{'loading...'}</p>
1538
+ }
1539
+ }
1540
+
1541
+ expect(() => {
1542
+ render(App);
1543
+ }).toThrow('unhandled error');
1544
+ });
1545
+
1546
+ it(
1547
+ 'outer try block with pending and inner try with catch and without pending should propagate rejection to inner boundary',
1548
+ async () => {
1549
+ let reject_fn: (error: Error) => void;
1550
+ const user_promise = new Promise<string>((_, reject) => {
1551
+ reject_fn = reject;
1552
+ });
1553
+
1554
+ component Inner() {
1555
+ try {
1556
+ let &[data] = trackAsync(() => user_promise);
1557
+ <p class="resolved">{data}</p>
1558
+ } catch (err) {
1559
+ <p class="downstream-caught">{(err as Error).message}</p>
1560
+ }
1561
+ }
1562
+
1563
+ component App() {
1564
+ try {
1565
+ <Inner />
1566
+ } pending {
1567
+ <p class="outer-pending">{'loading...'}</p>
1568
+ }
1569
+ }
1570
+
1571
+ render(App);
1572
+ await new Promise((resolve) => setTimeout(resolve, 0));
1573
+ flushSync();
1574
+
1575
+ // Inner should show nothing yet (no pending block)
1576
+ expect(container.querySelector('.resolved')).toBeNull();
1577
+ expect(container.querySelector('.downstream-caught')).toBeNull();
1578
+ expect(container.querySelector('.outer-pending')?.textContent).toBe('loading...');
1579
+
1580
+ // Reject the promise
1581
+ reject_fn!(new Error('deferred no catch'));
1582
+ await new Promise((resolve) => setTimeout(resolve, 0));
1583
+ flushSync();
1584
+
1585
+ expect(container.querySelector('.resolved')).toBeNull();
1586
+ expect(container.querySelector('.downstream-caught')?.textContent).toBe('deferred no catch');
1587
+ expect(container.querySelector('.outer-pending')).toBeNull();
1588
+ },
1589
+ );
1590
+
1591
+ it(
1592
+ 'outer try block with pending and inner try with synchronous catch and without pending should propagate rejection to inner boundary with promise rejecting',
1593
+ async () => {
1594
+ let reject_fn: (error: Error) => void;
1595
+ let resolve_fn: (value: string) => void;
1596
+ const user_promise = new Promise<string>((resolve, reject) => {
1597
+ resolve_fn = resolve;
1598
+ reject_fn = reject;
1599
+ });
1600
+
1601
+ let user_settled: { type: string; value: any } | null = null;
1602
+ user_promise.then(
1603
+ (v) => (user_settled = { type: 'resolved', value: v }),
1604
+ (e) => (user_settled = { type: 'rejected', value: e }),
1605
+ );
1606
+
1607
+ component Inner() {
1608
+ try {
1609
+ let &[data] = trackAsync(() => user_promise);
1610
+ throw new Error('synchronous error');
1611
+ <p class="resolved">{data}</p>
1612
+ } catch (err) {
1613
+ <p class="downstream-caught">{(err as Error).message}</p>
1614
+ }
1615
+ }
1616
+
1617
+ component App() {
1618
+ try {
1619
+ <Inner />
1620
+ } pending {
1621
+ <p class="outer-pending">{'loading...'}</p>
1622
+ }
1623
+ }
1624
+
1625
+ render(App);
1626
+ await new Promise((resolve) => setTimeout(resolve, 0));
1627
+ flushSync();
1628
+
1629
+ // Inner should show nothing yet (no pending block)
1630
+ expect(container.querySelector('.resolved')).toBeNull();
1631
+ expect(container.querySelector('.outer-pending')).not.toBeNull();
1632
+ expect(container.querySelector('.downstream-caught')).toBeNull();
1633
+
1634
+ // Reject the promise
1635
+ reject_fn!(new Error('whatever'));
1636
+ await new Promise((resolve) => setTimeout(resolve, 0));
1637
+ flushSync();
1638
+
1639
+ expect(container.querySelector('.resolved')).toBeNull();
1640
+ expect(container.querySelector('.outer-pending')).toBeNull();
1641
+ expect(container.querySelector('.downstream-caught')?.textContent).toBe('synchronous error');
1642
+ expect(user_settled).toEqual({ type: 'rejected', value: expect.any(Error) });
1643
+ },
1644
+ );
1645
+
1646
+ it(
1647
+ 'outer try block with pending and inner try with synchronous catch and without pending should propagate rejection to inner boundary with promise resolving instead of rejecting',
1648
+ async () => {
1649
+ let reject_fn: (error: Error) => void;
1650
+ let resolve_fn: (value: string) => void;
1651
+ const user_promise = new Promise<string>((resolve, reject) => {
1652
+ resolve_fn = resolve;
1653
+ reject_fn = reject;
1654
+ });
1655
+
1656
+ let user_settled: { type: string; value: any } | null = null;
1657
+ user_promise.then(
1658
+ (v) => (user_settled = { type: 'resolved', value: v }),
1659
+ (e) => (user_settled = { type: 'rejected', value: e }),
1660
+ );
1661
+
1662
+ component Inner() {
1663
+ try {
1664
+ let &[data] = trackAsync(() => user_promise);
1665
+ throw new Error('synchronous error');
1666
+ <p class="resolved">{data}</p>
1667
+ } catch (err) {
1668
+ <p class="downstream-caught">{(err as Error).message}</p>
1669
+ }
1670
+ }
1671
+
1672
+ component App() {
1673
+ try {
1674
+ <Inner />
1675
+ } pending {
1676
+ <p class="outer-pending">{'loading...'}</p>
1677
+ }
1678
+ }
1679
+
1680
+ render(App);
1681
+ await new Promise((resolve) => setTimeout(resolve, 0));
1682
+ flushSync();
1683
+
1684
+ // Inner should show nothing yet (no pending block)
1685
+ expect(container.querySelector('.resolved')).toBeNull();
1686
+ expect(container.querySelector('.outer-pending')).not.toBeNull();
1687
+ expect(container.querySelector('.downstream-caught')).toBeNull();
1688
+
1689
+ // Resolve the promise
1690
+ resolve_fn!('hello');
1691
+ await new Promise((resolve) => setTimeout(resolve, 0));
1692
+ flushSync();
1693
+
1694
+ expect(container.querySelector('.resolved')).toBeNull();
1695
+ expect(container.querySelector('.outer-pending')).toBeNull();
1696
+ // this should render the catch block and make the resolved visible,
1697
+ // and the pending of the parent goes away
1698
+ expect(container.querySelector('.downstream-caught')?.textContent).toBe('synchronous error');
1699
+ expect(user_settled).toEqual({ type: 'resolved', value: 'hello' });
1700
+ },
1701
+ );
1702
+ });