@furystack/shades 11.0.35 → 12.0.0

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 (215) hide show
  1. package/CHANGELOG.md +337 -0
  2. package/README.md +99 -13
  3. package/esm/compile-route.spec.d.ts +2 -0
  4. package/esm/compile-route.spec.d.ts.map +1 -0
  5. package/esm/compile-route.spec.js +34 -0
  6. package/esm/compile-route.spec.js.map +1 -0
  7. package/esm/component-factory.spec.js +13 -5
  8. package/esm/component-factory.spec.js.map +1 -1
  9. package/esm/components/index.d.ts +4 -1
  10. package/esm/components/index.d.ts.map +1 -1
  11. package/esm/components/index.js +4 -1
  12. package/esm/components/index.js.map +1 -1
  13. package/esm/components/lazy-load.d.ts +2 -4
  14. package/esm/components/lazy-load.d.ts.map +1 -1
  15. package/esm/components/lazy-load.js +40 -24
  16. package/esm/components/lazy-load.js.map +1 -1
  17. package/esm/components/lazy-load.spec.js +57 -50
  18. package/esm/components/lazy-load.spec.js.map +1 -1
  19. package/esm/components/link-to-route.d.ts +2 -0
  20. package/esm/components/link-to-route.d.ts.map +1 -1
  21. package/esm/components/link-to-route.js +3 -2
  22. package/esm/components/link-to-route.js.map +1 -1
  23. package/esm/components/link-to-route.spec.js +13 -9
  24. package/esm/components/link-to-route.spec.js.map +1 -1
  25. package/esm/components/nested-route-link.d.ts +62 -0
  26. package/esm/components/nested-route-link.d.ts.map +1 -0
  27. package/esm/components/nested-route-link.js +66 -0
  28. package/esm/components/nested-route-link.js.map +1 -0
  29. package/esm/components/nested-route-link.spec.d.ts +2 -0
  30. package/esm/components/nested-route-link.spec.d.ts.map +1 -0
  31. package/esm/components/nested-route-link.spec.js +179 -0
  32. package/esm/components/nested-route-link.spec.js.map +1 -0
  33. package/esm/components/nested-route-types.d.ts +37 -0
  34. package/esm/components/nested-route-types.d.ts.map +1 -0
  35. package/esm/components/nested-route-types.js +2 -0
  36. package/esm/components/nested-route-types.js.map +1 -0
  37. package/esm/components/nested-router.d.ts +103 -0
  38. package/esm/components/nested-router.d.ts.map +1 -0
  39. package/esm/components/nested-router.js +178 -0
  40. package/esm/components/nested-router.js.map +1 -0
  41. package/esm/components/nested-router.spec.d.ts +2 -0
  42. package/esm/components/nested-router.spec.d.ts.map +1 -0
  43. package/esm/components/nested-router.spec.js +659 -0
  44. package/esm/components/nested-router.spec.js.map +1 -0
  45. package/esm/components/route-link.d.ts +4 -0
  46. package/esm/components/route-link.d.ts.map +1 -1
  47. package/esm/components/route-link.js +9 -10
  48. package/esm/components/route-link.js.map +1 -1
  49. package/esm/components/route-link.spec.js +16 -12
  50. package/esm/components/route-link.spec.js.map +1 -1
  51. package/esm/components/router.d.ts +20 -2
  52. package/esm/components/router.d.ts.map +1 -1
  53. package/esm/components/router.js +3 -0
  54. package/esm/components/router.js.map +1 -1
  55. package/esm/components/router.spec.js +75 -74
  56. package/esm/components/router.spec.js.map +1 -1
  57. package/esm/css-generator.d.ts +50 -0
  58. package/esm/css-generator.d.ts.map +1 -0
  59. package/esm/css-generator.js +107 -0
  60. package/esm/css-generator.js.map +1 -0
  61. package/esm/css-generator.spec.d.ts +2 -0
  62. package/esm/css-generator.spec.d.ts.map +1 -0
  63. package/esm/css-generator.spec.js +162 -0
  64. package/esm/css-generator.spec.js.map +1 -0
  65. package/esm/index.d.ts +2 -0
  66. package/esm/index.d.ts.map +1 -1
  67. package/esm/index.js +2 -0
  68. package/esm/index.js.map +1 -1
  69. package/esm/initialize.d.ts +11 -0
  70. package/esm/initialize.d.ts.map +1 -1
  71. package/esm/initialize.js +5 -0
  72. package/esm/initialize.js.map +1 -1
  73. package/esm/jsx.d.ts +83 -2
  74. package/esm/jsx.d.ts.map +1 -1
  75. package/esm/models/children-list.d.ts +5 -1
  76. package/esm/models/children-list.d.ts.map +1 -1
  77. package/esm/models/css-object.d.ts +33 -0
  78. package/esm/models/css-object.d.ts.map +1 -0
  79. package/esm/models/css-object.js +2 -0
  80. package/esm/models/css-object.js.map +1 -0
  81. package/esm/models/index.d.ts +1 -0
  82. package/esm/models/index.d.ts.map +1 -1
  83. package/esm/models/index.js +1 -0
  84. package/esm/models/index.js.map +1 -1
  85. package/esm/models/partial-element.d.ts +12 -2
  86. package/esm/models/partial-element.d.ts.map +1 -1
  87. package/esm/models/render-options.d.ts +89 -3
  88. package/esm/models/render-options.d.ts.map +1 -1
  89. package/esm/models/selection-state.d.ts +4 -0
  90. package/esm/models/selection-state.d.ts.map +1 -1
  91. package/esm/services/location-service.d.ts +11 -0
  92. package/esm/services/location-service.d.ts.map +1 -1
  93. package/esm/services/location-service.js +11 -0
  94. package/esm/services/location-service.js.map +1 -1
  95. package/esm/services/resource-manager.d.ts +24 -0
  96. package/esm/services/resource-manager.d.ts.map +1 -1
  97. package/esm/services/resource-manager.js +30 -0
  98. package/esm/services/resource-manager.js.map +1 -1
  99. package/esm/services/resource-manager.spec.js +93 -0
  100. package/esm/services/resource-manager.spec.js.map +1 -1
  101. package/esm/services/screen-service.d.ts +81 -4
  102. package/esm/services/screen-service.d.ts.map +1 -1
  103. package/esm/services/screen-service.js +75 -4
  104. package/esm/services/screen-service.js.map +1 -1
  105. package/esm/services/screen-service.spec.js +91 -7
  106. package/esm/services/screen-service.spec.js.map +1 -1
  107. package/esm/shade-component.d.ts +17 -4
  108. package/esm/shade-component.d.ts.map +1 -1
  109. package/esm/shade-component.js +67 -5
  110. package/esm/shade-component.js.map +1 -1
  111. package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
  112. package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
  113. package/esm/shade-host-props-ref.integration.spec.js +381 -0
  114. package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
  115. package/esm/shade-resources.integration.spec.js +208 -39
  116. package/esm/shade-resources.integration.spec.js.map +1 -1
  117. package/esm/shade.d.ts +34 -15
  118. package/esm/shade.d.ts.map +1 -1
  119. package/esm/shade.js +180 -33
  120. package/esm/shade.js.map +1 -1
  121. package/esm/shade.spec.d.ts +2 -0
  122. package/esm/shade.spec.d.ts.map +1 -0
  123. package/esm/shade.spec.js +198 -0
  124. package/esm/shade.spec.js.map +1 -0
  125. package/esm/shades.integration.spec.js +135 -72
  126. package/esm/shades.integration.spec.js.map +1 -1
  127. package/esm/style-manager.d.ts +65 -0
  128. package/esm/style-manager.d.ts.map +1 -0
  129. package/esm/style-manager.js +95 -0
  130. package/esm/style-manager.js.map +1 -0
  131. package/esm/style-manager.spec.d.ts +2 -0
  132. package/esm/style-manager.spec.d.ts.map +1 -0
  133. package/esm/style-manager.spec.js +179 -0
  134. package/esm/style-manager.spec.js.map +1 -0
  135. package/esm/styled-element.spec.d.ts +2 -0
  136. package/esm/styled-element.spec.d.ts.map +1 -0
  137. package/esm/styled-element.spec.js +86 -0
  138. package/esm/styled-element.spec.js.map +1 -0
  139. package/esm/styled-shade.spec.d.ts +2 -0
  140. package/esm/styled-shade.spec.d.ts.map +1 -0
  141. package/esm/styled-shade.spec.js +66 -0
  142. package/esm/styled-shade.spec.js.map +1 -0
  143. package/esm/svg-types.d.ts +389 -0
  144. package/esm/svg-types.d.ts.map +1 -0
  145. package/esm/svg-types.js +9 -0
  146. package/esm/svg-types.js.map +1 -0
  147. package/esm/svg.d.ts +15 -0
  148. package/esm/svg.d.ts.map +1 -0
  149. package/esm/svg.js +76 -0
  150. package/esm/svg.js.map +1 -0
  151. package/esm/svg.spec.d.ts +2 -0
  152. package/esm/svg.spec.d.ts.map +1 -0
  153. package/esm/svg.spec.js +80 -0
  154. package/esm/svg.spec.js.map +1 -0
  155. package/esm/vnode.d.ts +103 -0
  156. package/esm/vnode.d.ts.map +1 -0
  157. package/esm/vnode.integration.spec.d.ts +2 -0
  158. package/esm/vnode.integration.spec.d.ts.map +1 -0
  159. package/esm/vnode.integration.spec.js +494 -0
  160. package/esm/vnode.integration.spec.js.map +1 -0
  161. package/esm/vnode.js +453 -0
  162. package/esm/vnode.js.map +1 -0
  163. package/esm/vnode.spec.d.ts +2 -0
  164. package/esm/vnode.spec.d.ts.map +1 -0
  165. package/esm/vnode.spec.js +473 -0
  166. package/esm/vnode.spec.js.map +1 -0
  167. package/package.json +3 -3
  168. package/src/compile-route.spec.ts +39 -0
  169. package/src/component-factory.spec.tsx +18 -5
  170. package/src/components/index.ts +4 -1
  171. package/src/components/lazy-load.spec.tsx +82 -75
  172. package/src/components/lazy-load.tsx +49 -27
  173. package/src/components/link-to-route.spec.tsx +25 -21
  174. package/src/components/link-to-route.tsx +4 -2
  175. package/src/components/nested-route-link.spec.tsx +303 -0
  176. package/src/components/nested-route-link.tsx +100 -0
  177. package/src/components/nested-route-types.ts +42 -0
  178. package/src/components/nested-router.spec.tsx +817 -0
  179. package/src/components/nested-router.tsx +256 -0
  180. package/src/components/route-link.spec.tsx +22 -18
  181. package/src/components/route-link.tsx +10 -10
  182. package/src/components/router.spec.tsx +109 -108
  183. package/src/components/router.tsx +15 -2
  184. package/src/css-generator.spec.ts +183 -0
  185. package/src/css-generator.ts +117 -0
  186. package/src/index.ts +2 -0
  187. package/src/initialize.ts +12 -0
  188. package/src/jsx.ts +129 -2
  189. package/src/models/children-list.ts +7 -1
  190. package/src/models/css-object.ts +34 -0
  191. package/src/models/index.ts +1 -0
  192. package/src/models/partial-element.ts +13 -2
  193. package/src/models/render-options.ts +90 -3
  194. package/src/models/selection-state.ts +4 -0
  195. package/src/services/location-service.tsx +11 -0
  196. package/src/services/resource-manager.spec.ts +116 -0
  197. package/src/services/resource-manager.ts +30 -0
  198. package/src/services/screen-service.spec.ts +109 -7
  199. package/src/services/screen-service.ts +81 -4
  200. package/src/shade-component.ts +72 -6
  201. package/src/shade-host-props-ref.integration.spec.tsx +460 -0
  202. package/src/shade-resources.integration.spec.tsx +276 -52
  203. package/src/shade.spec.tsx +239 -0
  204. package/src/shade.ts +211 -56
  205. package/src/shades.integration.spec.tsx +154 -80
  206. package/src/style-manager.spec.ts +229 -0
  207. package/src/style-manager.ts +104 -0
  208. package/src/styled-element.spec.tsx +117 -0
  209. package/src/styled-shade.spec.ts +86 -0
  210. package/src/svg-types.ts +437 -0
  211. package/src/svg.spec.ts +89 -0
  212. package/src/svg.ts +78 -0
  213. package/src/vnode.integration.spec.tsx +657 -0
  214. package/src/vnode.spec.ts +579 -0
  215. package/src/vnode.ts +508 -0
