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,662 @@
1
+ import type { Tracked } from 'ripple';
2
+ import { DERIVED_UPDATED, effect, flushSync, track, trackAsync } from 'ripple';
3
+
4
+ describe('async suspense', () => {
5
+ it('hides child content during re-suspension when tracked dependency changes', async () => {
6
+ let resolve_fn: (() => void) | null = null;
7
+
8
+ component Child({ countTracked }: { countTracked: Tracked<number> }) {
9
+ trackAsync(() => {
10
+ countTracked.value;
11
+ return new Promise<void>((resolve) => {
12
+ resolve_fn = resolve;
13
+ });
14
+ });
15
+
16
+ <div class="child-content">{'child content'}</div>
17
+ <div class="count">
18
+ {'count is: '}
19
+ {countTracked.value}
20
+ </div>
21
+ }
22
+
23
+ component App() {
24
+ let &[count, countTracked] = track(0);
25
+
26
+ try {
27
+ <Child {countTracked} />
28
+ } pending {
29
+ <div class="pending">{'pending...'}</div>
30
+ }
31
+
32
+ <button onClick={() => count++}>{'Increment'}</button>
33
+ }
34
+
35
+ render(App);
36
+
37
+ // Initial state: should show pending
38
+ await new Promise((resolve) => setTimeout(resolve, 0));
39
+ flushSync();
40
+ expect(container.innerHTML).toContain('pending...');
41
+ expect(container.innerHTML).not.toContain('child content');
42
+
43
+ // Resolve the first promise
44
+ (resolve_fn as () => void)?.();
45
+ await new Promise((resolve) => setTimeout(resolve, 0));
46
+ flushSync();
47
+
48
+ // After resolution: should show child content, not pending
49
+ expect(container.innerHTML).toContain('child content');
50
+ expect(container.innerHTML).not.toContain('pending...');
51
+
52
+ // Changing count should keep the ui in the same state
53
+ const button = container.querySelector('button');
54
+ button?.click();
55
+ flushSync();
56
+ expect(container.innerHTML).toContain('count is: 1');
57
+
58
+ // Wait for microtask to process
59
+ await new Promise((resolve) => setTimeout(resolve, 0));
60
+ flushSync();
61
+
62
+ // After count change: should still show child content, not pending
63
+ expect(container.innerHTML).not.toContain('pending...');
64
+ expect(container.innerHTML).toContain('child content');
65
+ expect(container.innerHTML).toContain('count is: 1');
66
+ });
67
+
68
+ it('ignores settled promises after the surrounding boundary is destroyed', async () => {
69
+ let resolve_value: ((value: string) => void) | null = null;
70
+
71
+ component Child() {
72
+ let &[value] = trackAsync(
73
+ () => new Promise<string>((resolve) => {
74
+ resolve_value = resolve;
75
+ }),
76
+ );
77
+
78
+ <div class="value">{value}</div>
79
+ }
80
+
81
+ component App() {
82
+ let &[show] = track(true);
83
+
84
+ <button onClick={() => (show = false)}>{'Hide'}</button>
85
+
86
+ if (show) {
87
+ try {
88
+ <Child />
89
+ } pending {
90
+ <div class="pending">{'loading...'}</div>
91
+ }
92
+ } else {
93
+ <div class="hidden">{'hidden'}</div>
94
+ }
95
+ }
96
+
97
+ render(App);
98
+
99
+ await new Promise((resolve) => setTimeout(resolve, 0));
100
+ flushSync();
101
+ expect(container.innerHTML).toContain('loading...');
102
+
103
+ (container.querySelector('button') as HTMLButtonElement).click();
104
+ flushSync();
105
+ await new Promise((resolve) => setTimeout(resolve, 0));
106
+ flushSync();
107
+
108
+ expect(container.innerHTML).toContain('hidden');
109
+
110
+ (resolve_value as (value: string) => void)('late value');
111
+ await new Promise((resolve) => setTimeout(resolve, 0));
112
+ flushSync();
113
+
114
+ expect(container.innerHTML).toContain('hidden');
115
+ expect(container.innerHTML).not.toContain('late value');
116
+ });
117
+
118
+ it('aborts superseded requests with DERIVED_UPDATED without rendering catch', async () => {
119
+ const requests = new Map<
120
+ string,
121
+ { resolve: (value: string) => void; abortController: AbortController }
122
+ >();
123
+
124
+ function createRequest(label: string) {
125
+ const abortController = new AbortController();
126
+ let resolve_value: (value: string) => void = () => {};
127
+
128
+ const promise = new Promise<string>((resolve, reject) => {
129
+ resolve_value = resolve;
130
+ abortController.signal.addEventListener('abort', () => {
131
+ reject(abortController.signal.reason);
132
+ });
133
+ });
134
+
135
+ requests.set(label, { resolve: resolve_value, abortController });
136
+
137
+ return { promise, abortController };
138
+ }
139
+
140
+ component App() {
141
+ let &[query] = track('a');
142
+
143
+ try {
144
+ let &[value] = trackAsync(() => createRequest(query));
145
+ <div class="value">{value}</div>
146
+ } pending {
147
+ <div class="pending">{'loading...'}</div>
148
+ } catch (error) {
149
+ <div class="error">{String(error)}</div>
150
+ }
151
+
152
+ <button
153
+ onClick={() => {
154
+ query = query === 'a' ? 'b' : 'c';
155
+ }}
156
+ >
157
+ {'Next'}
158
+ </button>
159
+ }
160
+
161
+ render(App);
162
+
163
+ await new Promise((resolve) => setTimeout(resolve, 0));
164
+ flushSync();
165
+ expect(container.innerHTML).toContain('loading...');
166
+
167
+ (requests.get('a') as { resolve: (value: string) => void }).resolve('value-a');
168
+ await new Promise((resolve) => setTimeout(resolve, 0));
169
+ flushSync();
170
+
171
+ expect(container.innerHTML).toContain('value-a');
172
+
173
+ const button = container.querySelector('button') as HTMLButtonElement;
174
+ button.click();
175
+ flushSync();
176
+ button.click();
177
+ flushSync();
178
+
179
+ await new Promise((resolve) => setTimeout(resolve, 0));
180
+ flushSync();
181
+
182
+ expect(
183
+ (requests.get('b') as { abortController: AbortController }).abortController.signal.reason,
184
+ ).toBe(DERIVED_UPDATED);
185
+ expect(container.innerHTML).not.toContain('class="error"');
186
+ expect(container.innerHTML).toContain('value-a');
187
+
188
+ (requests.get('c') as { resolve: (value: string) => void }).resolve('value-c');
189
+ await new Promise((resolve) => setTimeout(resolve, 0));
190
+ flushSync();
191
+
192
+ expect(container.innerHTML).toContain('value-c');
193
+ expect(container.innerHTML).not.toContain('class="error"');
194
+ });
195
+
196
+ it(
197
+ 'updates the existing reactive graph when tracked dependency triggers re-fetch in child component',
198
+ async () => {
199
+ let pending_render_count = 0;
200
+
201
+ component FilteredList({ queryTracked }: { queryTracked: Tracked<string> }) {
202
+ let &[query] = queryTracked;
203
+ let &[items] = trackAsync(
204
+ () => Promise.resolve(
205
+ !query ? ['apple', 'banana', 'cherry'] : ['avocado', 'blueberry', 'citrus'],
206
+ ),
207
+ );
208
+
209
+ let &[filtered] = track(() => {
210
+ return items.filter((item: string) => item.includes(query));
211
+ });
212
+
213
+ <ul>
214
+ for (let item of filtered) {
215
+ <li>{item}</li>
216
+ }
217
+ </ul>
218
+
219
+ <pre>{JSON.stringify(items)}</pre>
220
+ }
221
+
222
+ component App() {
223
+ let &[query, queryTracked] = track('');
224
+
225
+ try {
226
+ <FilteredList {queryTracked} />
227
+ } pending {
228
+ pending_render_count += 1;
229
+ <p class="pending">{'loading...'}</p>
230
+ }
231
+
232
+ <button onClick={() => (query = 'a')}>{'Search'}</button>
233
+ }
234
+
235
+ render(App);
236
+
237
+ // Promise.resolve settles in microtask — wait for it
238
+ await new Promise((resolve) => setTimeout(resolve, 0));
239
+ flushSync();
240
+ expect(pending_render_count).toBe(1);
241
+
242
+ // Initial: filtered by '' matches apple, banana (both contain '')
243
+ expect(container.querySelectorAll('li').length).toBe(3);
244
+ expect(container.innerHTML).toContain('apple');
245
+ expect(container.innerHTML).toContain('banana');
246
+ expect(container.innerHTML).toContain('cherry');
247
+ expect(container.querySelectorAll('ul').length).toBe(1);
248
+ expect(container.querySelectorAll('pre').length).toBe(1);
249
+ expect(container.querySelector('pre')!.textContent).toBe('["apple","banana","cherry"]');
250
+
251
+ // Change query to 'a' — triggers re-fetch with new items
252
+ (container.querySelector('button') as HTMLButtonElement).click();
253
+ flushSync();
254
+ await new Promise((resolve) => setTimeout(resolve, 0));
255
+ flushSync();
256
+
257
+ // The resolved branch should update in place without appending duplicates.
258
+ expect(pending_render_count).toBe(1);
259
+ expect(container.innerHTML).not.toContain('loading...');
260
+ expect(container.querySelectorAll('ul').length).toBe(1);
261
+ expect(container.querySelectorAll('pre').length).toBe(1);
262
+ // After re-fetch, the branch re-runs with the new data.
263
+ expect(container.innerHTML).toContain('avocado');
264
+ expect(container.innerHTML).toContain('blueberry');
265
+ expect(container.innerHTML).toContain('citrus');
266
+ expect(container.querySelector('pre')!.textContent).toBe('["avocado","blueberry","citrus"]');
267
+ expect(container.innerHTML).not.toContain('apple');
268
+ expect(container.innerHTML).not.toContain('banana');
269
+ expect(container.innerHTML).not.toContain('cherry');
270
+ },
271
+ );
272
+
273
+ it('updates direct try-block trackAsync consumers and renders pending only once', async () => {
274
+ let pending_render_count = 0;
275
+
276
+ component App() {
277
+ let &[query] = track('');
278
+
279
+ try {
280
+ let &[items] = trackAsync(
281
+ () => Promise.resolve(
282
+ !query ? ['apple', 'banana', 'cherry'] : ['avocado', 'blueberry', 'citrus'],
283
+ ),
284
+ );
285
+
286
+ let &[filtered] = track(() => {
287
+ return items.filter((item: string) => item.includes(query));
288
+ });
289
+
290
+ <ul>
291
+ for (let item of filtered) {
292
+ <li>{item}</li>
293
+ }
294
+ </ul>
295
+
296
+ <pre>{JSON.stringify(items)}</pre>
297
+ } pending {
298
+ pending_render_count += 1;
299
+ <p class="pending">{'loading...'}</p>
300
+ }
301
+
302
+ <button onClick={() => (query = 'a')}>{'Search'}</button>
303
+ }
304
+
305
+ render(App);
306
+
307
+ await new Promise((resolve) => setTimeout(resolve, 0));
308
+ flushSync();
309
+
310
+ expect(pending_render_count).toBe(1);
311
+ expect(container.querySelectorAll('li').length).toBe(3);
312
+ expect(container.querySelector('pre')!.textContent).toBe('["apple","banana","cherry"]');
313
+
314
+ (container.querySelector('button') as HTMLButtonElement).click();
315
+ flushSync();
316
+ await new Promise((resolve) => setTimeout(resolve, 0));
317
+ flushSync();
318
+
319
+ expect(pending_render_count).toBe(1);
320
+ expect(container.innerHTML).not.toContain('loading...');
321
+ expect(container.querySelectorAll('ul').length).toBe(1);
322
+ expect(container.querySelectorAll('pre').length).toBe(1);
323
+ // After re-fetch, the branch re-runs with the new data.
324
+ expect(container.innerHTML).toContain('avocado');
325
+ expect(container.innerHTML).toContain('blueberry');
326
+ expect(container.innerHTML).toContain('citrus');
327
+ expect(container.innerHTML).not.toContain('apple');
328
+ expect(container.innerHTML).not.toContain('banana');
329
+ expect(container.innerHTML).not.toContain('cherry');
330
+ expect(container.querySelector('pre')!.textContent).toBe('["avocado","blueberry","citrus"]');
331
+ });
332
+
333
+ it(
334
+ 'defers paused async consumer blocks until chained async requests settle with render only running once',
335
+ async () => {
336
+ let resolve_double: ((value: number) => void) | null = null;
337
+ let resolve_quadruple: ((value: number) => void) | null = null;
338
+ let double_effect_runs = 0;
339
+ let quadruple_effect_runs = 0;
340
+ let times_rendered = 0;
341
+ component Child(&{ count }: { count: number }) {
342
+ const &[double]: [number] = trackAsync(() => {
343
+ const result = count * 2;
344
+
345
+ return new Promise<number>((resolve) => {
346
+ resolve_double = () => resolve(result);
347
+ });
348
+ });
349
+
350
+ const &[quadruple]: [number] = trackAsync(() => {
351
+ const result = double * 2;
352
+
353
+ return new Promise<number>((resolve) => {
354
+ resolve_quadruple = () => resolve(result);
355
+ });
356
+ });
357
+
358
+ effect(() => {
359
+ double;
360
+ double_effect_runs += 1;
361
+ });
362
+
363
+ effect(() => {
364
+ quadruple;
365
+ quadruple_effect_runs += 1;
366
+ });
367
+
368
+ // this is to make the times_rendered render together with double
369
+ <div class="double">{double + (++times_rendered - times_rendered)}</div>
370
+ <div class="quadruple">{quadruple}</div>
371
+ }
372
+
373
+ component App() {
374
+ let &[count] = track(2);
375
+
376
+ try {
377
+ <Child {count} />
378
+ } pending {
379
+ <div class="pending">{'Loading...'}</div>
380
+ }
381
+
382
+ <button onClick={() => count++}>{'Increment'}</button>
383
+ }
384
+
385
+ render(App);
386
+
387
+ const button = container.querySelector('button') as HTMLButtonElement;
388
+
389
+ await new Promise((resolve) => setTimeout(resolve, 0));
390
+ flushSync();
391
+
392
+ expect(container.innerHTML).toContain('Loading...');
393
+ expect(double_effect_runs).toBe(0);
394
+ expect(quadruple_effect_runs).toBe(0);
395
+
396
+ (resolve_double as (value: number) => void)(4);
397
+ await new Promise((resolve) => setTimeout(resolve, 0));
398
+ flushSync();
399
+
400
+ expect(container.innerHTML).toContain('Loading...');
401
+ expect(double_effect_runs).toBe(0);
402
+ expect(quadruple_effect_runs).toBe(0);
403
+
404
+ (resolve_quadruple as (value: number) => void)(8);
405
+ await new Promise((resolve) => setTimeout(resolve, 0));
406
+ flushSync();
407
+
408
+ expect(container.innerHTML).not.toContain('Loading...');
409
+ expect(container.querySelector('.double')!.textContent).toBe('4');
410
+ expect(container.querySelector('.quadruple')!.textContent).toBe('8');
411
+ expect(double_effect_runs).toBe(1);
412
+ expect(quadruple_effect_runs).toBe(1);
413
+ expect(times_rendered).toBe(1);
414
+
415
+ button.click();
416
+ flushSync();
417
+
418
+ (resolve_double as (value: number) => void)(6);
419
+ await new Promise((resolve) => setTimeout(resolve, 0));
420
+ (resolve_quadruple as (value: number) => void)(12);
421
+ await new Promise((resolve) => setTimeout(resolve, 0));
422
+ expect(container.querySelector('.double')!.textContent).toBe('6');
423
+ expect(container.querySelector('.quadruple')!.textContent).toBe('12');
424
+ expect(times_rendered).toBe(2);
425
+ },
426
+ );
427
+
428
+ it(
429
+ 'chained async requests settle with render only running once when multiple consecutive requests are fired off',
430
+ async () => {
431
+ let resolve_double: ((value: number) => void) | null = null;
432
+ let resolve_quadruple: ((value: number) => void) | null = null;
433
+ let double_effect_runs = 0;
434
+ let quadruple_effect_runs = 0;
435
+ let times_rendered = 0;
436
+ component Child(&{ count }: { count: number }) {
437
+ const &[double]: [number] = trackAsync(() => {
438
+ const result = count * 2;
439
+
440
+ return new Promise<number>((resolve) => {
441
+ resolve_double = () => resolve(result);
442
+ });
443
+ });
444
+
445
+ const &[quadruple]: [number] = trackAsync(() => {
446
+ const result = double * 2;
447
+
448
+ return new Promise<number>((resolve) => {
449
+ resolve_quadruple = () => resolve(result);
450
+ });
451
+ });
452
+
453
+ effect(() => {
454
+ double;
455
+ double_effect_runs += 1;
456
+ });
457
+
458
+ effect(() => {
459
+ quadruple;
460
+ quadruple_effect_runs += 1;
461
+ });
462
+
463
+ // this is to make the times_rendered render together with double
464
+ <div class="double">{double + (++times_rendered - times_rendered)}</div>
465
+ <div class="quadruple">{quadruple}</div>
466
+ }
467
+
468
+ component App() {
469
+ let &[count] = track(2);
470
+
471
+ try {
472
+ <Child {count} />
473
+ } pending {
474
+ <div class="pending">{'Loading...'}</div>
475
+ }
476
+
477
+ <button onClick={() => count++}>{'Increment'}</button>
478
+ }
479
+
480
+ render(App);
481
+
482
+ const button = container.querySelector('button') as HTMLButtonElement;
483
+
484
+ await new Promise((resolve) => setTimeout(resolve, 0));
485
+ flushSync();
486
+
487
+ expect(container.innerHTML).toContain('Loading...');
488
+ expect(double_effect_runs).toBe(0);
489
+ expect(quadruple_effect_runs).toBe(0);
490
+
491
+ (resolve_double as (value: number) => void)(4);
492
+ await new Promise((resolve) => setTimeout(resolve, 0));
493
+ flushSync();
494
+
495
+ expect(container.innerHTML).toContain('Loading...');
496
+ expect(double_effect_runs).toBe(0);
497
+ expect(quadruple_effect_runs).toBe(0);
498
+
499
+ (resolve_quadruple as (value: number) => void)(8);
500
+ await new Promise((resolve) => setTimeout(resolve, 0));
501
+ flushSync();
502
+
503
+ expect(container.innerHTML).not.toContain('Loading...');
504
+ expect(container.querySelector('.double')!.textContent).toBe('4');
505
+ expect(container.querySelector('.quadruple')!.textContent).toBe('8');
506
+ expect(double_effect_runs).toBe(1);
507
+ expect(quadruple_effect_runs).toBe(1);
508
+ expect(times_rendered).toBe(1);
509
+
510
+ button.click();
511
+ flushSync();
512
+ button.click();
513
+ flushSync();
514
+ button.click();
515
+ flushSync();
516
+
517
+ (resolve_double as (value: number) => void)(10);
518
+ await new Promise((resolve) => setTimeout(resolve, 0));
519
+ (resolve_quadruple as (value: number) => void)(12);
520
+ await new Promise((resolve) => setTimeout(resolve, 0));
521
+ expect(container.querySelector('.double')!.textContent).toBe('10');
522
+ expect(container.querySelector('.quadruple')!.textContent).toBe('20');
523
+ expect(times_rendered).toBe(2);
524
+ },
525
+ );
526
+
527
+ it('resolves chained async values without explicit flushSync', async () => {
528
+ let times_rendered = 0;
529
+ component Child(&{ count }: { count: number }) {
530
+ const &[double]: [number] = trackAsync(() => {
531
+ const result = count * 2;
532
+
533
+ return new Promise<number>((resolve) => {
534
+ setTimeout(() => resolve(result), 0);
535
+ });
536
+ });
537
+
538
+ const &[quadruple]: [number] = trackAsync(() => {
539
+ const result = double * 2;
540
+
541
+ return new Promise<number>((resolve) => {
542
+ setTimeout(() => resolve(result), 0);
543
+ });
544
+ });
545
+
546
+ // this is to make the times_rendered render together with double
547
+ <div class="double">{double + (++times_rendered - times_rendered)}</div>
548
+ <div class="quadruple">{quadruple}</div>
549
+ }
550
+
551
+ component App() {
552
+ let &[count] = track(0);
553
+
554
+ <button onClick={() => count++}>{count}</button>
555
+
556
+ try {
557
+ <Child {count} />
558
+ } pending {
559
+ <div class="pending">{'Loading...'}</div>
560
+ }
561
+ }
562
+
563
+ render(App);
564
+
565
+ await new Promise((resolve) => setTimeout(resolve, 10));
566
+
567
+ expect(container.innerHTML).not.toContain('Loading...');
568
+ expect(container.querySelector('.double')!.textContent).toBe('0');
569
+ expect(container.querySelector('.quadruple')!.textContent).toBe('0');
570
+ expect(times_rendered).toBe(1);
571
+ });
572
+
573
+ it(
574
+ 'registers async derived under multiple try/pending boundaries across components',
575
+ async () => {
576
+ let resolve_fn: ((value: string[]) => void) | null = null;
577
+
578
+ component Child({ itemsTracked }: { itemsTracked: Tracked<string[]> }) {
579
+ try {
580
+ <div class="child-content">
581
+ {'child: '}
582
+ {itemsTracked.value.join(', ')}
583
+ </div>
584
+ } pending {
585
+ <div class="child-pending">{'child loading...'}</div>
586
+ }
587
+ }
588
+
589
+ component App() {
590
+ let &[query] = track('initial');
591
+
592
+ try {
593
+ let &[items, itemsTracked] = trackAsync(() => {
594
+ const q = query;
595
+ return new Promise<string[]>((resolve) => {
596
+ resolve_fn = resolve;
597
+ });
598
+ });
599
+
600
+ <div class="parent-content">
601
+ {'parent: '}
602
+ {items.join(', ')}
603
+ </div>
604
+ <Child {itemsTracked} />
605
+ } pending {
606
+ <div class="parent-pending">{'parent loading...'}</div>
607
+ }
608
+
609
+ <button onClick={() => (query = 'next')}>{'Change'}</button>
610
+ }
611
+
612
+ render(App);
613
+
614
+ // Initial: parent pending, child not yet rendered
615
+ await new Promise((resolve) => setTimeout(resolve, 0));
616
+ expect(container.innerHTML).toContain('parent loading...');
617
+ expect(container.innerHTML).not.toContain('parent-content');
618
+ expect(container.innerHTML).not.toContain('child-content');
619
+
620
+ // Resolve first request — both parent and child should show content,
621
+ // child reading the derived registers its own try boundary on the same derived
622
+ (resolve_fn as (value: string[]) => void)(['apple', 'banana']);
623
+ await new Promise((resolve) => setTimeout(resolve, 0));
624
+
625
+ expect(container.innerHTML).toContain('parent: apple, banana');
626
+ expect(container.innerHTML).toContain('child: apple, banana');
627
+ expect(container.innerHTML).not.toContain('parent loading...');
628
+ expect(container.innerHTML).not.toContain('child loading...');
629
+
630
+ // Trigger re-fetch — derived now has entries for both parent and child boundaries
631
+ const button = container.querySelector('button') as HTMLButtonElement;
632
+ button.click();
633
+ flushSync();
634
+ await new Promise((resolve) => setTimeout(resolve, 0));
635
+
636
+ // After has_resolved, re-fetch keeps resolved branch visible (no re-suspension)
637
+ expect(container.innerHTML).not.toContain('parent loading...');
638
+ expect(container.innerHTML).not.toContain('child loading...');
639
+
640
+ // Resolve second request — both should update in place
641
+ (resolve_fn as (value: string[]) => void)(['cherry', 'date']);
642
+ await new Promise((resolve) => setTimeout(resolve, 0));
643
+ flushSync();
644
+
645
+ expect(container.innerHTML).toContain('parent: cherry, date');
646
+ expect(container.innerHTML).toContain('child: cherry, date');
647
+ expect(container.innerHTML).not.toContain('parent loading...');
648
+ expect(container.innerHTML).not.toContain('child loading...');
649
+ },
650
+ );
651
+
652
+ it('throws when trackAsync is used without a try/pending boundary', () => {
653
+ component App() {
654
+ let &[value] = trackAsync(() => Promise.resolve('test'));
655
+ <div>{value}</div>
656
+ }
657
+
658
+ expect(() => {
659
+ render(App);
660
+ }).toThrow('Missing parent `try { ... } pending { ... }` statement');
661
+ });
662
+ });