@bitstillery/mithril 3.0.0-AA

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/api/mount-redraw.ts +173 -0
  4. package/api/router-server.ts +88 -0
  5. package/api/router.ts +670 -0
  6. package/browser.ts +10 -0
  7. package/htm.ts +7 -0
  8. package/hyperscript.ts +8 -0
  9. package/index.ts +134 -0
  10. package/jsx.d.ts +16 -0
  11. package/mount.ts +10 -0
  12. package/package.json +66 -0
  13. package/pathname/build.ts +40 -0
  14. package/pathname/compileTemplate.ts +49 -0
  15. package/pathname/parse.ts +19 -0
  16. package/querystring/build.ts +22 -0
  17. package/querystring/parse.ts +50 -0
  18. package/redraw.ts +10 -0
  19. package/render/cachedAttrsIsStaticMap.ts +10 -0
  20. package/render/delayedRemoval.ts +1 -0
  21. package/render/domFor.ts +23 -0
  22. package/render/emptyAttrs.ts +2 -0
  23. package/render/fragment.ts +11 -0
  24. package/render/hyperscript.ts +125 -0
  25. package/render/hyperscriptVnode.ts +21 -0
  26. package/render/render.ts +1307 -0
  27. package/render/renderToString.ts +494 -0
  28. package/render/ssrState.ts +366 -0
  29. package/render/trust.ts +6 -0
  30. package/render/vnode.ts +131 -0
  31. package/route.ts +11 -0
  32. package/server/logger.ts +223 -0
  33. package/server/session.ts +87 -0
  34. package/server/ssr.ts +205 -0
  35. package/server/ssrLogger.ts +13 -0
  36. package/server.ts +101 -0
  37. package/signal.ts +320 -0
  38. package/ssrContext.ts +87 -0
  39. package/state.ts +1026 -0
  40. package/store.ts +457 -0
  41. package/util/censor.ts +46 -0
  42. package/util/decodeURIComponentSafe.ts +34 -0
  43. package/util/hasOwn.ts +2 -0
  44. package/util/next_tick.ts +45 -0
  45. package/util/ssr.ts +385 -0
  46. package/util/uri.ts +126 -0
  47. package/version.ts +3 -0
