@fictjs/runtime 0.4.0 → 0.5.1
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/dist/advanced.cjs +10 -8
- package/dist/advanced.cjs.map +1 -1
- package/dist/advanced.d.cts +4 -3
- package/dist/advanced.d.ts +4 -3
- package/dist/advanced.js +10 -8
- package/dist/advanced.js.map +1 -1
- package/dist/{chunk-L4DIV3RC.cjs → chunk-4ZPZM5IG.cjs} +9 -7
- package/dist/chunk-4ZPZM5IG.cjs.map +1 -0
- package/dist/{chunk-XLIZJMMJ.js → chunk-5OYBRKE4.js} +8 -6
- package/dist/{chunk-XLIZJMMJ.js.map → chunk-5OYBRKE4.js.map} +1 -1
- package/dist/chunk-6RCEIWZL.cjs +2380 -0
- package/dist/chunk-6RCEIWZL.cjs.map +1 -0
- package/dist/chunk-7BO6P2KP.js +2380 -0
- package/dist/chunk-7BO6P2KP.js.map +1 -0
- package/dist/{chunk-TWELIZRY.js → chunk-AR6NSCZM.js} +5 -3
- package/dist/{chunk-TWELIZRY.js.map → chunk-AR6NSCZM.js.map} +1 -1
- package/dist/{chunk-M2TSXZ4C.cjs → chunk-LFMXNQZC.cjs} +18 -16
- package/dist/chunk-LFMXNQZC.cjs.map +1 -0
- package/dist/{chunk-SO6X7G5S.js → chunk-RY5CY4CI.js} +501 -1880
- package/dist/chunk-RY5CY4CI.js.map +1 -0
- package/dist/chunk-WJHXPF7M.cjs +2259 -0
- package/dist/chunk-WJHXPF7M.cjs.map +1 -0
- package/dist/{context-B25xyQrJ.d.cts → context-CTBE00S_.d.cts} +1 -1
- package/dist/{context-CGdP7_Jb.d.ts → context-lkLhbkFJ.d.ts} +1 -1
- package/dist/{effect-D6kaLM2-.d.cts → effect-BpSNEJJz.d.cts} +7 -67
- package/dist/{effect-D6kaLM2-.d.ts → effect-BpSNEJJz.d.ts} +7 -67
- package/dist/index.cjs +40 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.dev.js +125 -22
- package/dist/index.dev.js.map +1 -1
- package/dist/index.js +19 -17
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +202 -203
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +13 -23
- package/dist/internal.d.ts +13 -23
- package/dist/internal.js +207 -208
- package/dist/internal.js.map +1 -1
- package/dist/loader.cjs +280 -0
- package/dist/loader.cjs.map +1 -0
- package/dist/loader.d.cts +57 -0
- package/dist/loader.d.ts +57 -0
- package/dist/loader.js +280 -0
- package/dist/loader.js.map +1 -0
- package/dist/{props-BIfromL0.d.cts → props-XTHYD19o.d.cts} +13 -2
- package/dist/{props-BEgIVMRx.d.ts → props-x-HbI-jX.d.ts} +13 -2
- package/dist/resume-BrAkmSTY.d.cts +79 -0
- package/dist/resume-Dx8_l72o.d.ts +79 -0
- package/dist/{scope-CzNkn587.d.ts → scope-CdbGmsFf.d.ts} +1 -1
- package/dist/{scope-Cx_3CjIZ.d.cts → scope-DfcP9I-A.d.cts} +1 -1
- package/dist/signal-C4ISF17w.d.cts +66 -0
- package/dist/signal-C4ISF17w.d.ts +66 -0
- package/package.json +6 -1
- package/src/binding.ts +254 -5
- package/src/cycle-guard.ts +1 -1
- package/src/dom.ts +103 -5
- package/src/hooks.ts +15 -2
- package/src/hydration.ts +75 -0
- package/src/internal.ts +34 -2
- package/src/list-helpers.ts +127 -11
- package/src/loader.ts +437 -0
- package/src/node-ops.ts +65 -0
- package/src/resume.ts +517 -0
- package/src/signal.ts +47 -22
- package/src/store.ts +8 -0
- package/dist/chunk-ID3WBWNO.cjs +0 -3638
- package/dist/chunk-ID3WBWNO.cjs.map +0 -1
- package/dist/chunk-L4DIV3RC.cjs.map +0 -1
- package/dist/chunk-M2TSXZ4C.cjs.map +0 -1
- package/dist/chunk-SO6X7G5S.js.map +0 -1
package/src/resume.ts
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import type { HookContext } from './hooks'
|
|
2
|
+
import { createSignal, isSignal } from './signal'
|
|
3
|
+
import { createStore, isStoreProxy, unwrapStore } from './store'
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Serialization Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Type markers for serialized values.
|
|
11
|
+
* These allow us to preserve type information through JSON serialization.
|
|
12
|
+
*/
|
|
13
|
+
type SerializedMarker =
|
|
14
|
+
| { __t: 'd'; v: number } // Date (as timestamp)
|
|
15
|
+
| { __t: 'm'; v: [unknown, unknown][] } // Map (as entries array)
|
|
16
|
+
| { __t: 's'; v: unknown[] } // Set (as array)
|
|
17
|
+
| { __t: 'r'; v: { s: string; f: string } } // RegExp (source + flags)
|
|
18
|
+
| { __t: 'u' } // undefined
|
|
19
|
+
| { __t: 'n' } // NaN
|
|
20
|
+
| { __t: '+i' } // Infinity
|
|
21
|
+
| { __t: '-i' } // -Infinity
|
|
22
|
+
| { __t: 'b'; v: string } // BigInt (as string)
|
|
23
|
+
| { __t: 'ref'; v: string } // Circular reference (path)
|
|
24
|
+
|
|
25
|
+
export type SlotSnapshot =
|
|
26
|
+
| [index: number, type: 'sig', value: unknown]
|
|
27
|
+
| [index: number, type: 'store', value: unknown]
|
|
28
|
+
| [index: number, type: 'raw', value: unknown]
|
|
29
|
+
|
|
30
|
+
export interface ScopeSnapshot {
|
|
31
|
+
id: string
|
|
32
|
+
t?: string
|
|
33
|
+
slots: SlotSnapshot[]
|
|
34
|
+
props?: Record<string, unknown>
|
|
35
|
+
vars?: Record<string, number>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SSRState {
|
|
39
|
+
scopes: Record<string, ScopeSnapshot>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ScopeRecord {
|
|
43
|
+
id: string
|
|
44
|
+
ctx: HookContext
|
|
45
|
+
host: Element
|
|
46
|
+
type?: string
|
|
47
|
+
props?: Record<string, unknown>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let ssrEnabled = false
|
|
51
|
+
let resumableEnabled = false
|
|
52
|
+
let hydrating = false
|
|
53
|
+
let scopeCounter = 0
|
|
54
|
+
let scopeRegistry = new Map<string, ScopeRecord>()
|
|
55
|
+
let snapshotState: SSRState | null = null
|
|
56
|
+
const resumedScopes = new Map<
|
|
57
|
+
string,
|
|
58
|
+
{ ctx: HookContext; host: Element; props?: Record<string, unknown> }
|
|
59
|
+
>()
|
|
60
|
+
|
|
61
|
+
export function __fictEnableSSR(): void {
|
|
62
|
+
ssrEnabled = true
|
|
63
|
+
scopeCounter = 0
|
|
64
|
+
scopeRegistry = new Map()
|
|
65
|
+
resumedScopes.clear()
|
|
66
|
+
snapshotState = null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function __fictDisableSSR(): void {
|
|
70
|
+
ssrEnabled = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function __fictEnableResumable(): void {
|
|
74
|
+
resumableEnabled = true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function __fictDisableResumable(): void {
|
|
78
|
+
resumableEnabled = false
|
|
79
|
+
resumedScopes.clear()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function __fictIsResumable(): boolean {
|
|
83
|
+
return ssrEnabled || resumableEnabled
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function __fictIsSSR(): boolean {
|
|
87
|
+
return ssrEnabled
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function __fictEnterHydration(): void {
|
|
91
|
+
hydrating = true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function __fictExitHydration(): void {
|
|
95
|
+
hydrating = false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function __fictIsHydrating(): boolean {
|
|
99
|
+
return hydrating
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function __fictRegisterScope(
|
|
103
|
+
ctx: HookContext,
|
|
104
|
+
host: Element,
|
|
105
|
+
type?: string,
|
|
106
|
+
props?: Record<string, unknown>,
|
|
107
|
+
): string {
|
|
108
|
+
if (!__fictIsResumable()) return ''
|
|
109
|
+
|
|
110
|
+
const id = `s${++scopeCounter}`
|
|
111
|
+
ctx.scopeId = id
|
|
112
|
+
if (type !== undefined) {
|
|
113
|
+
ctx.scopeType = type
|
|
114
|
+
}
|
|
115
|
+
host.setAttribute('data-fict-s', id)
|
|
116
|
+
if (type) {
|
|
117
|
+
host.setAttribute('data-fict-t', type)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const record: ScopeRecord = { id, ctx, host }
|
|
121
|
+
if (type !== undefined) {
|
|
122
|
+
record.type = type
|
|
123
|
+
}
|
|
124
|
+
if (props !== undefined) {
|
|
125
|
+
record.props = props
|
|
126
|
+
}
|
|
127
|
+
scopeRegistry.set(id, record)
|
|
128
|
+
return id
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function __fictGetScopeRegistry(): Map<string, ScopeRecord> {
|
|
132
|
+
return scopeRegistry
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function __fictSerializeSSRState(): SSRState {
|
|
136
|
+
const scopes: Record<string, ScopeSnapshot> = {}
|
|
137
|
+
|
|
138
|
+
for (const [id, record] of scopeRegistry.entries()) {
|
|
139
|
+
const snapshot: ScopeSnapshot = {
|
|
140
|
+
id,
|
|
141
|
+
slots: serializeSlots(record.ctx),
|
|
142
|
+
}
|
|
143
|
+
if (record.type !== undefined) {
|
|
144
|
+
snapshot.t = record.type
|
|
145
|
+
}
|
|
146
|
+
if (record.props !== undefined) {
|
|
147
|
+
snapshot.props = record.props
|
|
148
|
+
}
|
|
149
|
+
if (record.ctx.slotMap !== undefined) {
|
|
150
|
+
snapshot.vars = record.ctx.slotMap
|
|
151
|
+
}
|
|
152
|
+
scopes[id] = snapshot
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { scopes }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function __fictSetSSRState(state: SSRState | null): void {
|
|
159
|
+
snapshotState = state
|
|
160
|
+
if (!state) {
|
|
161
|
+
resumedScopes.clear()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function __fictGetSSRScope(id: string): ScopeSnapshot | undefined {
|
|
166
|
+
return snapshotState?.scopes[id]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function __fictEnsureScope(
|
|
170
|
+
scopeId: string,
|
|
171
|
+
host: Element,
|
|
172
|
+
snapshot?: ScopeSnapshot,
|
|
173
|
+
): HookContext {
|
|
174
|
+
const existing = resumedScopes.get(scopeId)
|
|
175
|
+
if (existing) return existing.ctx
|
|
176
|
+
|
|
177
|
+
const ctx = createContextFromSnapshot(snapshot)
|
|
178
|
+
ctx.scopeId = scopeId
|
|
179
|
+
if (snapshot?.t !== undefined) {
|
|
180
|
+
ctx.scopeType = snapshot.t
|
|
181
|
+
}
|
|
182
|
+
const entry: { ctx: HookContext; host: Element; props?: Record<string, unknown> } = { ctx, host }
|
|
183
|
+
if (snapshot?.props !== undefined) {
|
|
184
|
+
entry.props = snapshot.props
|
|
185
|
+
}
|
|
186
|
+
resumedScopes.set(scopeId, entry)
|
|
187
|
+
return ctx
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function __fictUseLexicalScope(scopeId: string, names: string[]): unknown[] {
|
|
191
|
+
const record = resumedScopes.get(scopeId)
|
|
192
|
+
if (!record) {
|
|
193
|
+
throw new Error(`[fict] Missing resumed scope for ${scopeId}`)
|
|
194
|
+
}
|
|
195
|
+
const ctx = record.ctx
|
|
196
|
+
const map = ctx.slotMap ?? {}
|
|
197
|
+
return names.map(name => ctx.slots[map[name] ?? -1])
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function __fictGetScopeProps(scopeId: string): Record<string, unknown> | undefined {
|
|
201
|
+
return resumedScopes.get(scopeId)?.props
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function __fictQrl(moduleId: string, exportName: string): string {
|
|
205
|
+
const manifest = (globalThis as Record<string, unknown>).__FICT_MANIFEST__ as
|
|
206
|
+
| Record<string, string>
|
|
207
|
+
| undefined
|
|
208
|
+
|
|
209
|
+
// Check manifest first (production builds)
|
|
210
|
+
if (manifest?.[moduleId]) {
|
|
211
|
+
return `${manifest[moduleId]}#${exportName}`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Handle file:// URLs for Vite dev mode SSR
|
|
215
|
+
if (moduleId.startsWith('file://')) {
|
|
216
|
+
const filePath = moduleId.slice(7) // Remove 'file://'
|
|
217
|
+
|
|
218
|
+
// Check for configured SSR base path (project root)
|
|
219
|
+
const ssrBase = (globalThis as Record<string, unknown>).__FICT_SSR_BASE__ as string | undefined
|
|
220
|
+
if (ssrBase) {
|
|
221
|
+
// Strip base to get relative path (e.g., /src/App.tsx)
|
|
222
|
+
if (filePath.startsWith(ssrBase)) {
|
|
223
|
+
const relativePath = filePath.slice(ssrBase.length)
|
|
224
|
+
return `${relativePath}#${exportName}`
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Fallback: use Vite's /@fs/ convention for direct file system access
|
|
229
|
+
return `/@fs${filePath}#${exportName}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return `${moduleId}#${exportName}`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Registry for resume functions to prevent tree-shaking
|
|
236
|
+
const resumeFunctionRegistry = new Map<string, (...args: unknown[]) => unknown>()
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Register a resume function to prevent it from being tree-shaken.
|
|
240
|
+
* This is called at module load time by compiled component code.
|
|
241
|
+
*/
|
|
242
|
+
export function __fictRegisterResume(name: string, fn: (...args: unknown[]) => unknown): void {
|
|
243
|
+
resumeFunctionRegistry.set(name, fn)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get a registered resume function by name.
|
|
248
|
+
* Used by the loader to find resume functions.
|
|
249
|
+
*/
|
|
250
|
+
export function __fictGetResume(name: string): ((...args: unknown[]) => unknown) | undefined {
|
|
251
|
+
return resumeFunctionRegistry.get(name)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function serializeSlots(ctx: HookContext): SlotSnapshot[] {
|
|
255
|
+
const slots: SlotSnapshot[] = []
|
|
256
|
+
const values = ctx.slots ?? []
|
|
257
|
+
// Share the 'seen' map across all slots to handle cross-slot circular references
|
|
258
|
+
const seen = new Map<object, string>()
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < values.length; i++) {
|
|
261
|
+
const value = values[i]
|
|
262
|
+
// Note: we don't skip undefined anymore since we can serialize it
|
|
263
|
+
if (value === undefined) {
|
|
264
|
+
slots.push([i, 'raw', serializeValue(undefined, seen, `$[${i}]`)])
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (isSignal(value)) {
|
|
269
|
+
try {
|
|
270
|
+
const raw = (value as () => unknown)()
|
|
271
|
+
slots.push([i, 'sig', serializeValue(raw, seen, `$[${i}]`)])
|
|
272
|
+
} catch {
|
|
273
|
+
// ignore signal read errors during SSR
|
|
274
|
+
}
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (isStoreProxy(value)) {
|
|
279
|
+
const raw = unwrapStore(value)
|
|
280
|
+
slots.push([i, 'store', serializeValue(raw, seen, `$[${i}]`)])
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Fallback: serialize raw slot value with complex type support
|
|
285
|
+
slots.push([i, 'raw', serializeValue(value, seen, `$[${i}]`)])
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return slots
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function createContextFromSnapshot(snapshot?: ScopeSnapshot): HookContext {
|
|
292
|
+
const ctx: HookContext = { slots: [], cursor: 0 }
|
|
293
|
+
if (!snapshot) return ctx
|
|
294
|
+
|
|
295
|
+
for (const slot of snapshot.slots) {
|
|
296
|
+
const [index, type, value] = slot
|
|
297
|
+
if (type === 'sig') {
|
|
298
|
+
ctx.slots[index] = createSignal(deserializeValue(value))
|
|
299
|
+
} else if (type === 'store') {
|
|
300
|
+
ctx.slots[index] = createStore(deserializeValue(value) as object)[0]
|
|
301
|
+
} else {
|
|
302
|
+
ctx.slots[index] = deserializeValue(value)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (snapshot.vars) {
|
|
306
|
+
ctx.slotMap = { ...snapshot.vars }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return ctx
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ============================================================================
|
|
313
|
+
// Value Serialization - Complex Type Support
|
|
314
|
+
// ============================================================================
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Check if value has a serialization marker
|
|
318
|
+
*/
|
|
319
|
+
function isSerializedMarker(value: unknown): value is SerializedMarker {
|
|
320
|
+
return (
|
|
321
|
+
typeof value === 'object' &&
|
|
322
|
+
value !== null &&
|
|
323
|
+
'__t' in value &&
|
|
324
|
+
typeof (value as SerializedMarker).__t === 'string'
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Serialize a value with support for complex types.
|
|
330
|
+
* Handles: Date, Map, Set, RegExp, undefined, NaN, Infinity, -Infinity, BigInt, circular references
|
|
331
|
+
*/
|
|
332
|
+
export function serializeValue(
|
|
333
|
+
value: unknown,
|
|
334
|
+
seen = new Map<object, string>(),
|
|
335
|
+
path = '$',
|
|
336
|
+
): unknown {
|
|
337
|
+
// Handle primitives that JSON can't represent correctly
|
|
338
|
+
if (value === undefined) {
|
|
339
|
+
return { __t: 'u' } as SerializedMarker
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (typeof value === 'number') {
|
|
343
|
+
if (Number.isNaN(value)) {
|
|
344
|
+
return { __t: 'n' } as SerializedMarker
|
|
345
|
+
}
|
|
346
|
+
if (value === Infinity) {
|
|
347
|
+
return { __t: '+i' } as SerializedMarker
|
|
348
|
+
}
|
|
349
|
+
if (value === -Infinity) {
|
|
350
|
+
return { __t: '-i' } as SerializedMarker
|
|
351
|
+
}
|
|
352
|
+
return value
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (typeof value === 'bigint') {
|
|
356
|
+
return { __t: 'b', v: value.toString() } as SerializedMarker
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Primitives that JSON handles correctly
|
|
360
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
|
|
361
|
+
return value
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Handle functions - can't serialize, skip
|
|
365
|
+
if (typeof value === 'function') {
|
|
366
|
+
return undefined
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Handle objects - check for circular references first
|
|
370
|
+
if (typeof value === 'object') {
|
|
371
|
+
// Check for circular reference
|
|
372
|
+
if (seen.has(value)) {
|
|
373
|
+
return { __t: 'ref', v: seen.get(value)! } as SerializedMarker
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Date
|
|
377
|
+
if (value instanceof Date) {
|
|
378
|
+
return { __t: 'd', v: value.getTime() } as SerializedMarker
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// RegExp
|
|
382
|
+
if (value instanceof RegExp) {
|
|
383
|
+
return { __t: 'r', v: { s: value.source, f: value.flags } } as SerializedMarker
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Map
|
|
387
|
+
if (value instanceof Map) {
|
|
388
|
+
seen.set(value, path)
|
|
389
|
+
const entries: [unknown, unknown][] = []
|
|
390
|
+
let i = 0
|
|
391
|
+
for (const [k, v] of value) {
|
|
392
|
+
entries.push([
|
|
393
|
+
serializeValue(k, seen, `${path}.k${i}`),
|
|
394
|
+
serializeValue(v, seen, `${path}.v${i}`),
|
|
395
|
+
])
|
|
396
|
+
i++
|
|
397
|
+
}
|
|
398
|
+
return { __t: 'm', v: entries } as SerializedMarker
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Set
|
|
402
|
+
if (value instanceof Set) {
|
|
403
|
+
seen.set(value, path)
|
|
404
|
+
const items: unknown[] = []
|
|
405
|
+
let i = 0
|
|
406
|
+
for (const item of value) {
|
|
407
|
+
items.push(serializeValue(item, seen, `${path}[${i}]`))
|
|
408
|
+
i++
|
|
409
|
+
}
|
|
410
|
+
return { __t: 's', v: items } as SerializedMarker
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Array
|
|
414
|
+
if (Array.isArray(value)) {
|
|
415
|
+
seen.set(value, path)
|
|
416
|
+
return value.map((item, i) => serializeValue(item, seen, `${path}[${i}]`))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Plain object
|
|
420
|
+
seen.set(value, path)
|
|
421
|
+
const result: Record<string, unknown> = {}
|
|
422
|
+
for (const key of Object.keys(value)) {
|
|
423
|
+
const serialized = serializeValue(
|
|
424
|
+
(value as Record<string, unknown>)[key],
|
|
425
|
+
seen,
|
|
426
|
+
`${path}.${key}`,
|
|
427
|
+
)
|
|
428
|
+
if (serialized !== undefined) {
|
|
429
|
+
result[key] = serialized
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return result
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return value
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Deserialize a value, restoring complex types from their serialized form.
|
|
440
|
+
*/
|
|
441
|
+
export function deserializeValue(
|
|
442
|
+
value: unknown,
|
|
443
|
+
refs = new Map<string, unknown>(),
|
|
444
|
+
path = '$',
|
|
445
|
+
): unknown {
|
|
446
|
+
// Handle null
|
|
447
|
+
if (value === null) {
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Handle primitives
|
|
452
|
+
if (typeof value !== 'object') {
|
|
453
|
+
return value
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Check for serialization markers
|
|
457
|
+
if (isSerializedMarker(value)) {
|
|
458
|
+
switch (value.__t) {
|
|
459
|
+
case 'u':
|
|
460
|
+
return undefined
|
|
461
|
+
case 'n':
|
|
462
|
+
return NaN
|
|
463
|
+
case '+i':
|
|
464
|
+
return Infinity
|
|
465
|
+
case '-i':
|
|
466
|
+
return -Infinity
|
|
467
|
+
case 'b':
|
|
468
|
+
return BigInt(value.v)
|
|
469
|
+
case 'd':
|
|
470
|
+
return new Date(value.v)
|
|
471
|
+
case 'r':
|
|
472
|
+
return new RegExp(value.v.s, value.v.f)
|
|
473
|
+
case 'm': {
|
|
474
|
+
const map = new Map<unknown, unknown>()
|
|
475
|
+
refs.set(path, map)
|
|
476
|
+
for (let i = 0; i < value.v.length; i++) {
|
|
477
|
+
const entry = value.v[i]
|
|
478
|
+
if (!entry) continue
|
|
479
|
+
const [k, v] = entry
|
|
480
|
+
map.set(
|
|
481
|
+
deserializeValue(k, refs, `${path}.k${i}`),
|
|
482
|
+
deserializeValue(v, refs, `${path}.v${i}`),
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
return map
|
|
486
|
+
}
|
|
487
|
+
case 's': {
|
|
488
|
+
const set = new Set<unknown>()
|
|
489
|
+
refs.set(path, set)
|
|
490
|
+
for (let i = 0; i < value.v.length; i++) {
|
|
491
|
+
set.add(deserializeValue(value.v[i], refs, `${path}[${i}]`))
|
|
492
|
+
}
|
|
493
|
+
return set
|
|
494
|
+
}
|
|
495
|
+
case 'ref':
|
|
496
|
+
return refs.get(value.v)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Handle arrays
|
|
501
|
+
if (Array.isArray(value)) {
|
|
502
|
+
const arr: unknown[] = []
|
|
503
|
+
refs.set(path, arr)
|
|
504
|
+
for (let i = 0; i < value.length; i++) {
|
|
505
|
+
arr.push(deserializeValue(value[i], refs, `${path}[${i}]`))
|
|
506
|
+
}
|
|
507
|
+
return arr
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Handle plain objects
|
|
511
|
+
const obj: Record<string, unknown> = {}
|
|
512
|
+
refs.set(path, obj)
|
|
513
|
+
for (const key of Object.keys(value)) {
|
|
514
|
+
obj[key] = deserializeValue((value as Record<string, unknown>)[key], refs, `${path}.${key}`)
|
|
515
|
+
}
|
|
516
|
+
return obj
|
|
517
|
+
}
|
package/src/signal.ts
CHANGED
|
@@ -103,6 +103,10 @@ export interface SignalNode<T = unknown> extends BaseNode {
|
|
|
103
103
|
currentValue: T
|
|
104
104
|
/** Pending value to be committed */
|
|
105
105
|
pendingValue: T
|
|
106
|
+
/** Previous committed value (for cleanup reads) */
|
|
107
|
+
prevValue?: T
|
|
108
|
+
/** Flush id when prevValue was recorded */
|
|
109
|
+
prevFlushId?: number
|
|
106
110
|
/** Signals don't have dependencies */
|
|
107
111
|
deps?: undefined
|
|
108
112
|
depsTail?: undefined
|
|
@@ -123,6 +127,10 @@ export interface SignalNode<T = unknown> extends BaseNode {
|
|
|
123
127
|
export interface ComputedNode<T = unknown> extends BaseNode {
|
|
124
128
|
/** Current computed value */
|
|
125
129
|
value: T
|
|
130
|
+
/** Previous computed value (for cleanup reads) */
|
|
131
|
+
prevValue?: T
|
|
132
|
+
/** Flush id when prevValue was recorded */
|
|
133
|
+
prevFlushId?: number
|
|
126
134
|
/** First dependency link */
|
|
127
135
|
deps: Link | undefined
|
|
128
136
|
/** Last dependency link */
|
|
@@ -251,6 +259,8 @@ let cycle = 0
|
|
|
251
259
|
let batchDepth = 0
|
|
252
260
|
let activeSub: ReactiveNode | undefined
|
|
253
261
|
let flushScheduled = false
|
|
262
|
+
let currentFlushId = 0
|
|
263
|
+
let activeCleanupFlushId = 0
|
|
254
264
|
// Dual-priority queue for scheduler
|
|
255
265
|
const highPriorityQueue: EffectNode[] = []
|
|
256
266
|
const lowPriorityQueue: EffectNode[] = []
|
|
@@ -798,6 +808,8 @@ function updateSignal(s: SignalNode): boolean {
|
|
|
798
808
|
const current = s.currentValue
|
|
799
809
|
const pending = s.pendingValue
|
|
800
810
|
if (valuesDiffer(s, current, pending)) {
|
|
811
|
+
s.prevValue = current
|
|
812
|
+
s.prevFlushId = currentFlushId
|
|
801
813
|
s.currentValue = pending
|
|
802
814
|
return true
|
|
803
815
|
}
|
|
@@ -822,6 +834,8 @@ function updateComputed<T>(c: ComputedNode<T>): boolean {
|
|
|
822
834
|
c.flags &= ~Running
|
|
823
835
|
purgeDeps(c)
|
|
824
836
|
if (valuesDiffer(c, oldValue, newValue)) {
|
|
837
|
+
c.prevValue = oldValue
|
|
838
|
+
c.prevFlushId = currentFlushId
|
|
825
839
|
c.value = newValue
|
|
826
840
|
if (isDev) updateComputedDevtools(c, newValue)
|
|
827
841
|
return true
|
|
@@ -839,16 +853,20 @@ function updateComputed<T>(c: ComputedNode<T>): boolean {
|
|
|
839
853
|
*/
|
|
840
854
|
function runEffect(e: EffectNode): void {
|
|
841
855
|
const flags = e.flags
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
856
|
+
const runCleanup = () => {
|
|
857
|
+
if (!e.runCleanup) return
|
|
858
|
+
inCleanup = true
|
|
859
|
+
activeCleanupFlushId = currentFlushId
|
|
860
|
+
try {
|
|
861
|
+
e.runCleanup()
|
|
862
|
+
} finally {
|
|
863
|
+
activeCleanupFlushId = 0
|
|
864
|
+
inCleanup = false
|
|
851
865
|
}
|
|
866
|
+
}
|
|
867
|
+
if (flags & Dirty) {
|
|
868
|
+
// Run cleanup before re-run; values are still the previous commit.
|
|
869
|
+
runCleanup()
|
|
852
870
|
++cycle
|
|
853
871
|
if (isDev) effectRunDevtools(e)
|
|
854
872
|
e.depsTail = undefined
|
|
@@ -866,15 +884,6 @@ function runEffect(e: EffectNode): void {
|
|
|
866
884
|
throw err
|
|
867
885
|
}
|
|
868
886
|
} else if (flags & Pending && e.deps) {
|
|
869
|
-
// Run cleanup before checkDirty which commits signal values
|
|
870
|
-
if (e.runCleanup) {
|
|
871
|
-
inCleanup = true
|
|
872
|
-
try {
|
|
873
|
-
e.runCleanup()
|
|
874
|
-
} finally {
|
|
875
|
-
inCleanup = false
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
887
|
let isDirty = false
|
|
879
888
|
try {
|
|
880
889
|
isDirty = checkDirty(e.deps, e)
|
|
@@ -894,6 +903,9 @@ function runEffect(e: EffectNode): void {
|
|
|
894
903
|
throw err
|
|
895
904
|
}
|
|
896
905
|
if (isDirty) {
|
|
906
|
+
// Only run cleanup if the effect will actually re-run.
|
|
907
|
+
// Cleanup reads should observe previous values for this flush.
|
|
908
|
+
runCleanup()
|
|
897
909
|
++cycle
|
|
898
910
|
if (isDev) effectRunDevtools(e)
|
|
899
911
|
e.depsTail = undefined
|
|
@@ -947,6 +959,7 @@ function flush(): void {
|
|
|
947
959
|
endFlushGuard()
|
|
948
960
|
return
|
|
949
961
|
}
|
|
962
|
+
currentFlushId++
|
|
950
963
|
flushScheduled = false
|
|
951
964
|
|
|
952
965
|
// 1. Process all high-priority effects first
|
|
@@ -1071,6 +1084,12 @@ function signalOper<T>(this: SignalNode<T>, value?: T): T | void {
|
|
|
1071
1084
|
if (subs !== undefined) shallowPropagate(subs)
|
|
1072
1085
|
}
|
|
1073
1086
|
}
|
|
1087
|
+
if (inCleanup) {
|
|
1088
|
+
if (this.prevFlushId === activeCleanupFlushId) {
|
|
1089
|
+
return this.prevValue as T
|
|
1090
|
+
}
|
|
1091
|
+
return this.currentValue
|
|
1092
|
+
}
|
|
1074
1093
|
|
|
1075
1094
|
let sub = activeSub
|
|
1076
1095
|
while (sub !== undefined) {
|
|
@@ -1118,10 +1137,14 @@ export function computed<T>(
|
|
|
1118
1137
|
return bound as ComputedAccessor<T>
|
|
1119
1138
|
}
|
|
1120
1139
|
function computedOper<T>(this: ComputedNode<T>): T {
|
|
1121
|
-
// fix: During cleanup, return
|
|
1122
|
-
// This ensures cleanup functions
|
|
1123
|
-
|
|
1124
|
-
|
|
1140
|
+
// fix: During cleanup, return previous value for this flush without triggering updates.
|
|
1141
|
+
// This ensures cleanup functions observe the pre-commit state for this effect.
|
|
1142
|
+
if (inCleanup) {
|
|
1143
|
+
if (this.prevFlushId === activeCleanupFlushId) {
|
|
1144
|
+
return this.prevValue as T
|
|
1145
|
+
}
|
|
1146
|
+
return this.value
|
|
1147
|
+
}
|
|
1125
1148
|
|
|
1126
1149
|
const flags = this.flags
|
|
1127
1150
|
|
|
@@ -1388,6 +1411,8 @@ export function __resetReactiveState(): void {
|
|
|
1388
1411
|
isInTransition = false
|
|
1389
1412
|
inCleanup = false
|
|
1390
1413
|
cycle = 0
|
|
1414
|
+
currentFlushId = 0
|
|
1415
|
+
activeCleanupFlushId = 0
|
|
1391
1416
|
}
|
|
1392
1417
|
/**
|
|
1393
1418
|
* Execute a function without tracking dependencies
|
package/src/store.ts
CHANGED
|
@@ -131,6 +131,14 @@ function unwrap<T>(value: T): T {
|
|
|
131
131
|
return value
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
export function isStoreProxy(value: unknown): boolean {
|
|
135
|
+
return !!(value && typeof value === 'object' && Reflect.get(value as object, PROXY))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function unwrapStore<T>(value: T): T {
|
|
139
|
+
return unwrap(value)
|
|
140
|
+
}
|
|
141
|
+
|
|
134
142
|
function track(target: object, prop: string | symbol) {
|
|
135
143
|
let signals = signalCache.get(target)
|
|
136
144
|
if (!signals) {
|