@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/api/mount-redraw.ts +173 -0
- package/api/router-server.ts +88 -0
- package/api/router.ts +670 -0
- package/browser.ts +10 -0
- package/htm.ts +7 -0
- package/hyperscript.ts +8 -0
- package/index.ts +134 -0
- package/jsx.d.ts +16 -0
- package/mount.ts +10 -0
- package/package.json +66 -0
- package/pathname/build.ts +40 -0
- package/pathname/compileTemplate.ts +49 -0
- package/pathname/parse.ts +19 -0
- package/querystring/build.ts +22 -0
- package/querystring/parse.ts +50 -0
- package/redraw.ts +10 -0
- package/render/cachedAttrsIsStaticMap.ts +10 -0
- package/render/delayedRemoval.ts +1 -0
- package/render/domFor.ts +23 -0
- package/render/emptyAttrs.ts +2 -0
- package/render/fragment.ts +11 -0
- package/render/hyperscript.ts +125 -0
- package/render/hyperscriptVnode.ts +21 -0
- package/render/render.ts +1307 -0
- package/render/renderToString.ts +494 -0
- package/render/ssrState.ts +366 -0
- package/render/trust.ts +6 -0
- package/render/vnode.ts +131 -0
- package/route.ts +11 -0
- package/server/logger.ts +223 -0
- package/server/session.ts +87 -0
- package/server/ssr.ts +205 -0
- package/server/ssrLogger.ts +13 -0
- package/server.ts +101 -0
- package/signal.ts +320 -0
- package/ssrContext.ts +87 -0
- package/state.ts +1026 -0
- package/store.ts +457 -0
- package/util/censor.ts +46 -0
- package/util/decodeURIComponentSafe.ts +34 -0
- package/util/hasOwn.ts +2 -0
- package/util/next_tick.ts +45 -0
- package/util/ssr.ts +385 -0
- package/util/uri.ts +126 -0
- 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
|
+
}
|