@pyreon/rocketstyle 0.24.5 → 0.24.6

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 (60) hide show
  1. package/package.json +8 -10
  2. package/src/__tests__/attrs-overloads.test.ts +0 -97
  3. package/src/__tests__/attrs.test.ts +0 -190
  4. package/src/__tests__/cache-key-boolean-collision.test.ts +0 -54
  5. package/src/__tests__/chaining.test.ts +0 -86
  6. package/src/__tests__/collection.test.ts +0 -35
  7. package/src/__tests__/compose.test.ts +0 -36
  8. package/src/__tests__/context.test.ts +0 -200
  9. package/src/__tests__/createLocalProvider.test.ts +0 -280
  10. package/src/__tests__/dimensions.test.ts +0 -183
  11. package/src/__tests__/e2e-styler.test.ts +0 -299
  12. package/src/__tests__/hooks.test.ts +0 -178
  13. package/src/__tests__/isRocketComponent.test.ts +0 -48
  14. package/src/__tests__/memo-cap.test.ts +0 -174
  15. package/src/__tests__/minimal-theme.test.ts +0 -62
  16. package/src/__tests__/misc.test.ts +0 -204
  17. package/src/__tests__/native-marker.test.ts +0 -9
  18. package/src/__tests__/providerConsumer.test.ts +0 -183
  19. package/src/__tests__/reactive-props-preservation.test.ts +0 -195
  20. package/src/__tests__/rocketstyle.browser.test.tsx +0 -481
  21. package/src/__tests__/rocketstyleIntegration.test.ts +0 -711
  22. package/src/__tests__/theme-integration.test.tsx +0 -254
  23. package/src/__tests__/themeUtils.test.ts +0 -463
  24. package/src/cache/LocalThemeManager.ts +0 -14
  25. package/src/cache/index.ts +0 -3
  26. package/src/constants/booleanTags.ts +0 -32
  27. package/src/constants/defaultDimensions.ts +0 -23
  28. package/src/constants/index.ts +0 -59
  29. package/src/context/context.ts +0 -70
  30. package/src/context/createLocalProvider.ts +0 -97
  31. package/src/context/localContext.ts +0 -37
  32. package/src/env.d.ts +0 -6
  33. package/src/hoc/index.ts +0 -3
  34. package/src/hoc/rocketstyleAttrsHoc.ts +0 -76
  35. package/src/hooks/index.ts +0 -4
  36. package/src/hooks/usePseudoState.ts +0 -79
  37. package/src/hooks/useTheme.ts +0 -48
  38. package/src/index.ts +0 -95
  39. package/src/init.ts +0 -93
  40. package/src/isRocketComponent.ts +0 -16
  41. package/src/rocketstyle.ts +0 -640
  42. package/src/types/attrs.ts +0 -23
  43. package/src/types/config.ts +0 -48
  44. package/src/types/configuration.ts +0 -69
  45. package/src/types/dimensions.ts +0 -109
  46. package/src/types/hoc.ts +0 -5
  47. package/src/types/pseudo.ts +0 -19
  48. package/src/types/rocketComponent.ts +0 -24
  49. package/src/types/rocketstyle.ts +0 -220
  50. package/src/types/styles.ts +0 -61
  51. package/src/types/theme.ts +0 -18
  52. package/src/types/utils.ts +0 -98
  53. package/src/utils/attrs.ts +0 -181
  54. package/src/utils/chaining.ts +0 -58
  55. package/src/utils/collection.ts +0 -9
  56. package/src/utils/compose.ts +0 -11
  57. package/src/utils/dimensions.ts +0 -126
  58. package/src/utils/statics.ts +0 -44
  59. package/src/utils/styles.ts +0 -18
  60. package/src/utils/theme.ts +0 -211
