acrylblends 0.6.2 → 0.6.3

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 (181) hide show
  1. package/README.md +4 -1
  2. package/docs/framework/README.md +3 -2
  3. package/docs/framework/UPSTREAM.md +1 -1
  4. package/docs/framework/cordis/acryl-in-a-cordis-app.md +6 -0
  5. package/docs/framework/getting-started/basic.md +1 -0
  6. package/docs/framework/getting-started/desktop.md +41 -0
  7. package/docs/framework/guides/jsx-dom.md +0 -3
  8. package/docs/framework/guides/jsx.md +0 -3
  9. package/docs/framework/helpers/css.md +0 -3
  10. package/docs/framework/upstream.json +3 -3
  11. package/lib/accepts.d.ts +1 -1
  12. package/lib/acryl-base.d.ts +1 -1
  13. package/lib/adapter.d.ts +1 -1
  14. package/lib/{app-BdrVHBlJ.d.ts → app-wFwpg-bt.d.ts} +1 -1
  15. package/lib/base-BpQy2Rk7.d.ts +783 -0
  16. package/lib/base-ddiVmiE7.js +721 -0
  17. package/lib/basic-auth.d.ts +1 -1
  18. package/lib/bearer-auth.d.ts +1 -1
  19. package/lib/body-limit.d.ts +1 -1
  20. package/lib/cache.d.ts +1 -1
  21. package/lib/client.d.ts +2 -2
  22. package/lib/combine.d.ts +1 -1
  23. package/lib/common-D8Z4JRCw.d.ts +46 -0
  24. package/lib/components-Ce5FRyHU.js +268 -0
  25. package/lib/components-DYZdxJIJ.js +24 -0
  26. package/lib/compress.d.ts +1 -1
  27. package/lib/conninfo.d.ts +2 -2
  28. package/lib/constants-BR9jM9BC.js +9 -0
  29. package/lib/{context-B5Y1lLha.d.ts → context-DVQBjuns.d.ts} +1 -1
  30. package/lib/context-DxFdY_un.d.ts +250 -0
  31. package/lib/context-storage.d.ts +1 -1
  32. package/lib/{cookie-D9_nQbsM.d.ts → cookie-CCelEqky.d.ts} +1 -1
  33. package/lib/cookie.d.ts +3 -3
  34. package/lib/cors.d.ts +1 -1
  35. package/lib/csrf.d.ts +1 -1
  36. package/lib/css-D2jb7X8b.js +320 -0
  37. package/lib/css.d.ts +82 -0
  38. package/lib/css.js +134 -0
  39. package/lib/dev.d.ts +2 -2
  40. package/lib/etag.d.ts +1 -1
  41. package/lib/factory.d.ts +2 -2
  42. package/lib/hooks-Bq5q9IMl.js +762 -0
  43. package/lib/html-SllAe0YT.d.ts +46 -0
  44. package/lib/html.d.ts +1 -1
  45. package/lib/index-C850MeBA.d.ts +94 -0
  46. package/lib/{index-m12zcmph.d.ts → index-CUTfU69R.d.ts} +3 -3
  47. package/lib/{index-DtEoak1i.d.ts → index-Cqcdluyn.d.ts} +2 -2
  48. package/lib/{index-Kqou8Fjh.d.ts → index-CsvB-F9j.d.ts} +2 -2
  49. package/lib/index.d.ts +4 -4
  50. package/lib/ip-restriction.d.ts +2 -2
  51. package/lib/jsx/dom/client.d.ts +30 -0
  52. package/lib/jsx/dom/client.js +49 -0
  53. package/lib/jsx/dom/css.d.ts +84 -0
  54. package/lib/jsx/dom/css.js +2 -0
  55. package/lib/jsx/dom/jsx-dev-runtime.d.ts +3 -0
  56. package/lib/jsx/dom/jsx-dev-runtime.js +2 -0
  57. package/lib/jsx/dom/jsx-runtime.d.ts +3 -0
  58. package/lib/jsx/dom/jsx-runtime.js +2 -0
  59. package/lib/jsx/dom/server.d.ts +105 -0
  60. package/lib/jsx/dom/server.js +37 -0
  61. package/lib/jsx/dom.d.ts +5 -0
  62. package/lib/jsx/dom.js +74 -0
  63. package/lib/jsx/jsx-dev-runtime.d.ts +6 -0
  64. package/lib/jsx/jsx-dev-runtime.js +14 -0
  65. package/lib/jsx/jsx-runtime.d.ts +10 -0
  66. package/lib/jsx/jsx-runtime.js +30 -0
  67. package/lib/jsx/streaming.d.ts +2 -0
  68. package/lib/jsx/streaming.js +2 -0
  69. package/lib/jsx-dev-runtime-B22O-Npk.d.ts +7 -0
  70. package/lib/jsx-dev-runtime-kAhR_4C7.js +354 -0
  71. package/lib/jsx-runtime-BVVgDSdq.d.ts +1 -0
  72. package/lib/jsx.d.ts +3 -0
  73. package/lib/jsx.js +47 -0
  74. package/lib/jwk.d.ts +4 -4
  75. package/lib/{jwt-BA65QhnJ.d.ts → jwt-BARhotDz.d.ts} +1 -1
  76. package/lib/jwt.d.ts +4 -4
  77. package/lib/language.d.ts +1 -1
  78. package/lib/logger.d.ts +1 -1
  79. package/lib/loopback-only.d.ts +1 -1
  80. package/lib/method-not-allowed.d.ts +2 -2
  81. package/lib/method-override.d.ts +2 -2
  82. package/lib/mount.d.ts +1 -1
  83. package/lib/node-server/conninfo.d.ts +1 -1
  84. package/lib/node-server/serve-static.d.ts +1 -1
  85. package/lib/node-server/serve-static.js +1 -1
  86. package/lib/node-server.d.ts +1 -1
  87. package/lib/node-server.js +1 -1
  88. package/lib/powered-by.d.ts +1 -1
  89. package/lib/pretty-json.d.ts +1 -1
  90. package/lib/proxy.d.ts +1 -1
  91. package/lib/quick.d.ts +1 -1
  92. package/lib/request-id.d.ts +1 -1
  93. package/lib/request.d.ts +1 -1
  94. package/lib/route.d.ts +1 -1
  95. package/lib/schema-validator.d.ts +1 -1
  96. package/lib/secure-headers.d.ts +1 -1
  97. package/lib/{serve-static-Dw_m6KxR.js → serve-static-Cw23shXv.js} +1 -0
  98. package/lib/serve-static.d.ts +1 -1
  99. package/lib/streaming.d.ts +1 -1
  100. package/lib/testing.d.ts +4 -4
  101. package/lib/timeout.d.ts +1 -1
  102. package/lib/timing.d.ts +1 -1
  103. package/lib/tiny.d.ts +1 -1
  104. package/lib/trailing-slash.d.ts +1 -1
  105. package/lib/{types-Bd29gh2o.d.ts → types-CQiGmsWW.d.ts} +3 -3
  106. package/lib/{types-D9DEXMjE.d.ts → types-Lqx5BxQF.d.ts} +8 -1
  107. package/lib/{types-ZM9gIiKh.d.ts → types-nEL8VPDC.d.ts} +1 -1
  108. package/lib/types.d.ts +1 -1
  109. package/lib/utils/body.d.ts +1 -1
  110. package/lib/utils/cookie.d.ts +1 -1
  111. package/lib/utils/crypto.d.ts +1 -1
  112. package/lib/utils/html.d.ts +1 -45
  113. package/lib/utils/ipaddr.d.ts +1 -1
  114. package/lib/utils/jwt.d.ts +1 -1
  115. package/lib/utils/types.d.ts +2 -2
  116. package/lib/validator.d.ts +2 -2
  117. package/lib/ws.d.ts +1 -1
  118. package/package.json +45 -1
  119. package/plugins/acryl-inspect/upstream.json +1 -1
  120. package/plugins/acryl-node-server/upstream.json +1 -1
  121. package/plugins/acryl-router/src/core/helper/css/common.case.test.tsx +506 -0
  122. package/plugins/acryl-router/src/core/helper/css/common.ts +337 -0
  123. package/plugins/acryl-router/src/core/helper/css/index.test.tsx +678 -0
  124. package/plugins/acryl-router/src/core/helper/css/index.ts +262 -0
  125. package/plugins/acryl-router/src/core/helper/streaming/sse.test.tsx +557 -0
  126. package/plugins/acryl-router/src/core/jsx/base.test.tsx +97 -0
  127. package/plugins/acryl-router/src/core/jsx/base.ts +490 -0
  128. package/plugins/acryl-router/src/core/jsx/children.test.ts +52 -0
  129. package/plugins/acryl-router/src/core/jsx/children.ts +20 -0
  130. package/plugins/acryl-router/src/core/jsx/components.test.tsx +936 -0
  131. package/plugins/acryl-router/src/core/jsx/components.ts +257 -0
  132. package/plugins/acryl-router/src/core/jsx/constants.ts +6 -0
  133. package/plugins/acryl-router/src/core/jsx/context-isolation.test.ts +655 -0
  134. package/plugins/acryl-router/src/core/jsx/context.ts +266 -0
  135. package/plugins/acryl-router/src/core/jsx/dom/client.test.tsx +128 -0
  136. package/plugins/acryl-router/src/core/jsx/dom/client.ts +89 -0
  137. package/plugins/acryl-router/src/core/jsx/dom/components.test.tsx +330 -0
  138. package/plugins/acryl-router/src/core/jsx/dom/components.ts +39 -0
  139. package/plugins/acryl-router/src/core/jsx/dom/context.test.tsx +184 -0
  140. package/plugins/acryl-router/src/core/jsx/dom/context.ts +52 -0
  141. package/plugins/acryl-router/src/core/jsx/dom/css.test.tsx +149 -0
  142. package/plugins/acryl-router/src/core/jsx/dom/css.ts +263 -0
  143. package/plugins/acryl-router/src/core/jsx/dom/hooks/index.test.tsx +172 -0
  144. package/plugins/acryl-router/src/core/jsx/dom/hooks/index.ts +91 -0
  145. package/plugins/acryl-router/src/core/jsx/dom/index.test.tsx +3681 -0
  146. package/plugins/acryl-router/src/core/jsx/dom/index.ts +170 -0
  147. package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.test.tsx +1344 -0
  148. package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.ts +438 -0
  149. package/plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.ts +23 -0
  150. package/plugins/acryl-router/src/core/jsx/dom/jsx-runtime.ts +8 -0
  151. package/plugins/acryl-router/src/core/jsx/dom/render.ts +879 -0
  152. package/plugins/acryl-router/src/core/jsx/dom/server.test.tsx +199 -0
  153. package/plugins/acryl-router/src/core/jsx/dom/server.ts +76 -0
  154. package/plugins/acryl-router/src/core/jsx/dom/utils.ts +7 -0
  155. package/plugins/acryl-router/src/core/jsx/hooks/dom.test.tsx +859 -0
  156. package/plugins/acryl-router/src/core/jsx/hooks/index.ts +437 -0
  157. package/plugins/acryl-router/src/core/jsx/hooks/string.test.tsx +40 -0
  158. package/plugins/acryl-router/src/core/jsx/index.test.tsx +1744 -0
  159. package/plugins/acryl-router/src/core/jsx/index.ts +115 -0
  160. package/plugins/acryl-router/src/core/jsx/intrinsic-element/common.ts +23 -0
  161. package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.test.tsx +666 -0
  162. package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.ts +210 -0
  163. package/plugins/acryl-router/src/core/jsx/intrinsic-elements.ts +938 -0
  164. package/plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts +26 -0
  165. package/plugins/acryl-router/src/core/jsx/jsx-runtime.test.tsx +85 -0
  166. package/plugins/acryl-router/src/core/jsx/jsx-runtime.ts +51 -0
  167. package/plugins/acryl-router/src/core/jsx/streaming.test.tsx +1281 -0
  168. package/plugins/acryl-router/src/core/jsx/streaming.ts +231 -0
  169. package/plugins/acryl-router/src/core/jsx/types.ts +41 -0
  170. package/plugins/acryl-router/src/core/jsx/utils.test.ts +366 -0
  171. package/plugins/acryl-router/src/core/jsx/utils.ts +202 -0
  172. package/plugins/acryl-router/src/core/utils/types.ts +6 -0
  173. package/plugins/acryl-router/src/helpers/index.ts +1 -0
  174. package/plugins/acryl-router/tests/jsx.spec.tsx +63 -0
  175. package/plugins/acryl-router/upstream.json +3 -3
  176. package/plugins/acryl-router-http/upstream.json +1 -1
  177. package/plugins/acryl-state/upstream.json +1 -1
  178. package/plugins/desktop/upstream.json +1 -1
  179. package/plugins/desktop-web/upstream.json +1 -1
  180. package/plugins/save/plugin.json +1 -1
  181. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,266 @@
