@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 +25 -14
- package/api/router.ts +63 -26
- package/index.ts +1 -1
- package/package.json +10 -2
- package/state.ts +80 -217
package/README.md
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Mithril Bitstillery
|
|
2
2
|
|
|
3
|
-
Mithril
|
|
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
|
-
##
|
|
9
|
+
## Strengths
|
|
10
10
|
|
|
11
|
-
Mithril
|
|
11
|
+
Mithril Bitstillery focuses on **state management**, **SSR**, **watchers**, and **developer experience** around its signal/proxy store:
|
|
12
12
|
|
|
13
|
-
| Feature |
|
|
14
|
-
| ---------------- |
|
|
15
|
-
|
|
|
16
|
-
| State Management |
|
|
17
|
-
| SSR |
|
|
18
|
-
|
|
|
19
|
-
|
|
|
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
|
-
|
|
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
|
|
93
|
-
// route
|
|
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
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
"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 = [
|
|
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
650
|
+
return sig
|
|
646
651
|
}
|
|
647
652
|
|
|
648
653
|
const key = propStr
|
|
649
654
|
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|