@@ -1,9 +1,9 @@
1
1
  import { Injector } from '@furystack/inject'
2
- import { ObservableValue, sleepAsync } from '@furystack/utils'
2
+ import { ObservableValue, sleepAsync, usingAsync } from '@furystack/utils'
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  import { initializeShadeRoot } from './initialize.js'
5
5
  import { createComponent } from './shade-component.js'
6
- import { Shade } from './shade.js'
6
+ import { flushUpdates, Shade } from './shade.js'
7
7
 
8
8
  describe('Shade Resources integration tests', () => {
9
9
  beforeEach(() => {
@@ -14,68 +14,292 @@ describe('Shade Resources integration tests', () => {
14
14
  })
15
15
 
16
16
  it('Should update the component based on a custom observable value change', async () => {
17
- const injector = new Injector()
18
- const rootElement = document.getElementById('root') as HTMLDivElement
19
-
20
- const renderCounter = vi.fn()
21
-
22
- const obs1 = new ObservableValue(0)
23
- const obs2 = new ObservableValue('a')
24
-
25
- const ExampleComponent = Shade({
26
- render: ({ useObservable }) => {
27
- const [value1] = useObservable('obs1', obs1)
28
- const [value2] = useObservable('obs2', obs2)
29
-
30
- renderCounter()
31
- return (
32
- <div>
33
- <div id="val1">{value1}</div>
34
- <div id="val2">{value2}</div>
35
- </div>
36
- )
37
- },
38
- shadowDomName: 'shades-example-resource',
17
+ await usingAsync(new Injector(), async (injector) => {
18
+ const rootElement = document.getElementById('root') as HTMLDivElement
19
+
20
+ const renderCounter = vi.fn()
21
+
22
+ const obs1 = new ObservableValue(0)
23
+ const obs2 = new ObservableValue('a')
24
+
25
+ const ExampleComponent = Shade({
26
+ render: ({ useObservable }) => {
27
+ const [value1] = useObservable('obs1', obs1)
28
+ const [value2] = useObservable('obs2', obs2)
29
+
30
+ renderCounter()
31
+ return (
32
+ <div>
33
+ <div id="val1">{value1}</div>
34
+ <div id="val2">{value2}</div>
35
+ </div>
36
+ )
37
+ },
38
+ shadowDomName: 'shades-example-resource',
39
+ })
40
+
41
+ expect(obs1.getObservers().length).toBe(0)
42
+ expect(obs2.getObservers().length).toBe(0)
43
+
44
+ initializeShadeRoot({
45
+ injector,
46
+ rootElement,
47
+ jsxElement: <ExampleComponent />,
48
+ })
49
+ await flushUpdates()
50
+ expect(document.body.innerHTML).toBe(
51
+ '<div id="root"><shades-example-resource><div><div id="val1">0</div><div id="val2">a</div></div></shades-example-resource></div>',
52
+ )
53
+
54
+ expect(obs1.getObservers().length).toBe(1)
55
+ expect(obs2.getObservers().length).toBe(1)
56
+
57
+ expect(renderCounter).toBeCalledTimes(1)
58
+
59
+ obs1.setValue(1)
60
+ await flushUpdates()
61
+ expect(document.body.innerHTML).toBe(
62
+ '<div id="root"><shades-example-resource><div><div id="val1">1</div><div id="val2">a</div></div></shades-example-resource></div>',
63
+ )
64
+ expect(renderCounter).toBeCalledTimes(2)
65
+
66
+ obs2.setValue('b')
67
+ await flushUpdates()
68
+ expect(document.body.innerHTML).toBe(
69
+ '<div id="root"><shades-example-resource><div><div id="val1">1</div><div id="val2">b</div></div></shades-example-resource></div>',
70
+ )
71
+
72
+ const element = document.querySelector('shades-example-resource') as JSX.Element
73
+ expect(element.getRenderCount()).toBe(3)
74
+
75
+ document.body.innerHTML = ''
76
+
77
+ await sleepAsync(10) // Cleanup can be async
78
+
79
+ expect(obs1.getObservers().length).toBe(0)
80
+ expect(obs2.getObservers().length).toBe(0)
81
+
82
+ expect(renderCounter).toBeCalledTimes(3)
39
83
  })
84
+ })
85
+
86
+ it('Should NOT re-render the component when a custom onChange callback is provided', async () => {
87
+ await usingAsync(new Injector(), async (injector) => {
88
+ const rootElement = document.getElementById('root') as HTMLDivElement
89
+
90
+ const renderCounter = vi.fn()
91
+ const customOnChange = vi.fn()
92
+
93
+ const obs = new ObservableValue(0)
94
+
95
+ const ExampleComponent = Shade({
96
+ render: ({ useObservable }) => {
97
+ const [value] = useObservable('obs', obs, { onChange: customOnChange })
98
+
99
+ renderCounter()
100
+ return <div id="val">{value}</div>
101
+ },
102
+ shadowDomName: 'shades-example-custom-onchange',
103
+ })
104
+
105
+ initializeShadeRoot({
106
+ injector,
107
+ rootElement,
108
+ jsxElement: <ExampleComponent />,
109
+ })
110
+ await flushUpdates()
111
+
112
+ const element = document.querySelector('shades-example-custom-onchange') as JSX.Element
40
113
 
41
- expect(obs1.getObservers().length).toBe(0)
42
- expect(obs2.getObservers().length).toBe(0)
114
+ // Initial render
115
+ expect(element.getRenderCount()).toBe(1)
116
+ expect(renderCounter).toBeCalledTimes(1)
117
+ expect(customOnChange).toBeCalledTimes(0) // Not called until value changes
118
+ expect(document.getElementById('val')?.textContent).toBe('0')
43
119
 
44
- initializeShadeRoot({
45
- injector,
46
- rootElement,
47
- jsxElement: <ExampleComponent />,
120
+ // Change the observable value
121
+ obs.setValue(1)
122
+
123
+ // Custom onChange should be called
124
+ expect(customOnChange).toBeCalledTimes(1)
125
+ expect(customOnChange).toHaveBeenLastCalledWith(1)
126
+
127
+ // But component should NOT re-render
128
+ expect(element.getRenderCount()).toBe(1)
129
+ expect(renderCounter).toBeCalledTimes(1)
130
+
131
+ // DOM should still show old value since no re-render occurred
132
+ expect(document.getElementById('val')?.textContent).toBe('0')
133
+
134
+ // Change again to verify consistent behavior
135
+ obs.setValue(2)
136
+
137
+ expect(customOnChange).toBeCalledTimes(2)
138
+ expect(customOnChange).toHaveBeenLastCalledWith(2)
139
+ expect(element.getRenderCount()).toBe(1)
140
+ expect(renderCounter).toBeCalledTimes(1)
48
141
  })
49
- expect(document.body.innerHTML).toBe(
50
- '<div id="root"><shades-example-resource><div><div id="val1">0</div><div id="val2">a</div></div></shades-example-resource></div>',
51
- )
142
+ })
52
143
 
53
- expect(obs1.getObservers().length).toBe(1)
54
- expect(obs2.getObservers().length).toBe(1)
144
+ it('Should allow manual DOM updates in custom onChange callback without re-render', async () => {
145
+ await usingAsync(new Injector(), async (injector) => {
146
+ const rootElement = document.getElementById('root') as HTMLDivElement
55
147
 
56
- expect(renderCounter).toBeCalledTimes(1)
148
+ const renderCounter = vi.fn()
149
+ const obs = new ObservableValue(0)
57
150
 
58
- obs1.setValue(1)
59
- expect(document.body.innerHTML).toBe(
60
- '<div id="root"><shades-example-resource><div><div id="val1">1</div><div id="val2">a</div></div></shades-example-resource></div>',
61
- )
62
- expect(renderCounter).toBeCalledTimes(2)
151
+ const ExampleComponent = Shade({
152
+ render: ({ useObservable, useRef }) => {
153
+ const valRef = useRef<HTMLDivElement>('manualVal')
154
+ useObservable('obs', obs, {
155
+ onChange: (newValue) => {
156
+ if (valRef.current) {
157
+ valRef.current.textContent = String(newValue)
158
+ }
159
+ },
160
+ })
63
161
 
64
- obs2.setValue('b')
65
- expect(document.body.innerHTML).toBe(
66
- '<div id="root"><shades-example-resource><div><div id="val1">1</div><div id="val2">b</div></div></shades-example-resource></div>',
67
- )
162
+ renderCounter()
163
+ return (
164
+ <div ref={valRef} id="manual-val">
165
+ {obs.getValue()}
166
+ </div>
167
+ )
168
+ },
169
+ shadowDomName: 'shades-example-manual-dom-update',
170
+ })
68
171
 
69
- const element = document.querySelector('shades-example-resource') as JSX.Element
70
- expect(element.getRenderCount()).toBe(3)
172
+ initializeShadeRoot({
173
+ injector,
174
+ rootElement,
175
+ jsxElement: <ExampleComponent />,
176
+ })
177
+ await flushUpdates()
71
178
 
72
- document.body.innerHTML = ''
179
+ const element = document.querySelector('shades-example-manual-dom-update') as JSX.Element
73
180
 
74
- await sleepAsync(10) // Cleanup can be async
181
+ // Initial render
182
+ expect(element.getRenderCount()).toBe(1)
183
+ expect(renderCounter).toBeCalledTimes(1)
184
+ expect(document.getElementById('manual-val')?.textContent).toBe('0')
75
185
 
76
- expect(obs1.getObservers().length).toBe(0)
77
- expect(obs2.getObservers().length).toBe(0)
186
+ // Change the observable value
187
+ obs.setValue(42)
78
188
 
79
- expect(renderCounter).toBeCalledTimes(3)
189
+ // Component should NOT re-render
190
+ expect(element.getRenderCount()).toBe(1)
191
+ expect(renderCounter).toBeCalledTimes(1)
192
+
193
+ // But DOM should be updated via the manual onChange callback
194
+ expect(document.getElementById('manual-val')?.textContent).toBe('42')
195
+
196
+ // Change again
197
+ obs.setValue(100)
198
+
199
+ expect(element.getRenderCount()).toBe(1)
200
+ expect(renderCounter).toBeCalledTimes(1)
201
+ expect(document.getElementById('manual-val')?.textContent).toBe('100')
202
+ })
203
+ })
204
+
205
+ it('Should batch multiple synchronous observable changes into a single render', async () => {
206
+ await usingAsync(new Injector(), async (injector) => {
207
+ const rootElement = document.getElementById('root') as HTMLDivElement
208
+
209
+ const renderCounter = vi.fn()
210
+
211
+ const obs1 = new ObservableValue(0)
212
+ const obs2 = new ObservableValue('a')
213
+ const obs3 = new ObservableValue(false)
214
+
215
+ const ExampleComponent = Shade({
216
+ render: ({ useObservable }) => {
217
+ const [value1] = useObservable('obs1', obs1)
218
+ const [value2] = useObservable('obs2', obs2)
219
+ const [value3] = useObservable('obs3', obs3)
220
+
221
+ renderCounter()
222
+ return (
223
+ <div>
224
+ <span id="v1">{value1}</span>
225
+ <span id="v2">{value2}</span>
226
+ <span id="v3">{String(value3)}</span>
227
+ </div>
228
+ )
229
+ },
230
+ shadowDomName: 'shades-example-batching',
231
+ })
232
+
233
+ initializeShadeRoot({
234
+ injector,
235
+ rootElement,
236
+ jsxElement: <ExampleComponent />,
237
+ })
238
+ await flushUpdates()
239
+
240
+ const element = document.querySelector('shades-example-batching') as JSX.Element
241
+
242
+ expect(element.getRenderCount()).toBe(1)
243
+ expect(renderCounter).toBeCalledTimes(1)
244
+
245
+ // Change all three observables synchronously without awaiting in between
246
+ obs1.setValue(42)
247
+ obs2.setValue('z')
248
+ obs3.setValue(true)
249
+
250
+ // Before flushing, the DOM should still reflect the old values
251
+ expect(element.getRenderCount()).toBe(1)
252
+
253
+ await flushUpdates()
254
+
255
+ // After flushing, all changes should be reflected in a single render
256
+ expect(element.getRenderCount()).toBe(2)
257
+ expect(renderCounter).toBeCalledTimes(2)
258
+ expect(document.getElementById('v1')?.textContent).toBe('42')
259
+ expect(document.getElementById('v2')?.textContent).toBe('z')
260
+ expect(document.getElementById('v3')?.textContent).toBe('true')
261
+ })
262
+ })
263
+
264
+ it('Should batch multiple updateComponent() calls into a single render', async () => {
265
+ await usingAsync(new Injector(), async (injector) => {
266
+ const rootElement = document.getElementById('root') as HTMLDivElement
267
+
268
+ const renderCounter = vi.fn()
269
+
270
+ const ExampleComponent = Shade({
271
+ render: () => {
272
+ renderCounter()
273
+ return <div>content</div>
274
+ },
275
+ shadowDomName: 'shades-example-update-batching',
276
+ })
277
+
278
+ initializeShadeRoot({
279
+ injector,
280
+ rootElement,
281
+ jsxElement: <ExampleComponent />,
282
+ })
283
+ await flushUpdates()
284
+
285
+ const element = document.querySelector('shades-example-update-batching') as JSX.Element
286
+
287
+ expect(element.getRenderCount()).toBe(1)
288
+ expect(renderCounter).toBeCalledTimes(1)
289
+
290
+ // Call updateComponent multiple times synchronously
291
+ element.updateComponent()
292
+ element.updateComponent()
293
+ element.updateComponent()
294
+
295
+ // Before flushing, render count should still be 1
296
+ expect(element.getRenderCount()).toBe(1)
297
+
298
+ await flushUpdates()
299
+
300
+ // After flushing, only a single additional render should have occurred
301
+ expect(element.getRenderCount()).toBe(2)
302
+ expect(renderCounter).toBeCalledTimes(2)
303
+ })
80
304
  })
81
305
  })
@@ -0,0 +1,239 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { sleepAsync, usingAsync } from '@furystack/utils'
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+ import { initializeShadeRoot } from './initialize.js'
5
+ import { createComponent } from './shade-component.js'
6
+ import { Shade } from './shade.js'
7
+
8
+ describe('Shade edge cases', () => {
9
+ beforeEach(() => {
10
+ document.body.innerHTML = '<div id="root"></div>'
11
+ })
12
+ afterEach(() => {
13
+ document.body.innerHTML = ''
14
+ })
15
+
16
+ describe('duplicate shadowDomName error', () => {
17
+ it('should throw an error when registering a duplicate shadowDomName', () => {
18
+ // First registration should succeed
19
+ Shade({
20
+ shadowDomName: 'shade-duplicate-test',
21
+ render: () => <div>First</div>,
22
+ })
23
+
24
+ // Second registration with the same name should throw
25
+ expect(() => {
26
+ Shade({
27
+ shadowDomName: 'shade-duplicate-test',
28
+ render: () => <div>Second</div>,
29
+ })
30
+ }).toThrow("A custom shade with name 'shade-duplicate-test' has already been registered!")
31
+ })
32
+
33
+ it('should include the duplicate name in the error message', () => {
34
+ const uniqueName = `shade-duplicate-name-in-error-${Date.now()}`
35
+
36
+ Shade({
37
+ shadowDomName: uniqueName,
38
+ render: () => <div>First</div>,
39
+ })
40
+
41
+ try {
42
+ Shade({
43
+ shadowDomName: uniqueName,
44
+ render: () => <div>Second</div>,
45
+ })
46
+ // Should not reach here
47
+ expect.fail('Expected an error to be thrown')
48
+ } catch (e) {
49
+ expect((e as Error).message).toContain(uniqueName)
50
+ }
51
+ })
52
+ })
53
+
54
+ describe('injector from props', () => {
55
+ it('should use props injector for child component instead of inheriting from parent', async () => {
56
+ await usingAsync(new Injector(), async (rootInjector) => {
57
+ await usingAsync(new Injector(), async (propsInjector) => {
58
+ const rootElement = document.getElementById('root') as HTMLDivElement
59
+
60
+ let parentCapturedInjector: Injector | undefined
61
+ let childCapturedInjector: Injector | undefined
62
+
63
+ const ChildComponent = Shade<{ injector?: Injector }>({
64
+ shadowDomName: 'shade-injector-child-props-test',
65
+ render: ({ injector }) => {
66
+ childCapturedInjector = injector
67
+ return <div>Child</div>
68
+ },
69
+ })
70
+
71
+ const ParentComponent = Shade({
72
+ shadowDomName: 'shade-injector-parent-props-test',
73
+ render: ({ injector, children }) => {
74
+ parentCapturedInjector = injector
75
+ return <div>{children}</div>
76
+ },
77
+ })
78
+
79
+ initializeShadeRoot({
80
+ injector: rootInjector,
81
+ rootElement,
82
+ jsxElement: (
83
+ <ParentComponent>
84
+ <ChildComponent injector={propsInjector} />
85
+ </ParentComponent>
86
+ ),
87
+ })
88
+
89
+ await sleepAsync(10)
90
+
91
+ // Parent should use root injector (inherited from parent)
92
+ expect(parentCapturedInjector).toBe(rootInjector)
93
+ // Child should use the props injector, not the parent's
94
+ expect(childCapturedInjector).toBe(propsInjector)
95
+ expect(childCapturedInjector).not.toBe(rootInjector)
96
+ })
97
+ })
98
+ })
99
+ })
100
+
101
+ describe('BroadcastChannel cross-tab communication', () => {
102
+ it('should update stored state when receiving BroadcastChannel message with matching key', async () => {
103
+ const mockedStorage = new Map<string, string>()
104
+
105
+ const store: typeof localStorage = {
106
+ getItem: (key) => mockedStorage.get(key) || null,
107
+ setItem: (key, value) => mockedStorage.set(key, value),
108
+ length: 0,
109
+ clear: () => mockedStorage.clear(),
110
+ key: (index) => Array.from(mockedStorage.keys())[index] || null,
111
+ removeItem: (key) => mockedStorage.delete(key),
112
+ }
113
+
114
+ await usingAsync(new Injector(), async (injector) => {
115
+ const rootElement = document.getElementById('root') as HTMLDivElement
116
+ const stateKey = 'broadcast-test-key'
117
+
118
+ const ExampleComponent = Shade({
119
+ shadowDomName: 'shade-broadcast-channel-test',
120
+ render: ({ useStoredState }) => {
121
+ const [value] = useStoredState(stateKey, 'initial', store)
122
+ return <div id="value">{value}</div>
123
+ },
124
+ })
125
+
126
+ initializeShadeRoot({
127
+ injector,
128
+ rootElement,
129
+ jsxElement: <ExampleComponent />,
130
+ })
131
+
132
+ await sleepAsync(50)
133
+ expect(document.getElementById('value')?.textContent).toBe('initial')
134
+
135
+ // Simulate cross-tab message via BroadcastChannel
136
+ const channel = new BroadcastChannel('useStoredState-broadcast-channel')
137
+ channel.postMessage({ key: stateKey, value: 'updated-from-other-tab' })
138
+
139
+ await sleepAsync(50)
140
+ expect(document.getElementById('value')?.textContent).toBe('updated-from-other-tab')
141
+
142
+ channel.close()
143
+ })
144
+ })
145
+
146
+ it('should ignore BroadcastChannel messages with different key', async () => {
147
+ const mockedStorage = new Map<string, string>()
148
+
149
+ const store: typeof localStorage = {
150
+ getItem: (key) => mockedStorage.get(key) || null,
151
+ setItem: (key, value) => mockedStorage.set(key, value),
152
+ length: 0,
153
+ clear: () => mockedStorage.clear(),
154
+ key: (index) => Array.from(mockedStorage.keys())[index] || null,
155
+ removeItem: (key) => mockedStorage.delete(key),
156
+ }
157
+
158
+ await usingAsync(new Injector(), async (injector) => {
159
+ const rootElement = document.getElementById('root') as HTMLDivElement
160
+ const stateKey = 'broadcast-filter-test-key'
161
+
162
+ const ExampleComponent = Shade({
163
+ shadowDomName: 'shade-broadcast-channel-filter-test',
164
+ render: ({ useStoredState }) => {
165
+ const [value] = useStoredState(stateKey, 'initial', store)
166
+ return <div id="value">{value}</div>
167
+ },
168
+ })
169
+
170
+ initializeShadeRoot({
171
+ injector,
172
+ rootElement,
173
+ jsxElement: <ExampleComponent />,
174
+ })
175
+
176
+ await sleepAsync(50)
177
+ expect(document.getElementById('value')?.textContent).toBe('initial')
178
+
179
+ // Simulate cross-tab message with different key
180
+ const channel = new BroadcastChannel('useStoredState-broadcast-channel')
181
+ channel.postMessage({ key: 'different-key', value: 'should-be-ignored' })
182
+
183
+ await sleepAsync(50)
184
+ // Value should remain unchanged
185
+ expect(document.getElementById('value')?.textContent).toBe('initial')
186
+
187
+ channel.close()
188
+ })
189
+ })
190
+
191
+ it('should cleanup BroadcastChannel on component disposal', async () => {
192
+ const mockedStorage = new Map<string, string>()
193
+
194
+ const store: typeof localStorage = {
195
+ getItem: (key) => mockedStorage.get(key) || null,
196
+ setItem: (key, value) => mockedStorage.set(key, value),
197
+ length: 0,
198
+ clear: () => mockedStorage.clear(),
199
+ key: (index) => Array.from(mockedStorage.keys())[index] || null,
200
+ removeItem: (key) => mockedStorage.delete(key),
201
+ }
202
+
203
+ await usingAsync(new Injector(), async (injector) => {
204
+ const rootElement = document.getElementById('root') as HTMLDivElement
205
+ const stateKey = 'broadcast-cleanup-test-key'
206
+
207
+ const ExampleComponent = Shade({
208
+ shadowDomName: 'shade-broadcast-channel-cleanup-test',
209
+ render: ({ useStoredState }) => {
210
+ const [value] = useStoredState(stateKey, 'initial', store)
211
+ return <div id="value">{value}</div>
212
+ },
213
+ })
214
+
215
+ initializeShadeRoot({
216
+ injector,
217
+ rootElement,
218
+ jsxElement: <ExampleComponent />,
219
+ })
220
+
221
+ await sleepAsync(50)
222
+ expect(document.getElementById('value')?.textContent).toBe('initial')
223
+
224
+ // Remove the component from DOM
225
+ document.body.innerHTML = ''
226
+ await sleepAsync(50)
227
+
228
+ // Create a new channel to send a message (simulating another tab)
229
+ const channel = new BroadcastChannel('useStoredState-broadcast-channel')
230
+ // This should not cause any errors since the component's channel should be closed
231
+ channel.postMessage({ key: stateKey, value: 'should-not-crash' })
232
+ await sleepAsync(50)
233
+
234
+ channel.close()
235
+ // Test passes if no errors occur
236
+ })
237
+ })
238
+ })
239
+ })