@bitstillery/mithril 3.0.3 → 3.2.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.
package/README.md CHANGED
@@ -1,24 +1,22 @@
1
- # @bitstillery/mithril
1
+ # Mithril Bitstillery
2
2
 
3
- Mithril.js fork with fine-grained reactivity, SSR hydration, and built-in signals. Drop-in compatible with Mithril v2.x.
3
+ Mithril Bitstillery extends Mithril with integrated state management, SSR hydration, watchers, and a signal/proxy store. Drop-in compatible with Mithril v2.x.
4
4
 
5
5
  ```bash
6
6
  bun add @bitstillery/mithril
7
7
  ```
8
8
 
9
- ## Why
9
+ ## Strengths
10
10
 
11
- Mithril.js uses global `m.redraw()` — one state change re-renders **everything**. This fork adds component-level reactivity via signals so only affected components update.
11
+ Mithril Bitstillery focuses on **state management**, **SSR**, **watchers**, and **developer experience** around its signal/proxy store:
12
12
 
13
- | Feature | Original | This Fork |
14
- | ---------------- | ------------------- | -------------------------------- |
15
- | Reactivity | Global `m.redraw()` | Fine-grained component updates |
16
- | State Management | Manual redraw calls | Signals with automatic tracking |
17
- | SSR | No hydration | State serialization + hydration |
18
- | TypeScript | Community types | Native |
19
- | Stream / request | Built-in | Removed (use signals, `fetch()`) |
20
-
21
- Signals are opt-in. Existing Mithril code works unchanged.
13
+ | Feature | Description |
14
+ | ---------------- | ------------------------------------------------------------------- |
15
+ | Proxy State | Reactive objects with nested objects, arrays, computeds |
16
+ | State Management | `state()` + `Store` for persistence (localStorage, session) |
17
+ | SSR | Full server-side rendering with state serialization + hydration |
18
+ | Watchers | `watch()` for observing signal changes; `effect()` for side effects |
19
+ | DX | Automatic dependency tracking, no manual redraw for signals |
22
20
 
