ripple 0.3.12 → 0.3.14

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 (217) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/package.json +11 -30
  3. package/src/compiler/types/import.d.ts +0 -12
  4. package/src/helpers.d.ts +2 -0
  5. package/src/runtime/array.js +38 -38
  6. package/src/runtime/create-subscriber.js +2 -2
  7. package/src/runtime/index-client.js +15 -13
  8. package/src/runtime/index-server.js +18 -11
  9. package/src/runtime/internal/client/bindings.js +4 -6
  10. package/src/runtime/internal/client/blocks.js +19 -23
  11. package/src/runtime/internal/client/constants.js +20 -9
  12. package/src/runtime/internal/client/events.js +8 -3
  13. package/src/runtime/internal/client/hmr.js +5 -17
  14. package/src/runtime/internal/client/index.js +14 -4
  15. package/src/runtime/internal/client/runtime.js +436 -173
  16. package/src/runtime/internal/client/try.js +334 -156
  17. package/src/runtime/internal/client/types.d.ts +26 -0
  18. package/src/runtime/internal/server/blocks.js +181 -0
  19. package/src/runtime/internal/server/constants.js +7 -0
  20. package/src/runtime/internal/server/index.js +774 -150
  21. package/src/runtime/internal/server/types.d.ts +35 -0
  22. package/src/runtime/media-query.js +34 -33
  23. package/src/runtime/object.js +7 -10
  24. package/src/runtime/proxy.js +2 -3
  25. package/src/runtime/reactive-value.js +23 -21
  26. package/src/server/index.js +1 -1
  27. package/src/utils/ast.js +1 -1
  28. package/src/utils/async.js +35 -0
  29. package/src/utils/attributes.js +43 -0
  30. package/src/utils/builders.js +5 -3
  31. package/tests/client/__snapshots__/computed-properties.test.rsrx.snap +49 -0
  32. package/tests/client/__snapshots__/for.test.rsrx.snap +319 -0
  33. package/tests/client/__snapshots__/html.test.rsrx.snap +40 -0
  34. package/tests/client/_etc.test.rsrx +7 -0
  35. package/tests/client/array/{array.static.test.ripple → array.static.test.rsrx} +18 -20
  36. package/tests/client/async-suspend.test.rsrx +662 -0
  37. package/tests/client/basic/__snapshots__/basic.attributes.test.rsrx.snap +60 -0
  38. package/tests/client/basic/__snapshots__/basic.rendering.test.rsrx.snap +59 -0
  39. package/tests/client/basic/{basic.errors.test.ripple → basic.errors.test.rsrx} +3 -3
  40. package/tests/client/basic/{basic.styling.test.ripple → basic.styling.test.rsrx} +1 -1
  41. package/tests/client/compiler/__snapshots__/compiler.assignments.test.rsrx.snap +12 -0
  42. package/tests/client/compiler/__snapshots__/compiler.typescript.test.rsrx.snap +46 -0
  43. package/tests/client/compiler/{compiler.assignments.test.ripple → compiler.assignments.test.rsrx} +1 -1
  44. package/tests/client/compiler/{compiler.attributes.test.ripple → compiler.attributes.test.rsrx} +1 -1
  45. package/tests/client/compiler/{compiler.basic.test.ripple → compiler.basic.test.rsrx} +13 -13
  46. package/tests/client/compiler/{compiler.tracked-access.test.ripple → compiler.tracked-access.test.rsrx} +1 -1
  47. package/tests/client/compiler/{compiler.try-in-function.test.ripple → compiler.try-in-function.test.rsrx} +9 -7
  48. package/tests/client/compiler/{compiler.typescript.test.ripple → compiler.typescript.test.rsrx} +1 -1
  49. package/tests/client/composite/__snapshots__/composite.render.test.rsrx.snap +37 -0
  50. package/tests/client/css/{global-additional-cases.test.ripple → global-additional-cases.test.rsrx} +1 -1
  51. package/tests/client/css/{global-advanced-selectors.test.ripple → global-advanced-selectors.test.rsrx} +1 -1
  52. package/tests/client/css/{global-at-rules.test.ripple → global-at-rules.test.rsrx} +1 -1
  53. package/tests/client/css/{global-basic.test.ripple → global-basic.test.rsrx} +1 -1
  54. package/tests/client/css/{global-classes-ids.test.ripple → global-classes-ids.test.rsrx} +1 -1
  55. package/tests/client/css/{global-combinators.test.ripple → global-combinators.test.rsrx} +1 -1
  56. package/tests/client/css/{global-complex-nesting.test.ripple → global-complex-nesting.test.rsrx} +1 -1
  57. package/tests/client/css/{global-edge-cases.test.ripple → global-edge-cases.test.rsrx} +1 -1
  58. package/tests/client/css/{global-keyframes.test.ripple → global-keyframes.test.rsrx} +1 -1
  59. package/tests/client/css/{global-nested.test.ripple → global-nested.test.rsrx} +1 -1
  60. package/tests/client/css/{global-pseudo.test.ripple → global-pseudo.test.rsrx} +1 -1
  61. package/tests/client/css/{global-scoping.test.ripple → global-scoping.test.rsrx} +1 -1
  62. package/tests/client/css/{style-identifier.test.ripple → style-identifier.test.rsrx} +1 -1
  63. package/tests/client/{function-overload.test.ripple → function-overload.test.rsrx} +1 -1
  64. package/tests/client/{return.test.ripple → return.test.rsrx} +1 -1
  65. package/tests/client/try.test.rsrx +1702 -0
  66. package/tests/hydration/build-components.js +6 -4
  67. package/tests/hydration/compiled/client/head.js +11 -11
  68. package/tests/hydration/compiled/client/mixed-control-flow.js +55 -70
  69. package/tests/hydration/compiled/client/nested-control-flow.js +72 -88
  70. package/tests/hydration/compiled/client/try.js +42 -54
  71. package/tests/hydration/compiled/server/basic.js +491 -369
  72. package/tests/hydration/compiled/server/composite.js +153 -128
  73. package/tests/hydration/compiled/server/events.js +166 -145
  74. package/tests/hydration/compiled/server/for.js +821 -677
  75. package/tests/hydration/compiled/server/head.js +200 -165
  76. package/tests/hydration/compiled/server/hmr.js +62 -54
  77. package/tests/hydration/compiled/server/html-in-template.js +64 -55
  78. package/tests/hydration/compiled/server/html.js +1477 -1360
  79. package/tests/hydration/compiled/server/if-children.js +448 -408
  80. package/tests/hydration/compiled/server/if.js +204 -171
  81. package/tests/hydration/compiled/server/mixed-control-flow.js +237 -195
  82. package/tests/hydration/compiled/server/nested-control-flow.js +533 -467
  83. package/tests/hydration/compiled/server/portal.js +94 -107
  84. package/tests/hydration/compiled/server/reactivity.js +87 -64
  85. package/tests/hydration/compiled/server/return.js +1424 -1174
  86. package/tests/hydration/compiled/server/switch.js +268 -238
  87. package/tests/hydration/compiled/server/try.js +98 -87
  88. package/tests/hydration/components/{mixed-control-flow.ripple → mixed-control-flow.rsrx} +2 -2
  89. package/tests/hydration/components/{try.ripple → try.rsrx} +4 -2
  90. package/tests/hydration/mixed-control-flow.test.js +14 -0
  91. package/tests/hydration/nested-control-flow.test.js +50 -48
  92. package/tests/hydration/try.test.js +25 -0
  93. package/tests/server/__snapshots__/compiler.test.ripple.snap +0 -32
  94. package/tests/server/__snapshots__/compiler.test.rsrx.snap +95 -0
  95. package/tests/server/{compiler.test.ripple → compiler.test.rsrx} +0 -17
  96. package/tests/server/{html-nesting-validation.test.ripple → html-nesting-validation.test.rsrx} +3 -3
  97. package/tests/server/streaming-ssr.test.rsrx +115 -0
  98. package/tests/server/{style-identifier.test.ripple → style-identifier.test.rsrx} +1 -1
  99. package/tests/server/try.test.rsrx +503 -0
  100. package/tests/setup-server.js +1 -1
  101. package/tests/utils/compiler-compat-config.test.js +4 -4
  102. package/tests/utils/vite-plugin-config.test.js +1 -1
  103. package/tests/utils/vite-plugin-hmr.test.js +5 -5
  104. package/tsconfig.json +2 -0
  105. package/types/index.d.ts +13 -23
  106. package/types/server.d.ts +43 -16
  107. package/src/compiler/comment-utils.js +0 -91
  108. package/src/compiler/errors.js +0 -77
  109. package/src/compiler/identifier-utils.js +0 -80
  110. package/src/compiler/index.d.ts +0 -127
  111. package/src/compiler/index.js +0 -89
  112. package/src/compiler/phases/1-parse/index.js +0 -2964
  113. package/src/compiler/phases/1-parse/style.js +0 -704
  114. package/src/compiler/phases/2-analyze/css-analyze.js +0 -160
  115. package/src/compiler/phases/2-analyze/index.js +0 -2238
  116. package/src/compiler/phases/2-analyze/prune.js +0 -1131
  117. package/src/compiler/phases/2-analyze/validation.js +0 -168
  118. package/src/compiler/phases/3-transform/client/index.js +0 -5301
  119. package/src/compiler/phases/3-transform/segments.js +0 -2129
  120. package/src/compiler/phases/3-transform/server/index.js +0 -1899
  121. package/src/compiler/phases/3-transform/stylesheet.js +0 -545
  122. package/src/compiler/scope.js +0 -476
  123. package/src/compiler/source-map-utils.js +0 -358
  124. package/src/compiler/types/acorn.d.ts +0 -11
  125. package/src/compiler/types/estree-jsx.d.ts +0 -11
  126. package/src/compiler/types/estree.d.ts +0 -11
  127. package/src/compiler/types/index.d.ts +0 -1404
  128. package/src/compiler/types/parse.d.ts +0 -1721
  129. package/src/compiler/utils.js +0 -1263
  130. package/tests/client/_etc.test.ripple +0 -5
  131. package/tests/client/async-suspend.test.ripple +0 -94
  132. package/tests/client/try.test.ripple +0 -196
  133. package/tests/server/streaming-ssr.test.ripple +0 -68
  134. package/tests/server/try.test.ripple +0 -82
  135. /package/tests/client/array/{array.copy-within.test.ripple → array.copy-within.test.rsrx} +0 -0
  136. /package/tests/client/array/{array.derived.test.ripple → array.derived.test.rsrx} +0 -0
  137. /package/tests/client/array/{array.iteration.test.ripple → array.iteration.test.rsrx} +0 -0
  138. /package/tests/client/array/{array.mutations.test.ripple → array.mutations.test.rsrx} +0 -0
  139. /package/tests/client/array/{array.to-methods.test.ripple → array.to-methods.test.rsrx} +0 -0
  140. /package/tests/client/basic/{basic.attributes.test.ripple → basic.attributes.test.rsrx} +0 -0
  141. /package/tests/client/basic/{basic.collections.test.ripple → basic.collections.test.rsrx} +0 -0
  142. /package/tests/client/basic/{basic.components.test.ripple → basic.components.test.rsrx} +0 -0
  143. /package/tests/client/basic/{basic.events.test.ripple → basic.events.test.rsrx} +0 -0
  144. /package/tests/client/basic/{basic.get-set.test.ripple → basic.get-set.test.rsrx} +0 -0
  145. /package/tests/client/basic/{basic.hmr.test.ripple → basic.hmr.test.rsrx} +0 -0
  146. /package/tests/client/basic/{basic.reactivity.test.ripple → basic.reactivity.test.rsrx} +0 -0
  147. /package/tests/client/basic/{basic.rendering.test.ripple → basic.rendering.test.rsrx} +0 -0
  148. /package/tests/client/basic/{basic.utilities.test.ripple → basic.utilities.test.rsrx} +0 -0
  149. /package/tests/client/{boundaries.test.ripple → boundaries.test.rsrx} +0 -0
  150. /package/tests/client/compiler/{compiler.regex.test.ripple → compiler.regex.test.rsrx} +0 -0
  151. /package/tests/client/composite/{composite.dynamic-components.test.ripple → composite.dynamic-components.test.rsrx} +0 -0
  152. /package/tests/client/composite/{composite.generics.test.ripple → composite.generics.test.rsrx} +0 -0
  153. /package/tests/client/composite/{composite.props.test.ripple → composite.props.test.rsrx} +0 -0
  154. /package/tests/client/composite/{composite.reactivity.test.ripple → composite.reactivity.test.rsrx} +0 -0
  155. /package/tests/client/composite/{composite.render.test.ripple → composite.render.test.rsrx} +0 -0
  156. /package/tests/client/{computed-properties.test.ripple → computed-properties.test.rsrx} +0 -0
  157. /package/tests/client/{context.test.ripple → context.test.rsrx} +0 -0
  158. /package/tests/client/{date.test.ripple → date.test.rsrx} +0 -0
  159. /package/tests/client/{dynamic-elements.test.ripple → dynamic-elements.test.rsrx} +0 -0
  160. /package/tests/client/{events.test.ripple → events.test.rsrx} +0 -0
  161. /package/tests/client/{for.test.ripple → for.test.rsrx} +0 -0
  162. /package/tests/client/{function-overload-import.ripple → function-overload-import.rsrx} +0 -0
  163. /package/tests/client/{head.test.ripple → head.test.rsrx} +0 -0
  164. /package/tests/client/{html.test.ripple → html.test.rsrx} +0 -0
  165. /package/tests/client/{input-value.test.ripple → input-value.test.rsrx} +0 -0
  166. /package/tests/client/{lazy-destructuring.test.ripple → lazy-destructuring.test.rsrx} +0 -0
  167. /package/tests/client/{map.test.ripple → map.test.rsrx} +0 -0
  168. /package/tests/client/{media-query.test.ripple → media-query.test.rsrx} +0 -0
  169. /package/tests/client/{object.test.ripple → object.test.rsrx} +0 -0
  170. /package/tests/client/{portal.test.ripple → portal.test.rsrx} +0 -0
  171. /package/tests/client/{ref.test.ripple → ref.test.rsrx} +0 -0
  172. /package/tests/client/{set.test.ripple → set.test.rsrx} +0 -0
  173. /package/tests/client/{svg.test.ripple → svg.test.rsrx} +0 -0
  174. /package/tests/client/{switch.test.ripple → switch.test.rsrx} +0 -0
  175. /package/tests/client/{tsx.test.ripple → tsx.test.rsrx} +0 -0
  176. /package/tests/client/{typescript-generics.test.ripple → typescript-generics.test.rsrx} +0 -0
  177. /package/tests/client/url/{url.derived.test.ripple → url.derived.test.rsrx} +0 -0
  178. /package/tests/client/url/{url.parsing.test.ripple → url.parsing.test.rsrx} +0 -0
  179. /package/tests/client/url/{url.partial-removal.test.ripple → url.partial-removal.test.rsrx} +0 -0
  180. /package/tests/client/url/{url.reactivity.test.ripple → url.reactivity.test.rsrx} +0 -0
  181. /package/tests/client/url/{url.serialization.test.ripple → url.serialization.test.rsrx} +0 -0
  182. /package/tests/client/url-search-params/{url-search-params.derived.test.ripple → url-search-params.derived.test.rsrx} +0 -0
  183. /package/tests/client/url-search-params/{url-search-params.initialization.test.ripple → url-search-params.initialization.test.rsrx} +0 -0
  184. /package/tests/client/url-search-params/{url-search-params.iteration.test.ripple → url-search-params.iteration.test.rsrx} +0 -0
  185. /package/tests/client/url-search-params/{url-search-params.mutation.test.ripple → url-search-params.mutation.test.rsrx} +0 -0
  186. /package/tests/client/url-search-params/{url-search-params.retrieval.test.ripple → url-search-params.retrieval.test.rsrx} +0 -0
  187. /package/tests/client/url-search-params/{url-search-params.serialization.test.ripple → url-search-params.serialization.test.rsrx} +0 -0
  188. /package/tests/client/url-search-params/{url-search-params.tracked-url.test.ripple → url-search-params.tracked-url.test.rsrx} +0 -0
  189. /package/tests/hydration/components/{basic.ripple → basic.rsrx} +0 -0
  190. /package/tests/hydration/components/{composite.ripple → composite.rsrx} +0 -0
  191. /package/tests/hydration/components/{events.ripple → events.rsrx} +0 -0
  192. /package/tests/hydration/components/{for.ripple → for.rsrx} +0 -0
  193. /package/tests/hydration/components/{head.ripple → head.rsrx} +0 -0
  194. /package/tests/hydration/components/{hmr.ripple → hmr.rsrx} +0 -0
  195. /package/tests/hydration/components/{html-in-template.ripple → html-in-template.rsrx} +0 -0
  196. /package/tests/hydration/components/{html.ripple → html.rsrx} +0 -0
  197. /package/tests/hydration/components/{if-children.ripple → if-children.rsrx} +0 -0
  198. /package/tests/hydration/components/{if.ripple → if.rsrx} +0 -0
  199. /package/tests/hydration/components/{nested-control-flow.ripple → nested-control-flow.rsrx} +0 -0
  200. /package/tests/hydration/components/{portal.ripple → portal.rsrx} +0 -0
  201. /package/tests/hydration/components/{reactivity.ripple → reactivity.rsrx} +0 -0
  202. /package/tests/hydration/components/{return.ripple → return.rsrx} +0 -0
  203. /package/tests/hydration/components/{switch.ripple → switch.rsrx} +0 -0
  204. /package/tests/server/{await.test.ripple → await.test.rsrx} +0 -0
  205. /package/tests/server/{basic.attributes.test.ripple → basic.attributes.test.rsrx} +0 -0
  206. /package/tests/server/{basic.components.test.ripple → basic.components.test.rsrx} +0 -0
  207. /package/tests/server/{basic.test.ripple → basic.test.rsrx} +0 -0
  208. /package/tests/server/{composite.props.test.ripple → composite.props.test.rsrx} +0 -0
  209. /package/tests/server/{composite.test.ripple → composite.test.rsrx} +0 -0
  210. /package/tests/server/{context.test.ripple → context.test.rsrx} +0 -0
  211. /package/tests/server/{dynamic-elements.test.ripple → dynamic-elements.test.rsrx} +0 -0
  212. /package/tests/server/{for.test.ripple → for.test.rsrx} +0 -0
  213. /package/tests/server/{head.test.ripple → head.test.rsrx} +0 -0
  214. /package/tests/server/{if.test.ripple → if.test.rsrx} +0 -0
  215. /package/tests/server/{lazy-destructuring.test.ripple → lazy-destructuring.test.rsrx} +0 -0
  216. /package/tests/server/{return.test.ripple → return.test.rsrx} +0 -0
  217. /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
+ });