@@ -1,481 +0,0 @@
1
- /** @jsxImportSource @pyreon/core */
2
- import type { ComponentFn, VNodeChild } from '@pyreon/core'
3
- import { h } from '@pyreon/core'
4
- import { signal } from '@pyreon/reactivity'
5
- import { sheet } from '@pyreon/styler'
6
- import { mountInBrowser } from '@pyreon/test-utils/browser'
7
- import { PyreonUI } from '@pyreon/ui-core'
8
- import { afterEach, describe, expect, it } from 'vitest'
9
- import rocketstyle from '../init'
10
-
11
- // Real-Chromium smoke for @pyreon/rocketstyle.
12
- //
13
- // Production usage wraps component functions (Element/Text/etc.), not
14
- // string tags — so the base is a real ComponentFn here. This also
15
- // satisfies the rocketstyle `ElementType` generic without `as any`.
16
-
17
- const Base: ComponentFn<{ id?: string; children?: VNodeChild; class?: string }> = (
18
- props,
19
- ) => h('div', props, props.children)
20
- ;(Base as ComponentFn & { displayName?: string }).displayName = 'Base'
21
-
22
- describe('@pyreon/rocketstyle in real browser', () => {
23
- afterEach(() => {
24
- sheet.clearCache()
25
- })
26
-
27
- it('rocketstyle(Base) with .theme() applies the authored color in Chromium', () => {
28
- const Box: any = rocketstyle()({ name: 'Box', component: Base })
29
- .styles(
30
- (css: any) => css`
31
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
32
- padding: 8px;
33
- `,
34
- )
35
- .theme({ color: 'rgb(255, 0, 0)' })
36
-
37
- const { container, unmount } = mountInBrowser(h(Box, { id: 'rs' }))
38
- const el = container.querySelector<HTMLElement>('#rs')!
39
- expect(el.className).toMatch(/pyr-/)
40
- expect(getComputedStyle(el).color).toBe('rgb(255, 0, 0)')
41
- expect(getComputedStyle(el).padding).toBe('8px')
42
- unmount()
43
- })
44
-
45
- it('the `state` prop swaps the resolved $rocketstyle theme', () => {
46
- const Box: any = rocketstyle()({ name: 'StateBox', component: Base })
47
- .styles(
48
- (css: any) => css`
49
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
50
- `,
51
- )
52
- .theme({ color: 'rgb(255, 0, 0)' })
53
- .states({ danger: { color: 'rgb(0, 0, 255)' } })
54
-
55
- const base = mountInBrowser(h(Box, { id: 'b' }))
56
- const danger = mountInBrowser(h(Box, { id: 'd', state: 'danger' }))
57
- expect(getComputedStyle(base.container.querySelector<HTMLElement>('#b')!).color).toBe(
58
- 'rgb(255, 0, 0)',
59
- )
60
- expect(getComputedStyle(danger.container.querySelector<HTMLElement>('#d')!).color).toBe(
61
- 'rgb(0, 0, 255)',
62
- )
63
- base.unmount()
64
- danger.unmount()
65
- })
66
-
67
- it('reactive mode swap: computed class updates via renderEffect (no full effect)', async () => {
68
- // Rocketstyle passes $rocketstyle as a function accessor. DynamicStyled
69
- // wraps it in a computed() that tracks the mode signal. When mode changes,
70
- // the computed re-evaluates → new CSS class → renderEffect updates DOM.
71
- // No per-component effect() — just one lightweight computed + renderEffect.
72
- const modeSig = signal<'light' | 'dark'>('light')
73
-
74
- const Box: any = rocketstyle()({ name: 'ModeSwapBox', component: Base })
75
- .styles(
76
- (css: any) => css`
77
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
78
- `,
79
- )
80
- .theme((_t: any, m: any) => ({
81
- color: m('rgb(255, 0, 0)', 'rgb(0, 0, 255)'),
82
- }))
83
-
84
- const { container, unmount } = mountInBrowser(
85
- h(PyreonUI, { theme: {}, mode: modeSig }, h(Box, { id: 'rx' })),
86
- )
87
- const el = container.querySelector<HTMLElement>('#rx')!
88
- expect(getComputedStyle(el).color).toBe('rgb(255, 0, 0)')
89
-
90
- modeSig.set('dark')
91
- await new Promise((r) => setTimeout(r, 0))
92
- await new Promise((r) => requestAnimationFrame(() => r(undefined)))
93
-
94
- expect(getComputedStyle(el).color).toBe('rgb(0, 0, 255)')
95
- unmount()
96
- })
97
-
98
- // Bug 3 audit: derivation chain matching the real bokisch.com bug shape.
99
- //
100
- // Library defines a base `Text` component with `.styles(({css}) => css\`color:
101
- // ${$rocketstyle.color}\`)` — the .styles() callback reads $rocketstyle.color.
102
- // Library also defines `.theme({color: 'black'})` as the default.
103
- //
104
- // Consumer derives a sub-component: `Text.theme((t, m) => ({color: m('red',
105
- // 'blue')}))` — adds NEW .theme() but does NOT call .styles() again. The
106
- // derivation should INHERIT the base's .styles() and have it consume the
107
- // new .theme() values, with mode toggling re-rendering correctly.
108
- //
109
- // The user's report: components in this chain keep stale colors on
110
- // theme toggle. Test verifies whether the derivation chain actually
111
- // wires up reactively.
112
- it('Bug 3 repro: rocketstyle derivation chain — inherited .styles() + new .theme() reacts to mode', async () => {
113
- const modeSig = signal<'light' | 'dark'>('light')
114
-
115
- // Library base — has .styles() that reads $rocketstyle.color.
116
- const TextBase: any = rocketstyle()({
117
- name: 'TextBase',
118
- component: Base,
119
- })
120
- .styles(
121
- (css: any) => css`
122
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
123
- `,
124
- )
125
- .theme({ color: 'rgb(0, 128, 0)' }) // green default
126
-
127
- // Consumer derivation — only adds .theme(), no .styles() override.
128
- const Text: any = TextBase
129
- .theme((_t: any, m: any) => ({
130
- color: m('rgb(255, 0, 0)', 'rgb(0, 0, 255)'),
131
- }))
132
-
133
- const { container, unmount } = mountInBrowser(
134
- h(PyreonUI, { theme: {}, mode: modeSig }, h(Text, { id: 'derive' })),
135
- )
136
-
137
- const el = container.querySelector<HTMLElement>('#derive')!
138
- const initialColor = getComputedStyle(el).color
139
-
140
- modeSig.set('dark')
141
- await new Promise((r) => setTimeout(r, 0))
142
- await new Promise((r) => requestAnimationFrame(() => r(undefined)))
143
-
144
- const darkColor = getComputedStyle(el).color
145
-
146
- // Diagnostic: log both colors for clarity, regardless of pass/fail
147
- // (helps triage whether the bug is "no .theme() applied at all" vs
148
- // "applied but doesn't react to mode toggle").
149
- if (initialColor === 'rgb(255, 0, 0)' && darkColor === 'rgb(0, 0, 255)') {
150
- // Works correctly — derivation chain wires up reactively
151
- expect(darkColor).toBe('rgb(0, 0, 255)')
152
- } else {
153
- throw new Error(
154
- `[bug-3-repro] derivation chain failed. initial=${initialColor}, dark=${darkColor}. ` +
155
- `Expected initial=rgb(255, 0, 0), dark=rgb(0, 0, 255). ` +
156
- `Possible causes: (a) .theme() override silently dropped, ` +
157
- `(b) .styles() not inherited from base, ` +
158
- `(c) mode toggle doesn't propagate.`,
159
- )
160
- }
161
- unmount()
162
- })
163
-
164
- // Bug 3 audit (continued): derivation chain WITH dimension props.
165
- // Real consumer pattern: `<Text base paragraph centered>` uses
166
- // dimension-prop values from .sizes()/.variants(). Tests that mode
167
- // toggle still propagates when dimension props are active.
168
- it('Bug 3 repro: derivation + dimension props + mode toggle', async () => {
169
- const modeSig = signal<'light' | 'dark'>('light')
170
-
171
- const TextBase: any = rocketstyle()({ name: 'TextBaseDim', component: Base })
172
- .styles(
173
- (css: any) => css`
174
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
175
- font-size: ${({ $rocketstyle }: any) => $rocketstyle.fontSize};
176
- `,
177
- )
178
- .theme({ color: 'rgb(0, 0, 0)', fontSize: '14px' })
179
- .sizes({
180
- small: { fontSize: '12px' },
181
- large: { fontSize: '20px' },
182
- })
183
-
184
- // Consumer derivation: theme override that depends on mode
185
- const Text: any = TextBase.theme((_t: any, m: any) => ({
186
- color: m('rgb(255, 0, 0)', 'rgb(0, 0, 255)'),
187
- }))
188
-
189
- const { container, unmount } = mountInBrowser(
190
- h(PyreonUI, { theme: {}, mode: modeSig },
191
- h(Text, { id: 'dim', size: 'large' }),
192
- ),
193
- )
194
- const el = container.querySelector<HTMLElement>('#dim')!
195
- expect(getComputedStyle(el).color).toBe('rgb(255, 0, 0)')
196
- expect(getComputedStyle(el).fontSize).toBe('20px')
197
-
198
- modeSig.set('dark')
199
- await new Promise((r) => setTimeout(r, 0))
200
- await new Promise((r) => requestAnimationFrame(() => r(undefined)))
201
-
202
- expect(getComputedStyle(el).color).toBe('rgb(0, 0, 255)')
203
- expect(getComputedStyle(el).fontSize).toBe('20px') // dimension unchanged
204
- unmount()
205
- })
206
-
207
- // Bug 3 audit (continued): DOUBLE derivation — Text → TextStyled → consumer.
208
- // Tests whether mode reactivity survives a multi-level chain.
209
- it('Bug 3 repro: double-derivation chain still reacts to mode', async () => {
210
- const modeSig = signal<'light' | 'dark'>('light')
211
-
212
- const TextBase: any = rocketstyle()({ name: 'DoubleTextBase', component: Base })
213
- .styles(
214
- (css: any) => css`
215
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
216
- `,
217
- )
218
- .theme({ color: 'rgb(0, 0, 0)' })
219
-
220
- // First derivation — adds states (no theme override yet)
221
- const TextStyled: any = TextBase.states({
222
- muted: { color: 'rgb(128, 128, 128)' },
223
- })
224
-
225
- // Second derivation — mode-aware theme
226
- const Text: any = TextStyled.theme((_t: any, m: any) => ({
227
- color: m('rgb(255, 0, 0)', 'rgb(0, 0, 255)'),
228
- }))
229
-
230
- const { container, unmount } = mountInBrowser(
231
- h(PyreonUI, { theme: {}, mode: modeSig }, h(Text, { id: 'dbl' })),
232
- )
233
- const el = container.querySelector<HTMLElement>('#dbl')!
234
- expect(getComputedStyle(el).color).toBe('rgb(255, 0, 0)')
235
-
236
- modeSig.set('dark')
237
- await new Promise((r) => setTimeout(r, 0))
238
- await new Promise((r) => requestAnimationFrame(() => r(undefined)))
239
-
240
- expect(getComputedStyle(el).color).toBe('rgb(0, 0, 255)')
241
- unmount()
242
- })
243
-
244
- it('the `variant` prop layers on top of state', () => {
245
- const Box: any = rocketstyle()({ name: 'VariantBox', component: Base })
246
- .styles(
247
- (css: any) => css`
248
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
249
- background-color: ${({ $rocketstyle }: any) => $rocketstyle.bg};
250
- `,
251
- )
252
- .theme({ color: 'rgb(0, 0, 0)', bg: 'rgb(240, 240, 240)' })
253
- .variants({ box: { bg: 'rgb(20, 30, 40)' } })
254
-
255
- const { container, unmount } = mountInBrowser(
256
- h(Box, { id: 'v', variant: 'box' }),
257
- )
258
- const el = container.querySelector<HTMLElement>('#v')!
259
- expect(getComputedStyle(el).color).toBe('rgb(0, 0, 0)')
260
- expect(getComputedStyle(el).backgroundColor).toBe('rgb(20, 30, 40)')
261
- unmount()
262
- })
263
-
264
- it('the `modifier` transform derives styles from the accumulated state theme', () => {
265
- const Box: any = rocketstyle()({ name: 'ModBox', component: Base })
266
- .styles(
267
- (css: any) => css`
268
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
269
- background-color: ${({ $rocketstyle }: any) => $rocketstyle.bg};
270
- `,
271
- )
272
- .theme({ color: 'rgb(255, 255, 255)', bg: 'rgb(0, 112, 243)' })
273
- .states({ danger: { color: 'rgb(255, 255, 255)', bg: 'rgb(220, 53, 69)' } })
274
- .modifiers({
275
- outlined: (acc: any) => ({
276
- color: acc.bg,
277
- bg: 'rgb(255, 255, 255)',
278
- }),
279
- })
280
-
281
- const { container, unmount } = mountInBrowser(
282
- h(Box, { id: 'm', state: 'danger', modifier: 'outlined' }),
283
- )
284
- const el = container.querySelector<HTMLElement>('#m')!
285
- expect(getComputedStyle(el).color).toBe('rgb(220, 53, 69)')
286
- expect(getComputedStyle(el).backgroundColor).toBe('rgb(255, 255, 255)')
287
- unmount()
288
- })
289
-
290
- it('m(light, dark) theme callback resolves per PyreonUI mode', () => {
291
- const Box: any = rocketstyle()({ name: 'ModeBox', component: Base })
292
- .styles(
293
- (css: any) => css`
294
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
295
- `,
296
- )
297
- .theme((_t: any, m: any) => ({
298
- color: m('rgb(12, 34, 56)', 'rgb(210, 220, 230)'),
299
- }))
300
-
301
- const light = mountInBrowser(
302
- h(PyreonUI, { theme: {}, mode: 'light' }, h(Box, { id: 'lt' })),
303
- )
304
- const dark = mountInBrowser(
305
- h(PyreonUI, { theme: {}, mode: 'dark' }, h(Box, { id: 'dk' })),
306
- )
307
- expect(getComputedStyle(light.container.querySelector<HTMLElement>('#lt')!).color).toBe(
308
- 'rgb(12, 34, 56)',
309
- )
310
- expect(getComputedStyle(dark.container.querySelector<HTMLElement>('#dk')!).color).toBe(
311
- 'rgb(210, 220, 230)',
312
- )
313
- light.unmount()
314
- dark.unmount()
315
- })
316
-
317
- // Bug 5 repro: cache-key collision under `useBooleans: true`.
318
- //
319
- // The user-facing bug shape: `<Btn primary />` and `<Btn secondary />`
320
- // both render with the FIRST cached variant's resolved styles. Pre-fix
321
- // the dimension-prop memo built its key from raw `propsRec[dimName]`
322
- // BEFORE _calculateStylingAttrs resolved the boolean shorthand, so all
323
- // boolean variants on the same dimension had `propsRec.state === undefined`
324
- // and collided. Real-app symptom: a button group where primary, secondary,
325
- // and danger variants all render in the FIRST color the user clicked.
326
- //
327
- // Real-Chromium proof: mounts both variants in parallel and asserts
328
- // `getComputedStyle().color` is different. happy-dom's class-based
329
- // assertions wouldn't catch this — only real CSS resolution proves
330
- // the styler classCache served different cached styles for each variant.
331
- it('Bug 5 repro: useBooleans:true with multiple boolean variants render with distinct computed styles', () => {
332
- const Btn: any = rocketstyle({ useBooleans: true })({
333
- name: 'BoolBtn',
334
- component: Base,
335
- })
336
- .styles(
337
- (css: any) => css`
338
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
339
- `,
340
- )
341
- .theme({ color: 'rgb(0, 0, 0)' })
342
- .states({
343
- primary: { color: 'rgb(255, 0, 0)' },
344
- secondary: { color: 'rgb(0, 255, 0)' },
345
- danger: { color: 'rgb(0, 0, 255)' },
346
- })
347
-
348
- const primaryMount = mountInBrowser(h(Btn, { id: 'p', primary: true }))
349
- const secondaryMount = mountInBrowser(h(Btn, { id: 's', secondary: true }))
350
- const dangerMount = mountInBrowser(h(Btn, { id: 'd', danger: true }))
351
-
352
- const primaryEl = primaryMount.container.querySelector<HTMLElement>('#p')!
353
- const secondaryEl = secondaryMount.container.querySelector<HTMLElement>('#s')!
354
- const dangerEl = dangerMount.container.querySelector<HTMLElement>('#d')!
355
-
356
- // Without the fix: all three resolve to whichever variant was cached
357
- // first under the colliding key (mode|undefined|...). With the fix:
358
- // each gets its own cache entry keyed off the resolved state value.
359
- expect(getComputedStyle(primaryEl).color).toBe('rgb(255, 0, 0)')
360
- expect(getComputedStyle(secondaryEl).color).toBe('rgb(0, 255, 0)')
361
- expect(getComputedStyle(dangerEl).color).toBe('rgb(0, 0, 255)')
362
-
363
- // Sibling mount under the SAME PyreonUI provider — exercises the
364
- // cross-instance shared memo (per-theme WeakMap entry). Pre-fix this
365
- // was the actual real-app shape: button group children mounted under
366
- // a single provider, all hitting the same colliding key.
367
- const groupMount = mountInBrowser(
368
- h(PyreonUI, { theme: {}, mode: 'light' },
369
- h('div', { id: 'group' },
370
- h(Btn, { id: 'g-primary', primary: true }),
371
- h(Btn, { id: 'g-secondary', secondary: true }),
372
- h(Btn, { id: 'g-danger', danger: true }),
373
- ),
374
- ),
375
- )
376
- expect(
377
- getComputedStyle(groupMount.container.querySelector<HTMLElement>('#g-primary')!).color,
378
- ).toBe('rgb(255, 0, 0)')
379
- expect(
380
- getComputedStyle(groupMount.container.querySelector<HTMLElement>('#g-secondary')!).color,
381
- ).toBe('rgb(0, 255, 0)')
382
- expect(
383
- getComputedStyle(groupMount.container.querySelector<HTMLElement>('#g-danger')!).color,
384
- ).toBe('rgb(0, 0, 255)')
385
-
386
- primaryMount.unmount()
387
- secondaryMount.unmount()
388
- dangerMount.unmount()
389
- groupMount.unmount()
390
- })
391
-
392
- it('multiple instances share definition-scoped caches (no per-mount rebuild)', () => {
393
- // Verifies the perf optimization: getDimensionsMap, reservedPropNames keys,
394
- // and omit Sets are cached at definition time (WeakMap), not rebuilt per mount.
395
- // 10 instances of the same component with different state props must all render
396
- // correctly — proving the caches handle varied prop combinations.
397
- const Box: any = rocketstyle()({ name: 'CacheBox', component: Base })
398
- .styles(
399
- (css: any) => css`
400
- color: ${({ $rocketstyle }: any) => $rocketstyle.color};
401
- `,
402
- )
403
- .theme({ color: 'rgb(100, 100, 100)' })
404
- .states({
405
- primary: { color: 'rgb(0, 100, 200)' },
406
- danger: { color: 'rgb(200, 50, 50)' },
407
- })
408
-
409
- const instances = Array.from({ length: 10 }, (_, i) => {
410
- const state = i % 3 === 0 ? 'primary' : i % 3 === 1 ? 'danger' : undefined
411
- return mountInBrowser(h(Box, { id: `c${i}`, ...(state ? { state } : {}) }))
412
- })
413
-
414
- // Check a subset — primary, danger, and default all resolve correctly
415
- expect(
416
- getComputedStyle(instances[0]!.container.querySelector('#c0')!).color,
417
- ).toBe('rgb(0, 100, 200)') // primary
418
- expect(
419
- getComputedStyle(instances[1]!.container.querySelector('#c1')!).color,
420
- ).toBe('rgb(200, 50, 50)') // danger
421
- expect(
422
- getComputedStyle(instances[2]!.container.querySelector('#c2')!).color,
423
- ).toBe('rgb(100, 100, 100)') // default
424
-
425
- for (const inst of instances) inst.unmount()
426
- })
427
-
428
- it('reactive prop flips reach the rendered DOM through the rocketstyle pipeline', async () => {
429
- // The bug class this catches: rocketstyle's HOC + EnhancedComponent
430
- // used to value-copy props (via spread + omit/pick + the
431
- // value-iteration removeUndefinedProps), collapsing getter-shaped
432
- // reactive props to a static value before the wrapped component
433
- // saw them. Downstream JSX accessors then read the captured-once
434
- // value, breaking signal-driven updates on every prop on every
435
- // rocketstyle-wrapped component (the whole of @pyreon/ui-components,
436
- // plus user-defined ones).
437
- //
438
- // Test mirrors the real-app shape: a signal feeds an `id` prop on a
439
- // rocketstyle-wrapped Base; the wrapped component renders a child
440
- // that reads `props.id` reactively. Flipping the signal must patch
441
- // the DOM.
442
- //
443
- // We can't drive this through the user-side compiler in tests, so
444
- // we manually `_rp`-brand the function + run `makeReactiveProps` —
445
- // exactly what the compiler + mount pipeline do for a real consumer.
446
- const { _rp } = await import('@pyreon/core')
447
- const labelSig = signal('initial')
448
-
449
- // Base renders the label prop as DOM text so we can assert reactivity
450
- // observably in the rendered tree.
451
- const ReactiveBase: ComponentFn<{ label?: string; children?: VNodeChild }> = (
452
- props,
453
- ) => h('div', { 'data-testid': 'reactive' }, () => props.label)
454
- ;(ReactiveBase as ComponentFn & { displayName?: string }).displayName = 'ReactiveBase'
455
-
456
- const Box: any = rocketstyle()({ name: 'ReactivePropBox', component: ReactiveBase })
457
-
458
- // Brand the prop like the compiler does (`_rp(() => signal())`) —
459
- // mount.ts's `makeReactiveProps` then converts the brand to a getter
460
- // property. If the rocketstyle pipeline preserves the descriptor, the
461
- // inner component sees a getter; if it value-copies (the broken
462
- // shape this test catches), it sees the resolved-once string 'initial'
463
- // and the DOM never updates on signal flip.
464
- const rawProps = { label: _rp(() => labelSig()) }
465
-
466
- const { container, unmount } = mountInBrowser(h(Box, rawProps))
467
- const el = container.querySelector<HTMLElement>('[data-testid="reactive"]')!
468
- expect(el.textContent).toBe('initial')
469
-
470
- labelSig.set('updated')
471
- // Microtask flush — Pyreon's renderEffect commits synchronously after batch.
472
- await Promise.resolve()
473
- expect(el.textContent).toBe('updated')
474
-
475
- labelSig.set('third')
476
- await Promise.resolve()
477
- expect(el.textContent).toBe('third')
478
-
479
- unmount()
480
- })
481
- })