package/state.ts ADDED
@@ -0,0 +1,1026 @@
1
+ import {signal, computed, Signal, ComputedSignal} from './signal'
2
+ import {getSSRContext} from './ssrContext'
3
+
4
+ // WeakMap to store parent signal references for arrays
5
+ const arrayParentSignalMap = new WeakMap<any, Signal<any>>()
6
+
7
+ // Deferred computed evaluation (ADR-0013): gate computeds until allowComputed() is called
8
+ const stateDeferredFlags = new WeakMap<object, {allowed: boolean}>()
9
+ // Store __rootState in a WeakMap to avoid proxy get recursion when reading (wrapped as any).__rootState
10
+ const stateRootMap = new WeakMap<object, any>()
11
+
12
+ function getDeferredAllowed(stateObj: any): boolean {
13
+ const root = (stateObj && (stateRootMap.get(stateObj) ?? stateObj)) || stateObj
14
+ const flags = root ? stateDeferredFlags.get(root) : undefined
15
+ return !flags || flags.allowed
16
+ }
17
+
18
+ function createStateComputed<T>(wrapped: any, computeFn: () => T, shouldDefer: boolean): ComputedSignal<T> {
19
+ if (!shouldDefer) return computed(computeFn)
20
+ return computed(() => {
21
+ if (!getDeferredAllowed(wrapped)) return undefined as T
22
+ return computeFn()
23
+ })
24
+ }
25
+
26
+ /** Recursively mark all ComputedSignals in a state tree dirty (used when opening deferred gate). */
27
+ function markAllComputedsDirty(stateObj: any): void {
28
+ if (!stateObj || !(stateObj as any).__isState) return
29
+ const signalMap = (stateObj as any).__signalMap
30
+ if (!signalMap || !(signalMap instanceof Map)) return
31
+ signalMap.forEach((sig: any) => {
32
+ if (sig instanceof ComputedSignal) {
33
+ sig.markDirty()
34
+ } else if (sig && typeof sig === 'object' && sig.value && (sig.value as any).__isState) {
35
+ markAllComputedsDirty(sig.value)
36
+ }
37
+ })
38
+ }
39
+
40
+ // Type guard to check if value is a Signal
41
+ function isSignal<T>(value: any): value is Signal<T> {
42
+ return value instanceof Signal || value instanceof ComputedSignal
43
+ }
44
+
45
+ // Type guard to check if value is already a state (has been wrapped)
46
+ function isState(value: any): boolean {
47
+ return value && typeof value === 'object' && (value as any).__isState === true
48
+ }
49
+
50
+ /**
51
+ * Check if a value is a get/set descriptor object (like JavaScript property descriptors)
52
+ * Used to detect computed properties defined as { get: () => T, set?: (value: T) => void }
53
+ */
54
+ function isGetSetDescriptor(value: any): boolean {
55
+ return value && typeof value === 'object' && (typeof value.get === 'function' || typeof value.set === 'function')
56
+ }
57
+
58
+ /**
59
+ * Convert a value to a signal if it's not already one
60
+ */
61
+ function toSignal<T>(value: T): Signal<T> | ComputedSignal<T> {
62
+ if (isSignal(value)) {
63
+ return value as Signal<T> | ComputedSignal<T>
64
+ }
65
+ if (typeof value === 'function') {
66
+ // Function properties become computed signals
67
+ return computed(value as () => T)
68
+ }
69
+ return signal(value)
70
+ }
71
+
72
+ // State registry for SSR serialization
73
+ // Stores both state instance and original initial state (with computed properties)
74
+ interface StateRegistryEntry {
75
+ state: any
76
+ initial: any
77
+ }
78
+
79
+ const globalStateRegistry = new Map<string, StateRegistryEntry>()
80
+
81
+ /**
82
+ * Returns the registry to use: per-request registry when inside an SSR
83
+ * runWithContext(), otherwise the global registry (client or tests).
84
+ */
85
+ function getCurrentStateRegistry(): Map<string, StateRegistryEntry> {
86
+ const ctx = getSSRContext()
87
+ if (ctx?.stateRegistry) {
88
+ return ctx.stateRegistry as Map<string, StateRegistryEntry>
89
+ }
90
+ return globalStateRegistry
91
+ }
92
+
93
+ /**
94
+ * Register a state for SSR serialization
95
+ * Called automatically when state is created with a name
96
+ * @param name - Unique name for the state
97
+ * @param stateInstance - The state instance to register
98
+ * @param initial - Original initial state (with computed properties) for restoration
99
+ */
100
+ export function registerState(name: string, stateInstance: any, initial: any): void {
101
+ if (!name || typeof name !== 'string' || name.trim() === '') {
102
+ throw new Error('State name is required and must be a non-empty string')
103
+ }
104
+
105
+ const registry = getCurrentStateRegistry()
106
+
107
+ // Warn in development if name collision detected
108
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
109
+ if (registry.has(name)) {
110
+ console.warn(`State name collision detected: "${name}". Last registered state will be used.`)
111
+ }
112
+ }
113
+
114
+ registry.set(name, {state: stateInstance, initial})
115
+ }
116
+
117
+ /**
118
+ * Update the registry entry for an existing state
119
+ * Used by Store to update its "initial" state after load() is called
120
+ * @param stateInstance - The state instance to update
121
+ * @param initial - New initial state (merged templates for Store)
122
+ */
123
+ export function updateStateRegistry(stateInstance: any, initial: any): void {
124
+ const registry = getCurrentStateRegistry()
125
+ // Find the registry entry for this state and update its initial value
126
+ for (const [name, entry] of registry.entries()) {
127
+ if (entry.state === stateInstance) {
128
+ registry.set(name, {state: stateInstance, initial})
129
+ return
130
+ }
131
+ }
132
+ // If not found, this is an error case - state should be registered
133
+ throw new Error('State instance not found in registry. State must be registered before updating.')
134
+ }
135
+
136
+ /**
137
+ * Get all registered states
138
+ * Returns Map of state names to registry entries (state instance and initial state)
139
+ */
140
+ export function getRegisteredStates(): Map<string, StateRegistryEntry> {
141
+ return getCurrentStateRegistry()
142
+ }
143
+
144
+ /**
145
+ * Copy states from global registry to SSR context.
146
+ * Used when app modules load at startup (registering to global) but SSR needs
147
+ * them in the per-request context for serialization.
148
+ */
149
+ export function copyGlobalStatesToContext(context: {stateRegistry: Map<string, StateRegistryEntry>}): void {
150
+ for (const [name, entry] of globalStateRegistry.entries()) {
151
+ context.stateRegistry.set(name, entry)
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Clear the state registry (useful for testing or after serialization).
157
+ * Clears the current registry (per-request in SSR, global on client).
158
+ */
159
+ export function clearStateRegistry(): void {
160
+ getCurrentStateRegistry().clear()
161
+ }
162
+
163
+ export interface StateOptions {
164
+ /** When true, computed properties are not evaluated until allowComputed() is called (ADR-0013). */
165
+ deferComputed?: boolean
166
+ }
167
+
168
+ /**
169
+ * Deep signal state - wraps objects/arrays with Proxy to make them reactive
170
+ * @param initial - Initial state object
171
+ * @param name - Optional name for SSR serialization/hydration. When omitted, state is not registered (suitable for client-only apps).
172
+ * @param options - Optional. deferComputed: when true, computeds return undefined until allowComputed() is called.
173
+ */
174
+ export function state<T extends Record<string, any>>(initial: T, name?: string, options?: StateOptions): State<T> {
175
+ const signalMap = new Map<string, Signal<any> | ComputedSignal<any>>()
176
+ const stateCache = new WeakMap<object, any>()
177
+ const deferComputed = !!options?.deferComputed
178
+
179
+ // Context passed through recursive initializeSignals for deferred computeds and root reference
180
+ interface InitContext {
181
+ deferComputed: boolean
182
+ rootState?: any
183
+ }
184
+
185
+ // Convert initial values to signals
186
+ // parentSignalMap is optional - if provided, nested states will use it
187
+ // If not provided, each nested state gets its own signalMap
188
+ function initializeSignals(
189
+ obj: any,
190
+ parentSignalMap?: Map<string, Signal<any> | ComputedSignal<any>>,
191
+ context?: InitContext,
192
+ ): any {
193
+ if (obj === null || typeof obj !== 'object') {
194
+ return obj
195
+ }
196
+
197
+ // Check if already wrapped
198
+ if (isState(obj)) {
199
+ return obj
200
+ }
201
+
202
+ // Check cache
203
+ if (stateCache.has(obj)) {
204
+ return stateCache.get(obj)
205
+ }
206
+
207
+ // Handle arrays
208
+ if (Array.isArray(obj)) {
209
+ // Arrays don't get their own signalMap - they use the parent's
210
+ // Nested objects AND arrays should be recursively wrapped
211
+ const signals = obj.map((item: any) => {
212
+ if (typeof item === 'object' && item !== null) {
213
+ // Recursively wrap nested objects AND arrays in Proxies
214
+ return initializeSignals(item, undefined, context)
215
+ }
216
+ return toSignal(item)
217
+ })
218
+
219
+ // List of mutating array methods that should trigger the parent signal
220
+ const mutatingMethods = new Set([
221
+ 'splice',
222
+ 'push',
223
+ 'pop',
224
+ 'shift',
225
+ 'unshift',
226
+ 'reverse',
227
+ 'sort',
228
+ 'fill',
229
+ 'copyWithin',
230
+ ])
231
+
232
+ // Wrap the signals array directly (not a copy) so mutations stay in sync
233
+ // Store parent signal reference directly on the Proxy for reliable lookup
234
+ const wrapped = new Proxy(signals, {
235
+ get(target, prop) {
236
+ if (prop === '__isState') return true
237
+ if (prop === '__signals') return signals
238
+ if (prop === '__parentSignal') {
239
+ // Allow accessing parent signal directly for debugging
240
+ return arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
241
+ }
242
+ if (prop === Symbol.toStringTag) return 'Array' // Make Array.isArray() work
243
+ if (prop === Symbol.iterator) {
244
+ // Provide custom iterator that unwraps Signal values
245
+ return function* () {
246
+ for (let i = 0; i < signals.length; i++) {
247
+ const sig = signals[i]
248
+ yield isSignal(sig) ? sig.value : sig
249
+ }
250
+ }
251
+ }
252
+ if (prop === 'length') return signals.length
253
+
254
+ const propStr = String(prop)
255
+
256
+ // Check for $ prefix convention (deepsignal-style: returns raw signal)
257
+ if (propStr.startsWith('$') && propStr.length > 1) {
258
+ const indexStr = propStr.slice(1)
259
+ if (!isNaN(Number(indexStr))) {
260
+ const index = Number(indexStr)
261
+ if (index >= 0 && index < signals.length) {
262
+ const sig = signals[index]
263
+ return isSignal(sig) ? sig : sig
264
+ }
265
+ }
266
+ return undefined
267
+ }
268
+
269
+ if (typeof prop === 'string' && !isNaN(Number(prop))) {
270
+ const index = Number(prop)
271
+ if (index >= 0 && index < signals.length) {
272
+ const sig = signals[index]
273
+ return isSignal(sig) ? sig.value : sig
274
+ }
275
+ }
276
+
277
+ const value = Reflect.get(target, prop)
278
+
279
+ // For array methods that iterate (map, filter, forEach, etc.), bind to wrapped Proxy
280
+ // so they go through our get trap for element access
281
+ if (typeof value === 'function' && Array.isArray(target)) {
282
+ // Array iteration methods need to use the Proxy so element access is unwrapped
283
+ const iterationMethods = [
284
+ 'map',
285
+ 'filter',
286
+ 'forEach',
287
+ 'some',
288
+ 'every',
289
+ 'find',
290
+ 'findIndex',
291
+ 'reduce',
292
+ 'reduceRight',
293
+ ]
294
+ // Search methods also need unwrapped values for comparison
295
+ const searchMethods = ['includes', 'indexOf', 'lastIndexOf']
296
+ // Methods that return new arrays or strings need unwrapped values
297
+ const returnMethods = ['slice', 'concat', 'flat', 'flatMap', 'join', 'toString', 'toLocaleString']
298
+ // Iterator methods need unwrapped values
299
+ const iteratorMethods = ['entries', 'keys', 'values']
300
+ if (
301
+ iterationMethods.includes(propStr) ||
302
+ searchMethods.includes(propStr) ||
303
+ returnMethods.includes(propStr) ||
304
+ iteratorMethods.includes(propStr)
305
+ ) {
306
+ return value.bind(wrapped)
307
+ }
308
+ }
309
+
310
+ // Intercept mutating methods to trigger parent signal
311
+ if (typeof value === 'function' && mutatingMethods.has(propStr)) {
312
+ return function (...args: any[]) {
313
+ // For splice, we need to handle it specially to convert new items to signals
314
+ if (propStr === 'splice') {
315
+ const start = args[0] ?? 0
316
+ const deleteCount = args[1] ?? signals.length - start
317
+ const newItems = args.slice(2)
318
+
319
+ // Convert new items - nested arrays/objects become Proxies, primitives become Signals
320
+ const newSignals = newItems.map((item: any) => {
321
+ if (typeof item === 'object' && item !== null) {
322
+ // Wrap objects/arrays in Proxies (NOT in Signals - the Proxy IS the value)
323
+ return initializeSignals(item, undefined, context)
324
+ }
325
+ return toSignal(item)
326
+ })
327
+
328
+ // Update the signals array (target is signals array)
329
+ const removed = signals.splice(start, deleteCount, ...newSignals)
330
+
331
+ // Look up parent signal AFTER mutation to ensure we have the latest reference
332
+ // This ensures we get the signal even if it was stored after accessing the method
333
+ // Try WeakMap first, then fallback to direct property access
334
+ const parentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
335
+
336
+ // Trigger parent signal if it exists
337
+ // Notify subscribers directly since the array reference hasn't changed
338
+ if (parentSignal) {
339
+ // Access the signal's internal subscribers and notify them
340
+ const subscribers = (parentSignal as any)._subscribers
341
+ if (subscribers) {
342
+ subscribers.forEach((fn: () => void) => {
343
+ try {
344
+ fn()
345
+ } catch (e) {
346
+ console.error('Error in signal subscriber:', e)
347
+ }
348
+ })
349
+ }
350
+ // Also trigger component redraws if callback is set
351
+ // __redrawCallback is set on the signal function itself
352
+ if ((signal as any).__redrawCallback) {
353
+ ;(signal as any).__redrawCallback(parentSignal)
354
+ }
355
+ }
356
+
357
+ // Return removed items (unwrapped)
358
+ return removed.map((sig) => (isSignal(sig) ? sig.value : sig))
359
+ } else {
360
+ // For other mutating methods, convert new items to signals first
361
+ let result
362
+ if (propStr === 'push' || propStr === 'unshift') {
363
+ const newItems = args
364
+ // Convert new items - nested arrays/objects become Proxies, primitives become Signals
365
+ const newSignals = newItems.map((item: any) => {
366
+ if (typeof item === 'object' && item !== null) {
367
+ // Wrap objects/arrays in Proxies (NOT in Signals - the Proxy IS the value)
368
+ return initializeSignals(item, undefined, context)
369
+ }
370
+ return toSignal(item)
371
+ })
372
+ if (propStr === 'push') {
373
+ result = signals.push(...newSignals)
374
+ } else {
375
+ result = signals.unshift(...newSignals)
376
+ }
377
+ } else if (propStr === 'pop' || propStr === 'shift') {
378
+ // Call on signals array directly and unwrap result
379
+ if (propStr === 'pop') {
380
+ const sig = signals.pop()
381
+ result = sig !== undefined ? (isSignal(sig) ? sig.value : sig) : undefined
382
+ } else {
383
+ const sig = signals.shift()
384
+ result = sig !== undefined ? (isSignal(sig) ? sig.value : sig) : undefined
385
+ }
386
+ } else if (propStr === 'reverse' || propStr === 'sort') {
387
+ // For reverse/sort, apply to signals array
388
+ if (propStr === 'reverse') {
389
+ signals.reverse()
390
+ } else {
391
+ // sort needs a comparator function that works on signals
392
+ const comparator = args[0]
393
+ if (comparator) {
394
+ signals.sort((a, b) => {
395
+ const aVal = isSignal(a) ? a.value : a
396
+ const bVal = isSignal(b) ? b.value : b
397
+ return comparator(aVal, bVal)
398
+ })
399
+ } else {
400
+ signals.sort((a, b) => {
401
+ const aVal = isSignal(a) ? a.value : a
402
+ const bVal = isSignal(b) ? b.value : b
403
+ return aVal < bVal ? -1 : aVal > bVal ? 1 : 0
404
+ })
405
+ }
406
+ }
407
+ // Return wrapped Proxy (not raw signals array) so chained calls like
408
+ // .sort().map() receive unwrapped values in the callback
409
+ result = wrapped
410
+ } else if (propStr === 'fill') {
411
+ const fillValue = args[0]
412
+ const start = args[1] ?? 0
413
+ const end = args[2] ?? signals.length
414
+ const fillSignal = toSignal(fillValue)
415
+ for (let i = start; i < end; i++) {
416
+ signals[i] = fillSignal
417
+ }
418
+ result = signals.length
419
+ } else {
420
+ // For other methods, just apply to target
421
+ result = value.apply(target, args)
422
+ }
423
+
424
+ // Trigger parent signal if it exists (look up again in case it was stored)
425
+ const currentParentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
426
+ if (currentParentSignal) {
427
+ // Notify subscribers directly since the array reference hasn't changed
428
+ const subscribers = (currentParentSignal as any)._subscribers
429
+ if (subscribers) {
430
+ subscribers.forEach((fn: () => void) => {
431
+ try {
432
+ fn()
433
+ } catch (e) {
434
+ console.error('Error in signal subscriber:', e)
435
+ }
436
+ })
437
+ }
438
+ // Also trigger component redraws if callback is set
439
+ // __redrawCallback is set on the signal function itself
440
+ if ((signal as any).__redrawCallback) {
441
+ ;(signal as any).__redrawCallback(currentParentSignal)
442
+ }
443
+ }
444
+
445
+ return result
446
+ }
447
+ }
448
+ }
449
+
450
+ if (typeof value === 'function') {
451
+ return value.bind(target)
452
+ }
453
+ return value
454
+ },
455
+ set(target, prop, value) {
456
+ if (typeof prop === 'string' && !isNaN(Number(prop))) {
457
+ const index = Number(prop)
458
+ if (index >= 0 && index < signals.length) {
459
+ const sig = signals[index]
460
+ if (isSignal(sig)) {
461
+ sig.value = value
462
+ } else {
463
+ signals[index] = toSignal(value)
464
+ }
465
+ // Trigger parent signal on element assignment (look up when called)
466
+ const parentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
467
+ if (parentSignal) {
468
+ // Notify subscribers directly since the array reference hasn't changed
469
+ const subscribers = (parentSignal as any)._subscribers
470
+ if (subscribers) {
471
+ subscribers.forEach((fn: () => void) => {
472
+ try {
473
+ fn()
474
+ } catch (e) {
475
+ console.error('Error in signal subscriber:', e)
476
+ }
477
+ })
478
+ }
479
+ // Also trigger component redraws if callback is set
480
+ if ((signal as any).__redrawCallback) {
481
+ ;(signal as any).__redrawCallback(parentSignal)
482
+ }
483
+ }
484
+ return true
485
+ } else if (prop === 'length') {
486
+ signals.length = Number(value)
487
+ // Trigger parent signal on length change (look up when called)
488
+ const parentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
489
+ if (parentSignal) {
490
+ // Notify subscribers directly since the array reference hasn't changed
491
+ const subscribers = (parentSignal as any)._subscribers
492
+ if (subscribers) {
493
+ subscribers.forEach((fn: () => void) => {
494
+ try {
495
+ fn()
496
+ } catch (e) {
497
+ console.error('Error in signal subscriber:', e)
498
+ }
499
+ })
500
+ }
501
+ // Also trigger component redraws if callback is set
502
+ if ((signal as any).__redrawCallback) {
503
+ ;(signal as any).__redrawCallback(parentSignal)
504
+ }
505
+ }
506
+ return true
507
+ }
508
+ }
509
+ return Reflect.set(target, prop, value)
510
+ },
511
+ ownKeys(_target) {
512
+ // Return array indices as keys for proper enumeration (needed for Bun's toEqual)
513
+ const keys: (string | symbol)[] = []
514
+ for (let i = 0; i < signals.length; i++) {
515
+ keys.push(String(i))
516
+ }
517
+ keys.push('length')
518
+ return keys
519
+ },
520
+ getOwnPropertyDescriptor(target, prop) {
521
+ // Provide property descriptors for array indices (needed for Bun's toEqual)
522
+ if (typeof prop === 'string' && !isNaN(Number(prop))) {
523
+ const index = Number(prop)
524
+ if (index >= 0 && index < signals.length) {
525
+ return {
526
+ enumerable: true,
527
+ configurable: true,
528
+ value: (() => {
529
+ const sig = signals[index]
530
+ return isSignal(sig) ? sig.value : sig
531
+ })(),
532
+ writable: true,
533
+ }
534
+ }
535
+ }
536
+ if (prop === 'length') {
537
+ return {
538
+ enumerable: false,
539
+ configurable: false,
540
+ value: signals.length,
541
+ writable: true,
542
+ }
543
+ }
544
+ return Reflect.getOwnPropertyDescriptor(target, prop)
545
+ },
546
+ })
547
+ stateCache.set(obj, wrapped)
548
+ return wrapped
549
+ }
550
+
551
+ // Handle objects
552
+ // Store original keys for SSR serialization (to distinguish nested state keys from parent keys)
553
+ const originalKeys = new Set(Object.keys(obj))
554
+ // Each nested state gets its own signalMap (unless parentSignalMap is explicitly provided)
555
+ // This prevents nested states from sharing the parent's signalMap
556
+ const nestedSignalMap = parentSignalMap || new Map<string, Signal<any> | ComputedSignal<any>>()
557
+ const wrapped = new Proxy(obj, {
558
+ get(target, prop) {
559
+ if (prop === '__originalKeys') return originalKeys
560
+ if (prop === '__isState') return true
561
+ // Check if __signalMap was explicitly set to null (for error testing)
562
+ // If so, return null; otherwise return the nestedSignalMap
563
+ if (prop === '__signalMap') {
564
+ const explicitValue = Reflect.get(target, '__signalMap')
565
+ return explicitValue !== undefined ? explicitValue : nestedSignalMap
566
+ }
567
+ if (prop === '__rootState') return stateRootMap.get(wrapped) ?? wrapped
568
+ // ADR-0013: allowComputed() opens the deferred-computed gate and marks all computeds dirty
569
+ if (prop === 'allowComputed') {
570
+ return function allowComputed(this: any) {
571
+ const root = (this && (stateRootMap.get(this) ?? this)) || this
572
+ const flags = root ? stateDeferredFlags.get(root) : undefined
573
+ if (flags) flags.allowed = true
574
+ markAllComputedsDirty(root)
575
+ }
576
+ }
577
+
578
+ const propStr = String(prop)
579
+
580
+ // Check for $ prefix convention (deepsignal-style: returns raw signal)
581
+ if (propStr.startsWith('$') && propStr.length > 1) {
582
+ const key = propStr.slice(1) // Remove $ prefix
583
+
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
+ }
642
+ }
643
+
644
+ // Return raw signal object (not the value)
645
+ return nestedSignalMap.get(key)
646
+ }
647
+
648
+ const key = propStr
649
+
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)
703
+ if (sig) {
704
+ // 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
721
+ }
722
+
723
+ // Fallback to original property
724
+ return Reflect.get(target, prop)
725
+ },
726
+ set(target, prop, value) {
727
+ const key = String(prop)
728
+
729
+ // Allow setting __signalMap to null for testing error cases
730
+ // But we'll check if it's actually a Map when serializing/deserializing
731
+ if (key === '__signalMap') {
732
+ // Store the value directly on the target (bypass proxy)
733
+ // This allows tests to corrupt the state for error handling tests
734
+ Reflect.set(target, prop, value)
735
+ return true
736
+ }
737
+
738
+ // Prevent setting other internal properties
739
+ if (key === '__isState' || key === '__originalKeys' || key === '__signals') {
740
+ // Silently ignore attempts to set internal properties
741
+ return true
742
+ }
743
+
744
+ // Check if the original property was a get/set descriptor
745
+ const originalValue = Reflect.get(target, prop)
746
+ if (isGetSetDescriptor(originalValue)) {
747
+ // Handle get/set descriptor
748
+ if (typeof originalValue.set === 'function') {
749
+ // Call the setter function
750
+ originalValue.set.call(wrapped, value)
751
+ return true
752
+ } else if (typeof originalValue.get === 'function') {
753
+ // Read-only property (get but no set)
754
+ throw new Error(`Cannot set read-only computed property "${key}"`)
755
+ }
756
+ }
757
+
758
+ // Check if the new value being set is a get/set descriptor
759
+ if (isGetSetDescriptor(value)) {
760
+ // Replace with computed signal from get function
761
+ if (typeof value.get === 'function') {
762
+ const computedSig = createStateComputed(wrapped, () => value.get.call(wrapped), !!context?.deferComputed)
763
+ nestedSignalMap.set(key, computedSig)
764
+ // Also update the target so setter can be found later
765
+ Reflect.set(target, prop, value)
766
+ return true
767
+ } else {
768
+ // Only setter, no getter - treat as regular signal with undefined initial value
769
+ const sig = signal(undefined)
770
+ nestedSignalMap.set(key, sig)
771
+ Reflect.set(target, prop, value)
772
+ return true
773
+ }
774
+ }
775
+
776
+ // Skip computed properties (functions)
777
+ if (typeof value === 'function') {
778
+ // Replace computed signal
779
+ const computedSig = createStateComputed(wrapped, () => value.call(wrapped), !!context?.deferComputed)
780
+ nestedSignalMap.set(key, computedSig)
781
+ return true
782
+ }
783
+
784
+ // Update or create signal
785
+ if (nestedSignalMap.has(key)) {
786
+ const sig = nestedSignalMap.get(key)
787
+ if (sig && !(sig instanceof ComputedSignal)) {
788
+ 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>)
797
+ }
798
+ ;(sig as Signal<any>).value = nestedState
799
+ } else {
800
+ ;(sig as Signal<any>).value = value
801
+ }
802
+ } else {
803
+ // 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
+ }
815
+ }
816
+ } else {
817
+ // 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
+ }
830
+ }
831
+
832
+ return true
833
+ },
834
+ has(target, prop) {
835
+ if (prop === '__isState' || prop === '__signalMap') return true
836
+ const propStr = String(prop)
837
+ // Check for $ prefix
838
+ if (propStr.startsWith('$') && propStr.length > 1) {
839
+ const key = propStr.slice(1)
840
+ return nestedSignalMap.has(key) || Reflect.has(target, key)
841
+ }
842
+ return nestedSignalMap.has(propStr) || Reflect.has(target, prop)
843
+ },
844
+ ownKeys(target) {
845
+ const keys = new Set(Reflect.ownKeys(target))
846
+ nestedSignalMap.forEach((_, key) => {
847
+ keys.add(key)
848
+ keys.add('$' + key) // Also include $ prefix keys
849
+ })
850
+ return Array.from(keys)
851
+ },
852
+ getOwnPropertyDescriptor(target, prop) {
853
+ const propStr = String(prop)
854
+ // Handle $ prefix
855
+ if (propStr.startsWith('$') && propStr.length > 1) {
856
+ const key = propStr.slice(1)
857
+ if (nestedSignalMap.has(key)) {
858
+ return {
859
+ enumerable: false,
860
+ configurable: true,
861
+ }
862
+ }
863
+ }
864
+ if (nestedSignalMap.has(propStr)) {
865
+ return {
866
+ enumerable: true,
867
+ configurable: true,
868
+ }
869
+ }
870
+ return Reflect.getOwnPropertyDescriptor(target, prop)
871
+ },
872
+ deleteProperty(target, prop) {
873
+ const key = String(prop)
874
+
875
+ // Update the signal to undefined to notify subscribers
876
+ if (nestedSignalMap.has(key)) {
877
+ const sig = nestedSignalMap.get(key)
878
+ if (sig && !(sig instanceof ComputedSignal)) {
879
+ // Set signal value to undefined to notify subscribers
880
+ ;(sig as Signal<any>).value = undefined
881
+ }
882
+ // Remove from the signal map
883
+ nestedSignalMap.delete(key)
884
+ }
885
+
886
+ // Delete from target
887
+ return Reflect.deleteProperty(target, prop)
888
+ },
889
+ })
890
+
891
+ stateRootMap.set(wrapped, context?.rootState ?? wrapped)
892
+ stateCache.set(obj, wrapped)
893
+ return wrapped
894
+ }
895
+
896
+ const initContext: InitContext | undefined = deferComputed ? {deferComputed: true} : undefined
897
+ const wrapped = initializeSignals(initial, undefined, initContext) as State<T>
898
+ stateRootMap.set(wrapped, wrapped)
899
+ if (deferComputed) {
900
+ stateDeferredFlags.set(wrapped, {allowed: false})
901
+ }
902
+
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
+ // Register state for SSR serialization when name is provided (required for hydration)
959
+ if (name && typeof name === 'string' && name.trim() !== '') {
960
+ registerState(name, wrapped, initial)
961
+ }
962
+
963
+ return wrapped
964
+ }
965
+
966
+ /**
967
+ * Mapped type that adds $prop for each key, returning the Signal for that property.
968
+ * - Primitives: $prop => Signal<T[K]>
969
+ * - Nested objects: $prop => Signal<State<T[K]>>
970
+ * - Functions: $prop => ComputedSignal (computed from getter)
971
+ */
972
+ type StateSignals<T extends Record<string, any>> = {
973
+ [K in keyof T as K extends string ? `$${K}` : never]: T[K] extends (...args: any[]) => any
974
+ ? ComputedSignal<any>
975
+ : T[K] extends object
976
+ ? Signal<State<T[K]>>
977
+ : Signal<T[K]>
978
+ }
979
+
980
+ /**
981
+ * State type - reactive object with signal-based properties
982
+ *
983
+ * Supports:
984
+ * - Regular access: `state.prop` returns unwrapped value
985
+ * - Signal access: `state.$prop` returns Signal instance ($ prefix convention)
986
+ * - Functions become computed signals
987
+ * - Nested objects become State instances (recursively)
988
+ */
989
+ export type State<T extends Record<string, any>> = {
990
+ [K in keyof T]: T[K] extends (...args: any[]) => infer R ? R : T[K] extends Record<string, any> ? State<T[K]> : T[K]
991
+ } & StateSignals<T>
992
+
993
+ /**
994
+ * Watch a signal for changes
995
+ * @param signal - The signal to watch
996
+ * @param callback - Callback function called when signal value changes
997
+ * @returns Unsubscribe function
998
+ */
999
+ export function watch<T>(signal: Signal<T> | ComputedSignal<T>, callback: (newValue: T, oldValue: T) => void): () => void {
1000
+ const unwatch = signal.watch(callback)
1001
+
1002
+ // Register watcher in SSR context for cleanup at end of request
1003
+ if (globalThis.__SSR_MODE__) {
1004
+ const context = getSSRContext()
1005
+ if (context) {
1006
+ if (!context.watchers) {
1007
+ context.watchers = []
1008
+ }
1009
+ context.watchers.push(unwatch)
1010
+ // During SSR, fire watcher immediately with current value to catch any changes
1011
+ // that happened before watcher registration (e.g., from restore_filters_sort)
1012
+ // Use Promise.resolve().then() to defer execution until after unwatch is returned,
1013
+ // so callbacks that reference unwatch won't cause ReferenceError
1014
+ Promise.resolve().then(() => {
1015
+ try {
1016
+ const currentValue = signal.peek()
1017
+ callback(currentValue, currentValue)
1018
+ } catch (e) {
1019
+ console.error('Error firing initial watcher callback:', e)
1020
+ }
1021
+ })
1022
+ }
1023
+ }
1024
+
1025
+ return unwatch
1026
+ }