23
21
  **Docs**: [mithril.garage44.org](https://mithril.garage44.org)
24
22
 
@@ -38,7 +36,7 @@ count(5) // Logs: 5 × 2 = 10
38
36
 
39
37
  ## Proxy State
40
38
 
41
- Wraps signals in a proxy for ergonomic access. Components automatically track which properties they read and only re-render when those change.
39
+ `state()` creates reactive objects. Components track which properties they read and only re-render when those change.
42
40
 
43
41
  ```tsx
44
42
  import m, {state, MithrilComponent} from '@bitstillery/mithril'
@@ -61,6 +59,19 @@ m.mount(document.body, Counter)
61
59
 
62
60
  The second argument to `state()` is a name used for SSR serialization.
63
61
 
62
+ ## Watchers
63
+
64
+ `watch()` observes signal changes:
65
+
66
+ ```typescript
67
+ import {state, watch} from '@bitstillery/mithril'
68
+
69
+ const $s = state({count: 0}, 'app')
70
+ const unwatch = watch($s.$count, (newVal, oldVal) => console.log(`${oldVal} → ${newVal}`))
71
+ $s.count++ // triggers callback
72
+ unwatch() // stop observing
73
+ ```
74
+
64
75
  ## SSR Hydration
65
76
 
66
77
  ```typescript
package/api/router.ts CHANGED
@@ -29,7 +29,7 @@ export type SSRResult = string | {html: string; state: SSRState}
29
29
  export interface Route {
30
30
  (path: string, params?: Record<string, any>, shouldReplaceHistory?: boolean): void
31
31
  (path: string, component: ComponentType, shouldReplaceHistory?: boolean): void
32
- set: (path: string, params?: Record<string, any>, data?: any) => void
32
+ set: (path: string, params?: Record<string, any>, data?: any) => Promise<void>
33
33
  get: () => string
34
34
  prefix: string
35
35
  link: (vnode: VnodeType) => string
@@ -53,6 +53,8 @@ interface MountRedraw {
53
53
  }
54
54
 
55
55
  interface RouteOptions {
56
+ /** When true, forces the route component to remount (e.g. for same-route navigation with different query params) */
57
+ remount?: boolean
56
58
  replace?: boolean
57
59
  state?: any
58
60
  title?: string | null
@@ -77,6 +79,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
77
79
  let attrs: Record<string, any> = {}
78
80
  let currentPath: string | undefined
79
81
  let lastUpdate: ((comp: any) => void) | null = null
82
+ let routeSetResolve: (() => void) | null = null
83
+ let remountPath: string | null = null
84
+ let pendingRemount = false
80
85
 
81
86
  const RouterRoot: ComponentType = {
82
87
  onremove: function () {
@@ -89,11 +94,22 @@ export default function router($window: any, mountRedraw: MountRedraw) {
89
94
  // if (!hasBeenResolved) return
90
95
 
91
96
  // Pass currentPath in attrs so RouteResolver.render can use it for routePath.
92
- // Use attrs.key (not currentPath) so that route param changes within the same
93
- // route pattern update the component instead of causing a full remount.
97
+ // Use remountPath when set (from route.set with remount: true) to force remount
98
+ // for same-route navigation; otherwise attrs.key for route param changes.
94
99
  const routeAttrs = {...attrs, routePath: currentPath || attrs.routePath}
95
- const vnode = Vnode(component, attrs.key, routeAttrs, null, null, null)
96
- if (currentResolver) return currentResolver.render!(vnode as any)
100
+ const vnodeKey = remountPath ?? attrs.key
101
+ const vnode = Vnode(component, vnodeKey, routeAttrs, null, null, null)
102
+ if (currentResolver) {
103
+ const result = currentResolver.render!(vnode as any)
104
+ // When remount requested, wrap in keyed fragment so same-route navigation
105
+ // (e.g. Stock <-> TBO) forces full remount. Key must change when path changes.
106
+ // Always wrap when we have a path so structure is consistent; key drives remount.
107
+ const fragmentKey = remountPath ?? currentPath ?? undefined
108
+ if (fragmentKey != null) {
109
+ return hyperscript.fragment({key: fragmentKey}, result)
110
+ }
111
+ return result
112
+ }
97
113
  // Wrap in a fragment to preserve existing key semantics
98
114
  return [vnode]
99
115
  },
@@ -176,7 +192,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
176
192
 
177
193
  function reject(e: any) {
178
194
  console.error(e)
179
- route.set(fallbackRoute!, null, {replace: true})
195
+ const resolve = routeSetResolve
196
+ routeSetResolve = null
197
+ route.set(fallbackRoute!, null, {replace: true}).then(() => resolve?.())
180
198
  }
181
199
 
182
200
  loop(0)
@@ -206,8 +224,10 @@ export default function router($window: any, mountRedraw: MountRedraw) {
206
224
  if (isRedirect(comp)) {
207
225
  // Extract redirect target path
208
226
  const redirectPath = comp[REDIRECT]
209
- // Trigger navigation to redirect target
210
- route.set(redirectPath, null)
227
+ const resolve = routeSetResolve
228
+ routeSetResolve = null
229
+ // Trigger navigation to redirect target; resolve original Promise when redirect completes
230
+ route.set(redirectPath, null).then(() => resolve?.())
211
231
  // Skip rendering current route - new route resolution will handle redirect target
212
232
  return
213
233
  }
@@ -228,7 +248,13 @@ export default function router($window: any, mountRedraw: MountRedraw) {
228
248
  }
229
249
  attrs = data.params
230
250
  currentPath = path
251
+ if (pendingRemount) {
252
+ remountPath = path
253
+ pendingRemount = false
254
+ }
231
255
  lastUpdate = null
256
+ routeSetResolve?.()
257
+ routeSetResolve = null
232
258
  if (hasBeenResolved) mountRedraw.redraw()
233
259
  else {
234
260
  hasBeenResolved = true
@@ -255,7 +281,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
255
281
  if (path === fallbackRoute) {
256
282
  throw new Error('Could not resolve default route ' + fallbackRoute + '.')
257
283
  }
258
- route.set(fallbackRoute!, null, {replace: true})
284
+ const resolve = routeSetResolve
285
+ routeSetResolve = null
286
+ route.set(fallbackRoute!, null, {replace: true}).then(() => resolve?.())
259
287
  }
260
288
  }
261
289
 
@@ -304,32 +332,39 @@ export default function router($window: any, mountRedraw: MountRedraw) {
304
332
  // The RouterRoot component is mounted when the route is first resolved.
305
333
  resolveRoute()
306
334
  }
307
- route.set = function (path: string, data: Record<string, any> | null, options?: RouteOptions) {
335
+ route.set = function (path: string, data: Record<string, any> | null, options?: RouteOptions): Promise<void> {
308
336
  if (lastUpdate != null) {
309
337
  options = options || {}
310
338
  options.replace = true
311
339
  }
312
340
  lastUpdate = null
313
341
 
342
+ if (!options?.remount) {
343
+ remountPath = null
344
+ }
345
+ pendingRemount = options?.remount ?? false
346
+
314
347
  path = buildPathname(path, data || {})
315
- if (ready) {
316
- // Router is initialized - use history API for navigation
317
- const state = options ? options.state : null
318
- const title = options ? options.title : null
319
- if ($window?.history) {
320
- if (options && options.replace) $window.history.replaceState(state, title, route.prefix + path)
321
- else $window.history.pushState(state, title, route.prefix + path)
322
- }
323
- // Update URL before fireAsync so resolveRoute reads the new path
324
- fireAsync()
325
- // In SSR context (no $window), navigation is a no-op since we're just rendering HTML
326
- } else {
327
- // Router not yet initialized - use location.href for initial navigation
348
+ if (!ready || !$window) {
349
+ // Router not yet initialized or SSR - navigation is a no-op
328
350
  if ($window?.location) {
329
351
  $window.location.href = route.prefix + path
330
352
  }
331
- // In SSR context (no $window), this is a no-op since we're just rendering HTML
353
+ return Promise.resolve()
332
354
  }
355
+ // Router is initialized - use history API for navigation
356
+ const state = options ? options.state : null
357
+ const title = options ? options.title : null
358
+ if ($window.history) {
359
+ if (options && options.replace) $window.history.replaceState(state, title, route.prefix + path)
360
+ else $window.history.pushState(state, title, route.prefix + path)
361
+ }
362
+ const promise = new Promise<void>((resolve) => {
363
+ routeSetResolve = resolve
364
+ })
365
+ // Update URL before fireAsync so resolveRoute reads the new path
366
+ fireAsync()
367
+ return promise
333
368
  }
334
369
  route.get = function (): string {
335
370
  // If currentPath is not set (e.g., during SSR before route.resolve is called),
@@ -352,11 +387,12 @@ export default function router($window: any, mountRedraw: MountRedraw) {
352
387
  // let them be specified in the selector as well.
353
388
  const child = hyperscript(
354
389
  vnode.attrs?.selector || 'a',
355
- censor(vnode.attrs || {}, ['options', 'params', 'selector', 'onclick']),
390
+ censor(vnode.attrs || {}, ['options', 'params', 'selector', 'onclick', 'onafternavigate']),
356
391
  vnode.children,
357
392
  )
358
393
  let options: RouteOptions | undefined
359
394
  let onclick: any
395
+ let onafternavigate: (() => void) | undefined
360
396
  let href: string
361
397
 
362
398
  // Let's provide a *right* way to disable a route link, rather than
@@ -373,6 +409,7 @@ export default function router($window: any, mountRedraw: MountRedraw) {
373
409
  } else {
374
410
  options = vnode.attrs?.options
375
411
  onclick = vnode.attrs?.onclick
412
+ onafternavigate = vnode.attrs?.onafternavigate
376
413
  // Easier to build it now to keep it isomorphic.
377
414
  href = buildPathname(child.attrs!.href || '', vnode.attrs?.params || {})
378
415
  // Make Link isomorphic - use empty prefix on server for pathname routing
@@ -421,7 +458,7 @@ export default function router($window: any, mountRedraw: MountRedraw) {
421
458
  e.originalEvent.preventDefault()
422
459
  }
423
460
  ;(e as any).redraw = false
424
- route.set(href, null, options)
461
+ route.set(href, null, options).then(onafternavigate ?? (() => {}))
425
462
  }
426
463
  }
427
464
  }
package/index.ts CHANGED
@@ -102,7 +102,7 @@ setSignalRedrawCallback((sig: Signal<any>) => {
102
102
 
103
103
  // Export signals API
104
104
  export {signal, computed, effect, Signal, ComputedSignal, state, watch, registerState, getRegisteredStates}
105
- export type {State, StateOptions, Unwatch} from './state'
105
+ export type {State, StateOptions, StateSignals, Unwatch} from './state'
106
106
 
107
107
  // Export Store class
108
108
  export {Store} from './store'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitstillery/mithril",
3
- "version": "3.0.3",
3
+ "version": "3.2.0",
4
4
  "description": "Mithril + Signals, Store and SSR",
5
5
  "license": "MIT",
6
6
  "author": "Bitstillery",
@@ -44,6 +44,9 @@
44
44
  "default": "./htm.ts"
45
45
  }
46
46
  },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
47
50
  "scripts": {
48
51
  "lint": "bun run lint:ts",
49
52
  "lint:ts": "bun run lint:ts-format && bun run lint:ts-syntax && bun run lint:ts-types",
@@ -52,7 +55,11 @@
52
55
  "lint:ts-types": "tsgo --noEmit",
53
56
  "test": "bun test",
54
57
  "prepublishOnly": "bun run lint:ts",
55
- "pack:dry-run": "npm pack --dry-run"
58
+ "pack:dry-run": "npm pack --dry-run",
59
+ "release": "./scripts/release.sh",
60
+ "release:patch": "./scripts/release.sh --release-as patch",
61
+ "release:minor": "./scripts/release.sh --release-as minor",
62
+ "release:major": "./scripts/release.sh --release-as major"
56
63
  },
57
64
  "dependencies": {
58
65
  "htm": "^3.1.1"
@@ -71,6 +78,7 @@
71
78
  "marked-gfm-heading-id": "^4.0.0",
72
79
  "oxfmt": "^0.32.0",
73
80
  "oxlint": "^1.30.0",
81
+ "standard-version": "^9.5.0",
74
82
  "tippy.js": "^6.3.7"
75
83
  }
76
84
  }
package/state.ts CHANGED
@@ -204,6 +204,16 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
204
204
  return stateCache.get(obj)
205
205
  }
206
206
 
207
+ const linkArrayParentSignal = (value: any, sig: Signal<any> | ComputedSignal<any>) => {
208
+ if (!value || typeof value !== 'object') return
209
+ if ((value as any).__isState === true && Array.isArray((value as any).__signals)) {
210
+ arrayParentSignalMap.set(value, sig as Signal<any>)
211
+ ;(value as any)._parentSignal = sig as Signal<any>
212
+ } else if (Array.isArray(value)) {
213
+ arrayParentSignalMap.set(value, sig as Signal<any>)
214
+ }
215
+ }
216
+
207
217
  // Handle arrays
208
218
  if (Array.isArray(obj)) {
209
219
  // Arrays don't get their own signalMap - they use the parent's
@@ -294,7 +304,19 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
294
304
  // Search methods also need unwrapped values for comparison
295
305
  const searchMethods = ['includes', 'indexOf', 'lastIndexOf']
296
306
  // Methods that return new arrays or strings need unwrapped values
297
- const returnMethods = ['slice', 'concat', 'flat', 'flatMap', 'join', 'toString', 'toLocaleString']
307
+ const returnMethods = [
308
+ 'slice',
309
+ 'concat',
310
+ 'flat',
311
+ 'flatMap',
312
+ 'join',
313
+ 'toString',
314
+ 'toLocaleString',
315
+ // ES2023 copy-with-mutate methods - bind to proxy so iteration unwraps signals
316
+ 'toSorted',
317
+ 'toReversed',
318
+ 'toSpliced',
319
+ ]
298
320
  // Iterator methods need unwrapped values
299
321
  const iteratorMethods = ['entries', 'keys', 'values']
300
322
  if (
@@ -554,7 +576,41 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
554
576
  // Each nested state gets its own signalMap (unless parentSignalMap is explicitly provided)
555
577
  // This prevents nested states from sharing the parent's signalMap
556
578
  const nestedSignalMap = parentSignalMap || new Map<string, Signal<any> | ComputedSignal<any>>()
557
- const wrapped = new Proxy(obj, {
579
+ let wrapped: any
580
+ const getChildContext = (): InitContext | undefined =>
581
+ context ? {...context, rootState: stateRootMap.get(wrapped) ?? wrapped} : undefined
582
+
583
+ const createPropertySignal = (originalValue: any): Signal<any> | ComputedSignal<any> => {
584
+ if (typeof originalValue === 'function') {
585
+ return createStateComputed(wrapped, () => originalValue.call(wrapped), !!context?.deferComputed)
586
+ }
587
+ if (isGetSetDescriptor(originalValue)) {
588
+ if (typeof originalValue.get === 'function') {
589
+ return createStateComputed(wrapped, () => originalValue.get.call(wrapped), !!context?.deferComputed)
590
+ }
591
+ return signal(undefined)
592
+ }
593
+ if (typeof originalValue === 'object' && originalValue !== null) {
594
+ const nestedState = initializeSignals(originalValue, undefined, getChildContext())
595
+ const sig = signal(nestedState)
596
+ if (nestedState && (nestedState as any).__isState) {
597
+ stateRootMap.set(nestedState, stateRootMap.get(wrapped) ?? wrapped)
598
+ }
599
+ linkArrayParentSignal(nestedState, sig)
600
+ return sig
601
+ }
602
+ return toSignal(originalValue)
603
+ }
604
+
605
+ const ensurePropertySignal = (target: any, prop: string | symbol, key: string) => {
606
+ if (!nestedSignalMap.has(key)) {
607
+ const originalValue = Reflect.get(target, prop)
608
+ if (originalValue === undefined) return undefined
609
+ nestedSignalMap.set(key, createPropertySignal(originalValue))
610
+ }
611
+ return nestedSignalMap.get(key)
612
+ }
613
+ wrapped = new Proxy(obj, {
558
614
  get(target, prop) {
559
615
  if (prop === '__originalKeys') return originalKeys
560
616
  if (prop === '__isState') return true
@@ -581,143 +637,25 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
581
637
  if (propStr.startsWith('$') && propStr.length > 1) {
582
638
  const key = propStr.slice(1) // Remove $ prefix
583
639
 
584
- // Ensure signal exists - initialize if needed
585
- // Use the same initialization logic as regular property access
586
- if (!nestedSignalMap.has(key)) {
587
- // First try to get from target (original object)
588
- const originalValue = Reflect.get(target, key)
589
- if (originalValue !== undefined) {
590
- if (typeof originalValue === 'function') {
591
- const computedSig = createStateComputed(
592
- wrapped,
593
- () => originalValue.call(wrapped),
594
- !!context?.deferComputed,
595
- )
596
- nestedSignalMap.set(key, computedSig)
597
- } else if (isGetSetDescriptor(originalValue)) {
598
- // Get/set descriptor -> computed signal from get function
599
- if (typeof originalValue.get === 'function') {
600
- const computedSig = createStateComputed(
601
- wrapped,
602
- () => originalValue.get.call(wrapped),
603
- !!context?.deferComputed,
604
- )
605
- nestedSignalMap.set(key, computedSig)
606
- } else {
607
- // Only setter, no getter - treat as regular signal with undefined initial value
608
- const sig = signal(undefined)
609
- nestedSignalMap.set(key, sig)
610
- }
611
- } else if (typeof originalValue === 'object' && originalValue !== null) {
612
- // Get the already-wrapped state from the wrapped object
613
- // Don't call initializeSignals again as it would create a new wrapped array
614
- const nestedState = (wrapped as any)[key]
615
- if (nestedState === undefined) {
616
- // Fallback: initialize if not already wrapped
617
- const childContext = context
618
- ? {...context, rootState: stateRootMap.get(wrapped) ?? wrapped}
619
- : undefined
620
- const initialized = initializeSignals(originalValue, undefined, childContext)
621
- const sig = signal(initialized)
622
- if (Array.isArray(initialized)) {
623
- arrayParentSignalMap.set(initialized, sig)
624
- }
625
- nestedSignalMap.set(key, sig)
626
- } else {
627
- const sig = signal(nestedState)
628
- // Store parent signal reference for arrays
629
- if (Array.isArray(nestedState)) {
630
- arrayParentSignalMap.set(nestedState, sig)
631
- }
632
- nestedSignalMap.set(key, sig)
633
- }
634
- } else {
635
- const sig = toSignal(originalValue)
636
- nestedSignalMap.set(key, sig)
637
- }
638
- } else {
639
- // Property doesn't exist - return undefined
640
- return undefined
641
- }
640
+ const sig = ensurePropertySignal(target, key, key)
641
+ if (!sig) return undefined
642
+
643
+ // Re-link array-backed values so mutations (splice, push) notify subscribers.
644
+ // Needed when $prop is accessed before .prop (e.g. watcher setup before render).
645
+ if (sig && !(sig instanceof ComputedSignal)) {
646
+ linkArrayParentSignal((sig as Signal<any>).peek(), sig)
642
647
  }
643
648
 
644
649
  // Return raw signal object (not the value)
645
- return nestedSignalMap.get(key)
650
+ return sig
646
651
  }
647
652
 
648
653
  const key = propStr
649
654
 
650
- // Check if we have a signal for this property
651
- if (!nestedSignalMap.has(key)) {
652
- // Try to get from target first (original object properties)
653
- const originalValue = Reflect.get(target, prop)
654
- if (originalValue !== undefined) {
655
- // Initialize signal for this property
656
- if (typeof originalValue === 'function') {
657
- // Function property -> computed signal
658
- const computedSig = createStateComputed(
659
- wrapped,
660
- () => originalValue.call(wrapped),
661
- !!context?.deferComputed,
662
- )
663
- nestedSignalMap.set(key, computedSig)
664
- } else if (isGetSetDescriptor(originalValue)) {
665
- // Get/set descriptor -> computed signal from get function
666
- if (typeof originalValue.get === 'function') {
667
- const computedSig = createStateComputed(
668
- wrapped,
669
- () => originalValue.get.call(wrapped),
670
- !!context?.deferComputed,
671
- )
672
- nestedSignalMap.set(key, computedSig)
673
- } else {
674
- // Only setter, no getter - treat as regular signal with undefined initial value
675
- const sig = signal(undefined)
676
- nestedSignalMap.set(key, sig)
677
- }
678
- } else if (typeof originalValue === 'object' && originalValue !== null) {
679
- // Nested object -> recursive state with its own signalMap
680
- const childContext = context
681
- ? {...context, rootState: stateRootMap.get(wrapped) ?? wrapped}
682
- : undefined
683
- const nestedState = initializeSignals(originalValue, undefined, childContext)
684
- const sig = signal(nestedState)
685
- // Store parent signal reference for arrays
686
- if (Array.isArray(nestedState)) {
687
- arrayParentSignalMap.set(nestedState, sig)
688
- }
689
- nestedSignalMap.set(key, sig)
690
- } else {
691
- // Primitive value -> signal
692
- const sig = toSignal(originalValue)
693
- nestedSignalMap.set(key, sig)
694
- }
695
- } else {
696
- // Property doesn't exist in original object
697
- // Check if it's a computed property that was added dynamically
698
- // For now, return undefined
699
- }
700
- }
701
-
702
- const sig = nestedSignalMap.get(key)
655
+ const sig = ensurePropertySignal(target, prop, key)
703
656
  if (sig) {
704
657
  // Access signal.value to track component dependency
705
- const value = sig.value
706
- // Always ensure parent signal is stored for arrays (in case it wasn't stored during initialization)
707
- // Check for wrapped arrays by looking for __isState and __signals properties
708
- // Array.isArray() may return false for Proxies, so we check __isState instead
709
- if (value && typeof value === 'object') {
710
- if ((value as any).__isState === true && Array.isArray((value as any).__signals)) {
711
- // This is a wrapped array - store parent signal
712
- arrayParentSignalMap.set(value, sig as Signal<any>)
713
- // Also store directly on the Proxy as a fallback
714
- ;(value as any)._parentSignal = sig as Signal<any>
715
- } else if (Array.isArray(value)) {
716
- // Regular array (shouldn't happen but just in case)
717
- arrayParentSignalMap.set(value, sig as Signal<any>)
718
- }
719
- }
720
- return value
658
+ return sig.value
721
659
  }
722
660
 
723
661
  // Fallback to original property
@@ -786,47 +724,22 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
786
724
  const sig = nestedSignalMap.get(key)
787
725
  if (sig && !(sig instanceof ComputedSignal)) {
788
726
  if (typeof value === 'object' && value !== null) {
789
- // Nested object -> recursive state with its own signalMap
790
- const childContext = context
791
- ? {...context, rootState: stateRootMap.get(wrapped) ?? wrapped}
792
- : undefined
793
- const nestedState = initializeSignals(value, undefined, childContext)
794
- // Store parent signal reference for arrays
795
- if (Array.isArray(nestedState)) {
796
- arrayParentSignalMap.set(nestedState, sig as Signal<any>)
727
+ const nestedState = initializeSignals(value, undefined, getChildContext())
728
+ if (nestedState && (nestedState as any).__isState) {
729
+ stateRootMap.set(nestedState, stateRootMap.get(wrapped) ?? wrapped)
797
730
  }
731
+ linkArrayParentSignal(nestedState, sig)
798
732
  ;(sig as Signal<any>).value = nestedState
799
733
  } else {
800
734
  ;(sig as Signal<any>).value = value
801
735
  }
802
736
  } else {
803
737
  // Replace computed with regular signal
804
- if (typeof value === 'object' && value !== null && Array.isArray(value)) {
805
- const childContext = context
806
- ? {...context, rootState: stateRootMap.get(wrapped) ?? wrapped}
807
- : undefined
808
- const nestedState = initializeSignals(value, undefined, childContext)
809
- const sig = signal(nestedState)
810
- arrayParentSignalMap.set(nestedState, sig)
811
- nestedSignalMap.set(key, sig)
812
- } else {
813
- nestedSignalMap.set(key, toSignal(value))
814
- }
738
+ nestedSignalMap.set(key, createPropertySignal(value))
815
739
  }
816
740
  } else {
817
741
  // Create new signal
818
- if (typeof value === 'object' && value !== null) {
819
- const childContext = context ? {...context, rootState: stateRootMap.get(wrapped) ?? wrapped} : undefined
820
- const nestedState = initializeSignals(value, undefined, childContext)
821
- const sig = signal(nestedState)
822
- // Store parent signal reference for arrays
823
- if (Array.isArray(nestedState)) {
824
- arrayParentSignalMap.set(nestedState, sig)
825
- }
826
- nestedSignalMap.set(key, sig)
827
- } else {
828
- nestedSignalMap.set(key, toSignal(value))
829
- }
742
+ nestedSignalMap.set(key, createPropertySignal(value))
830
743
  }
831
744
 
832
745
  return true
@@ -890,6 +803,11 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
890
803
 
891
804
  stateRootMap.set(wrapped, context?.rootState ?? wrapped)
892
805
  stateCache.set(obj, wrapped)
806
+ for (const key of originalKeys) {
807
+ if (!nestedSignalMap.has(key)) {
808
+ nestedSignalMap.set(key, createPropertySignal((obj as any)[key]))
809
+ }
810
+ }
893
811
  return wrapped
894
812
  }
895
813
 
@@ -900,61 +818,6 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
900
818
  stateDeferredFlags.set(wrapped, {allowed: false})
901
819
  }
902
820
 
903
- // Pre-initialize all signals from the initial object so they're available immediately
904
- // This ensures $s.$property works even if $s.property hasn't been accessed yet
905
- if (typeof initial === 'object' && initial !== null && !Array.isArray(initial)) {
906
- for (const key in initial) {
907
- if (Object.prototype.hasOwnProperty.call(initial, key)) {
908
- if (!signalMap.has(key)) {
909
- const value = initial[key]
910
- if (typeof value === 'function') {
911
- const computedSig = createStateComputed(wrapped, () => value.call(wrapped), deferComputed)
912
- signalMap.set(key, computedSig)
913
- } else if (isGetSetDescriptor(value)) {
914
- // Get/set descriptor -> computed signal from get function
915
- if (typeof value.get === 'function') {
916
- const computedSig = createStateComputed(wrapped, () => value.get.call(wrapped), deferComputed)
917
- signalMap.set(key, computedSig)
918
- } else {
919
- // Only setter, no getter - treat as regular signal with undefined initial value
920
- const sig = signal(undefined)
921
- signalMap.set(key, sig)
922
- }
923
- } else if (typeof value === 'object' && value !== null) {
924
- // Get the already-wrapped state from stateCache (bypass Proxy to get the actual wrapped value)
925
- // This ensures we get the same wrapped array that was created during initializeSignals
926
- const preInitContext = initContext ? {...initContext, rootState: wrapped} : undefined
927
- const nestedState = stateCache.has(value)
928
- ? stateCache.get(value)
929
- : initializeSignals(value, undefined, preInitContext)
930
- if (nestedState && (nestedState as any).__isState) stateRootMap.set(nestedState, wrapped)
931
- const sig = signal(nestedState)
932
- // Always store parent signal reference for arrays
933
- // Check for wrapped arrays by looking for __isState and __signals properties
934
- // Array.isArray() may return false for Proxies, so we check __isState instead
935
- if (
936
- nestedState &&
937
- typeof nestedState === 'object' &&
938
- (nestedState as any).__isState === true &&
939
- Array.isArray((nestedState as any).__signals)
940
- ) {
941
- arrayParentSignalMap.set(nestedState, sig)
942
- // Also store directly on the Proxy as a fallback
943
- ;(nestedState as any)._parentSignal = sig
944
- } else if (Array.isArray(nestedState)) {
945
- // Fallback for regular arrays (shouldn't happen but just in case)
946
- arrayParentSignalMap.set(nestedState, sig)
947
- }
948
- signalMap.set(key, sig)
949
- } else {
950
- const sig = toSignal(value)
951
- signalMap.set(key, sig)
952
- }
953
- }
954
- }
955
- }
956
- }
957
-
958
821
  // Register state for SSR serialization when name is provided (required for hydration)
959
822
  if (name && typeof name === 'string' && name.trim() !== '') {
960
823
  registerState(name, wrapped, initial)
@@ -969,7 +832,7 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
969
832
  * - Nested objects: $prop => Signal<State<T[K]>>
970
833
  * - Functions: $prop => ComputedSignal (computed from getter)
971
834
  */
972
- type StateSignals<T extends Record<string, any>> = {
835
+ export type StateSignals<T extends Record<string, any>> = {
973
836
  [K in keyof T as K extends string ? `$${K}` : never]: T[K] extends (...args: any[]) => any
974
837
  ? ComputedSignal<any>
975
838
  : T[K] extends object