1
+ import { raw } from '../helper/html/index.ts'
2
+ import type { HtmlEscapedString } from '../utils/html.ts'
3
+ import type { FC, PropsWithChildren } from './index.ts'
4
+ import { isUntrustedObject, JSXFragmentNode, renderChildren, renderUntrustedObject } from './base.ts'
5
+ import { DOM_RENDERER } from './constants.ts'
6
+ import { createContextProviderFunction } from './dom/context.ts'
7
+
8
+ export interface Context<T> extends FC<PropsWithChildren<{ value: T }>> {
9
+ values: T[]
10
+ Provider: FC<PropsWithChildren<{ value: T }>>
11
+ }
12
+
13
+ export const globalContexts: Context<unknown>[] = []
14
+
15
+ /** Per-render context store, isolated per request so values never leak across renders. */
16
+ type RenderStore = WeakMap<Context<unknown>, unknown[]>
17
+
18
+ type AsyncLocalStorageLike<T> = {
19
+ getStore(): T | undefined
20
+ run<R>(store: T, callback: () => R): R
21
+ }
22
+ type AsyncLocalStorageClass = new <T>() => AsyncLocalStorageLike<T>
23
+ type AsyncHooksModule = { AsyncLocalStorage?: AsyncLocalStorageClass }
24
+
25
+ let alsProbed = false
26
+ let asyncLocalStorage: AsyncLocalStorageLike<RenderStore> | undefined
27
+ let fallbackStore: RenderStore | undefined
28
+ // Unsettled async renders on the fallback path. While > 0, a store-less
29
+ // context access is a post-`await` read that degraded to the default value
30
+ // — worth a one-time warning. Stays 0 in the browser DOM renderer.
31
+ let fallbackRendersInFlight = 0
32
+ let warnedFallbackDefault = false
33
+
34
+ const loadAsyncLocalStorage = (): AsyncLocalStorageLike<RenderStore> | undefined => {
35
+ if (alsProbed) {
36
+ return asyncLocalStorage
37
+ }
38
+ alsProbed = true
39
+
40
+ const global = globalThis as unknown as {
41
+ process?: {
42
+ getBuiltinModule?: (specifier: string) => AsyncHooksModule
43
+ mainModule?: { require?: (specifier: string) => AsyncHooksModule }
44
+ }
45
+ }
46
+ let AsyncLocalStorage: AsyncLocalStorageClass | undefined
47
+ for (const probe of [
48
+ // Node.js >= 20.16, Deno, Bun, Cloudflare Workers (nodejs_compat). Property
49
+ // access only, so bundlers don't statically resolve `node:async_hooks`.
50
+ () => global.process?.getBuiltinModule?.('node:async_hooks')?.AsyncLocalStorage,
51
+ // Node.js < 20.16 has no `process.getBuiltinModule`, but a CJS entrypoint
52
+ // exposes the main module's `require` here.
53
+ () => global.process?.mainModule?.require?.('node:async_hooks')?.AsyncLocalStorage,
54
+ ]) {
55
+ try {
56
+ AsyncLocalStorage = probe()
57
+ } catch {}
58
+ if (AsyncLocalStorage) {
59
+ break
60
+ }
61
+ }
62
+
63
+ if (AsyncLocalStorage) {
64
+ asyncLocalStorage = new AsyncLocalStorage<RenderStore>()
65
+ }
66
+ return asyncLocalStorage
67
+ }
68
+
69
+ const getCurrentStore = (): RenderStore | undefined => {
70
+ return loadAsyncLocalStorage()?.getStore() || fallbackStore
71
+ }
72
+
73
+ const warnIfStorelessAccess = (): void => {
74
+ if (fallbackRendersInFlight > 0 && !warnedFallbackDefault) {
75
+ warnedFallbackDefault = true
76
+ console.warn(
77
+ 'acryl-router/jsx: AsyncLocalStorage is unavailable in this runtime, so useContext() after an await in an async component falls back to the context default value during server-side rendering. To get provided values across await boundaries, use a runtime with AsyncLocalStorage (Node.js >= 20.16, Deno, Bun, or Cloudflare Workers with the nodejs_compat flag).'
78
+ )
79
+ }
80
+ }
81
+
82
+ // Write path: materializes the store entry so a Provider's push/pop pair
83
+ // operates on a per-render array.
84
+ const getContextValuesIn = <T>(store: RenderStore | undefined, context: Context<T>): T[] => {
85
+ if (!store) {
86
+ warnIfStorelessAccess()
87
+ return context.values
88
+ }
89
+
90
+ let values = store.get(context as Context<unknown>) as T[] | undefined
91
+ if (!values) {
92
+ values = [context.values[0]]
93
+ store.set(context as Context<unknown>, values)
94
+ }
95
+ return values
96
+ }
97
+
98
+ // Read path: never materializes a store entry — a missing entry always
99
+ // resolves to the default value, so reads stay allocation-free.
100
+ const readContextValueIn = <T>(store: RenderStore | undefined, context: Context<T>): T => {
101
+ if (!store) {
102
+ warnIfStorelessAccess()
103
+ return context.values.at(-1) as T
104
+ }
105
+ const values = store.get(context as Context<unknown>) as T[] | undefined
106
+ return values?.length ? (values.at(-1) as T) : context.values[0]
107
+ }
108
+
109
+ type LocalContexts = [Context<unknown>, unknown][]
110
+
111
+ const captureContextValues = (store: RenderStore | undefined): LocalContexts =>
112
+ (store ? globalContexts.filter((c) => store.has(c)) : globalContexts).map((c) => [
113
+ c,
114
+ readContextValueIn(store, c),
115
+ ])
116
+
117
+ const resumeWithContextValues = <T>(
118
+ callback: () => T,
119
+ store: RenderStore | undefined,
120
+ contexts: LocalContexts
121
+ ): T =>
122
+ runWithRenderContext(() => {
123
+ // Resolve each target array once so every pop operates on the same array
124
+ // as its push, as the Provider in `createContext` does.
125
+ const currentStore = getCurrentStore()
126
+ const valuesPerContext = contexts.map(([context, value]) => {
127
+ const values = getContextValuesIn(currentStore, context)
128
+ values.push(value)
129
+ return values
130
+ })
131
+ const popContextValues = () => {
132
+ valuesPerContext.forEach((values) => {
133
+ values.pop()
134
+ })
135
+ }
136
+ try {
137
+ const result = callback()
138
+ if (result instanceof Promise) {
139
+ return result.finally(popContextValues) as T
140
+ }
141
+ popContextValues()
142
+ return result
143
+ } catch (e) {
144
+ popContextValues()
145
+ throw e
146
+ }
147
+ }, store)
148
+
149
+ /**
150
+ * Establish the request-scoped context store for a render.
151
+ *
152
+ * `resumeStore` continues a suspended subtree in the same store on the fallback
153
+ * path (ignored when `AsyncLocalStorage` is available, where isolation is
154
+ * automatic).
155
+ *
156
+ * Without `AsyncLocalStorage` a render can't be followed across `await`, so the
157
+ * store lives in `fallbackStore` only during synchronous work (mirroring
158
+ * React's request storage). Reading context after `await` then finds no store
159
+ * and falls back to the default value — never another request's value.
160
+ */
161
+ export const runWithRenderContext = <T>(callback: () => T, resumeStore?: RenderStore): T => {
162
+ if (getCurrentStore()) {
163
+ // Already inside a render (synchronously nested or resumed subtree).
164
+ return callback()
165
+ }
166
+
167
+ const store = resumeStore ?? new WeakMap()
168
+ const storage = loadAsyncLocalStorage()
169
+ if (storage) {
170
+ // AsyncLocalStorage path: request isolation propagates across `await`.
171
+ return storage.run(store, callback)
172
+ }
173
+
174
+ // Fallback path (no AsyncLocalStorage).
175
+ fallbackStore = store
176
+ let result: T
177
+ try {
178
+ result = callback()
179
+ } finally {
180
+ fallbackStore = undefined
181
+ }
182
+ if (!warnedFallbackDefault && result instanceof Promise) {
183
+ fallbackRendersInFlight++
184
+ result = result.finally(() => {
185
+ fallbackRendersInFlight--
186
+ }) as T
187
+ }
188
+ return result
189
+ }
190
+
191
+ /**
192
+ * Capture the current render store and return a resumer that re-establishes it
193
+ * around a deferred continuation (e.g. a re-render after a suspended promise
194
+ * settles). Shared by every suspension point so none reimplements it.
195
+ */
196
+ export const captureRenderContext = (): (<T>(callback: () => T) => T) => {
197
+ const store = getCurrentStore()
198
+ const contexts = captureContextValues(store)
199
+ return (callback) => resumeWithContextValues(callback, store, contexts)
200
+ }
201
+
202
+ /**
203
+ * Create a context whose value can be provided with `<Context.Provider>` and
204
+ * read with {@link useContext}.
205
+ *
206
+ * Server-side renders are isolated per request, so a provided value never leaks
207
+ * into a concurrent request — even across `await` in an async component, when
208
+ * `AsyncLocalStorage` is available (Node.js >= 20.16, Deno, Bun, Cloudflare
209
+ * Workers with `nodejs_compat`). Without it, reading context after `await`
210
+ * returns the default value; synchronous components and `use()`-based
211
+ * suspension are unaffected.
212
+ */
213
+ export const createContext = <T>(defaultValue: T): Context<T> => {
214
+ const values = [defaultValue]
215
+ const context: Context<T> = ((props): HtmlEscapedString | Promise<HtmlEscapedString> => {
216
+ // Resolve the target array once, synchronously: the pop in `.finally()`
217
+ // below may run later and must operate on the same array as the push.
218
+ const contextValues = getContextValuesIn(getCurrentStore(), context)
219
+ contextValues.push(props.value)
220
+ let rendered
221
+ try {
222
+ rendered =
223
+ typeof props.children === 'string'
224
+ ? renderChildren([props.children])
225
+ : isUntrustedObject(props.children)
226
+ ? renderUntrustedObject(props.children)
227
+ : props.children
228
+ ? (Array.isArray(props.children)
229
+ ? new JSXFragmentNode('', {}, props.children)
230
+ : props.children
231
+ ).toString()
232
+ : raw('')
233
+ } catch (e) {
234
+ contextValues.pop()
235
+ throw e
236
+ }
237
+
238
+ if (rendered instanceof Promise) {
239
+ return rendered
240
+ .finally(() => contextValues.pop())
241
+ .then((resString) => raw(resString, (resString as HtmlEscapedString).callbacks))
242
+ } else {
243
+ contextValues.pop()
244
+ return raw(rendered)
245
+ }
246
+ }) as Context<T>
247
+ context.values = values
248
+ context.Provider = context
249
+
250
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
251
+ ;(context as any)[DOM_RENDERER] = createContextProviderFunction(values)
252
+
253
+ globalContexts.push(context as Context<unknown>)
254
+
255
+ return context
256
+ }
257
+
258
+ /**
259
+ * Read the current value of a context created with {@link createContext}.
260
+ *
261
+ * Safe to call from async components after `await`. See {@link createContext}
262
+ * for the per-runtime isolation guarantees.
263
+ */
264
+ export const useContext = <T>(context: Context<T>): T => {
265
+ return readContextValueIn(getCurrentStore(), context)
266
+ }
@@ -0,0 +1,128 @@
1
+ /** @jsxImportSource ../ */
2
+ import { JSDOM } from 'jsdom'
3
+ import { useEffect } from './index.ts'
4
+ import DefaultExport, { createRoot, hydrateRoot } from './client.ts'
5
+
6
+ describe('createRoot', () => {
7
+ beforeAll(() => {
8
+ global.requestAnimationFrame = (cb) => setTimeout(cb)
9
+ })
10
+
11
+ let dom: JSDOM
12
+ let rootElement: HTMLElement
13
+ beforeEach(() => {
14
+ dom = new JSDOM('<html><body><div id="root"></div></body></html>', {
15
+ runScripts: 'dangerously',
16
+ })
17
+ global.document = dom.window.document
18
+ global.HTMLElement = dom.window.HTMLElement
19
+ global.SVGElement = dom.window.SVGElement
20
+ global.Text = dom.window.Text
21
+ rootElement = document.getElementById('root') as HTMLElement
22
+ })
23
+
24
+ it('render / unmount', async () => {
25
+ const cleanup = vi.fn()
26
+ const App = () => {
27
+ useEffect(() => cleanup, [])
28
+ return <h1>Hello</h1>
29
+ }
30
+ const root = createRoot(rootElement)
31
+ root.render(<App />)
32
+ expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
33
+ await new Promise((resolve) => setTimeout(resolve))
34
+ root.unmount()
35
+ await Promise.resolve()
36
+ expect(rootElement.innerHTML).toBe('')
37
+ expect(cleanup).toHaveBeenCalled()
38
+ })
39
+
40
+ it('call render twice', async () => {
41
+ const App = <h1>Hello</h1>
42
+ const App2 = <h1>World</h1>
43
+ const root = createRoot(rootElement)
44
+ root.render(App)
45
+ expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
46
+
47
+ const createElementSpy = vi.spyOn(dom.window.document, 'createElement')
48
+
49
+ root.render(App2)
50
+ await Promise.resolve()
51
+ expect(rootElement.innerHTML).toBe('<h1>World</h1>')
52
+
53
+ expect(createElementSpy).not.toHaveBeenCalled()
54
+ })
55
+
56
+ it('call render after unmount', async () => {
57
+ const App = <h1>Hello</h1>
58
+ const App2 = <h1>World</h1>
59
+ const root = createRoot(rootElement)
60
+ root.render(App)
61
+ expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
62
+ root.unmount()
63
+ expect(() => root.render(App2)).toThrow('Cannot update an unmounted root')
64
+ })
65
+ })
66
+
67
+ describe('hydrateRoot', () => {
68
+ let dom: JSDOM
69
+ let rootElement: HTMLElement
70
+ beforeEach(() => {
71
+ dom = new JSDOM('<html><body><div id="root"></div></body></html>', {
72
+ runScripts: 'dangerously',
73
+ })
74
+ global.document = dom.window.document
75
+ global.HTMLElement = dom.window.HTMLElement
76
+ global.SVGElement = dom.window.SVGElement
77
+ global.Text = dom.window.Text
78
+ rootElement = document.getElementById('root') as HTMLElement
79
+ })
80
+
81
+ it('should return root object', async () => {
82
+ const cleanup = vi.fn()
83
+ const App = () => {
84
+ useEffect(() => cleanup, [])
85
+ return <h1>Hello</h1>
86
+ }
87
+ const root = hydrateRoot(rootElement, <App />)
88
+ expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
89
+ await new Promise((resolve) => setTimeout(resolve))
90
+ root.unmount()
91
+ await Promise.resolve()
92
+ expect(rootElement.innerHTML).toBe('')
93
+ expect(cleanup).toHaveBeenCalled()
94
+ })
95
+
96
+ it('call render', async () => {
97
+ const App = <h1>Hello</h1>
98
+ const App2 = <h1>World</h1>
99
+ const root = hydrateRoot(rootElement, App)
100
+ expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
101
+
102
+ const createElementSpy = vi.spyOn(dom.window.document, 'createElement')
103
+
104
+ root.render(App2)
105
+ await Promise.resolve()
106
+ expect(rootElement.innerHTML).toBe('<h1>World</h1>')
107
+
108
+ expect(createElementSpy).not.toHaveBeenCalled()
109
+ })
110
+
111
+ it('call render after unmount', async () => {
112
+ const App = <h1>Hello</h1>
113
+ const App2 = <h1>World</h1>
114
+ const root = hydrateRoot(rootElement, App)
115
+ expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
116
+ root.unmount()
117
+ expect(() => root.render(App2)).toThrow('Cannot update an unmounted root')
118
+ })
119
+ })
120
+
121
+ describe('default export', () => {
122
+ ;['createRoot', 'hydrateRoot'].forEach((key) => {
123
+ it(key, () => {
124
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
+ expect((DefaultExport as any)[key]).toBeDefined()
126
+ })
127
+ })
128
+ })
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @module
3
+ * This module provides APIs for `acryl-router/jsx/dom/client`, which is compatible with `react-dom/client`.
4
+ */
5
+
6
+ import type { Child } from '../base.ts'
7
+ import { useState } from '../hooks/index.ts'
8
+ import { buildNode, renderNode } from './render.ts'
9
+ import type { NodeObject } from './render.ts'
10
+
11
+ export interface Root {
12
+ render(children: Child): void
13
+ unmount(): void
14
+ }
15
+ export type RootOptions = Record<string, unknown>
16
+
17
+ /**
18
+ * Create a root object for rendering
19
+ * @param element Render target
20
+ * @param options Options for createRoot (not supported yet)
21
+ * @returns Root object has `render` and `unmount` methods
22
+ */
23
+ export const createRoot = (
24
+ element: HTMLElement | DocumentFragment,
25
+ options: RootOptions = {}
26
+ ): Root => {
27
+ let setJsxNode:
28
+ | undefined // initial state
29
+ | ((jsxNode: unknown) => void) // rendered
30
+ | null = // unmounted
31
+ undefined
32
+
33
+ if (Object.keys(options).length > 0) {
34
+ console.warn('createRoot options are not supported yet')
35
+ }
36
+
37
+ return {
38
+ render(jsxNode: unknown) {
39
+ if (setJsxNode === null) {
40
+ // unmounted
41
+ throw new Error('Cannot update an unmounted root')
42
+ }
43
+ if (setJsxNode) {
44
+ // rendered
45
+ setJsxNode(jsxNode)
46
+ } else {
47
+ renderNode(
48
+ buildNode({
49
+ tag: () => {
50
+ const [_jsxNode, _setJsxNode] = useState(jsxNode)
51
+ setJsxNode = _setJsxNode
52
+ return _jsxNode
53
+ },
54
+ props: {},
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ } as any) as NodeObject,
57
+ element
58
+ )
59
+ }
60
+ },
61
+ unmount() {
62
+ setJsxNode?.(null)
63
+ setJsxNode = null
64
+ },
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Create a root object and hydrate app to the target element.
70
+ * In acryl-router/jsx/dom, hydrate is equivalent to render.
71
+ * @param element Render target
72
+ * @param reactNode A JSXNode to render
73
+ * @param options Options for createRoot (not supported yet)
74
+ * @returns Root object has `render` and `unmount` methods
75
+ */
76
+ export const hydrateRoot = (
77
+ element: HTMLElement | DocumentFragment,
78
+ reactNode: Child,
79
+ options: RootOptions = {}
80
+ ): Root => {
81
+ const root = createRoot(element, options)
82
+ root.render(reactNode)
83
+ return root
84
+ }
85
+
86
+ export default {
87
+ createRoot,
88
+ hydrateRoot,
89
+ }