@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.
Files changed (72) hide show
  1. package/dist/advanced.cjs +10 -8
  2. package/dist/advanced.cjs.map +1 -1
  3. package/dist/advanced.d.cts +4 -3
  4. package/dist/advanced.d.ts +4 -3
  5. package/dist/advanced.js +10 -8
  6. package/dist/advanced.js.map +1 -1
  7. package/dist/{chunk-L4DIV3RC.cjs → chunk-4ZPZM5IG.cjs} +9 -7
  8. package/dist/chunk-4ZPZM5IG.cjs.map +1 -0
  9. package/dist/{chunk-XLIZJMMJ.js → chunk-5OYBRKE4.js} +8 -6
  10. package/dist/{chunk-XLIZJMMJ.js.map → chunk-5OYBRKE4.js.map} +1 -1
  11. package/dist/chunk-6RCEIWZL.cjs +2380 -0
  12. package/dist/chunk-6RCEIWZL.cjs.map +1 -0
  13. package/dist/chunk-7BO6P2KP.js +2380 -0
  14. package/dist/chunk-7BO6P2KP.js.map +1 -0
  15. package/dist/{chunk-TWELIZRY.js → chunk-AR6NSCZM.js} +5 -3
  16. package/dist/{chunk-TWELIZRY.js.map → chunk-AR6NSCZM.js.map} +1 -1
  17. package/dist/{chunk-M2TSXZ4C.cjs → chunk-LFMXNQZC.cjs} +18 -16
  18. package/dist/chunk-LFMXNQZC.cjs.map +1 -0
  19. package/dist/{chunk-SO6X7G5S.js → chunk-RY5CY4CI.js} +501 -1880
  20. package/dist/chunk-RY5CY4CI.js.map +1 -0
  21. package/dist/chunk-WJHXPF7M.cjs +2259 -0
  22. package/dist/chunk-WJHXPF7M.cjs.map +1 -0
  23. package/dist/{context-B25xyQrJ.d.cts → context-CTBE00S_.d.cts} +1 -1
  24. package/dist/{context-CGdP7_Jb.d.ts → context-lkLhbkFJ.d.ts} +1 -1
  25. package/dist/{effect-D6kaLM2-.d.cts → effect-BpSNEJJz.d.cts} +7 -67
  26. package/dist/{effect-D6kaLM2-.d.ts → effect-BpSNEJJz.d.ts} +7 -67
  27. package/dist/index.cjs +40 -38
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +5 -4
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.dev.js +125 -22
  32. package/dist/index.dev.js.map +1 -1
  33. package/dist/index.js +19 -17
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal.cjs +202 -203
  36. package/dist/internal.cjs.map +1 -1
  37. package/dist/internal.d.cts +13 -23
  38. package/dist/internal.d.ts +13 -23
  39. package/dist/internal.js +207 -208
  40. package/dist/internal.js.map +1 -1
  41. package/dist/loader.cjs +280 -0
  42. package/dist/loader.cjs.map +1 -0
  43. package/dist/loader.d.cts +57 -0
  44. package/dist/loader.d.ts +57 -0
  45. package/dist/loader.js +280 -0
  46. package/dist/loader.js.map +1 -0
  47. package/dist/{props-BIfromL0.d.cts → props-XTHYD19o.d.cts} +13 -2
  48. package/dist/{props-BEgIVMRx.d.ts → props-x-HbI-jX.d.ts} +13 -2
  49. package/dist/resume-BrAkmSTY.d.cts +79 -0
  50. package/dist/resume-Dx8_l72o.d.ts +79 -0
  51. package/dist/{scope-CzNkn587.d.ts → scope-CdbGmsFf.d.ts} +1 -1
  52. package/dist/{scope-Cx_3CjIZ.d.cts → scope-DfcP9I-A.d.cts} +1 -1
  53. package/dist/signal-C4ISF17w.d.cts +66 -0
  54. package/dist/signal-C4ISF17w.d.ts +66 -0
  55. package/package.json +6 -1
  56. package/src/binding.ts +254 -5
  57. package/src/cycle-guard.ts +1 -1
  58. package/src/dom.ts +103 -5
  59. package/src/hooks.ts +15 -2
  60. package/src/hydration.ts +75 -0
  61. package/src/internal.ts +34 -2
  62. package/src/list-helpers.ts +127 -11
  63. package/src/loader.ts +437 -0
  64. package/src/node-ops.ts +65 -0
  65. package/src/resume.ts +517 -0
  66. package/src/signal.ts +47 -22
  67. package/src/store.ts +8 -0
  68. package/dist/chunk-ID3WBWNO.cjs +0 -3638
  69. package/dist/chunk-ID3WBWNO.cjs.map +0 -1
  70. package/dist/chunk-L4DIV3RC.cjs.map +0 -1
  71. package/dist/chunk-M2TSXZ4C.cjs.map +0 -1
  72. 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
- // Run cleanup BEFORE checkDirty so cleanup sees previous signal values
843
- if (flags & Dirty) {
844
- if (e.runCleanup) {
845
- inCleanup = true
846
- try {
847
- e.runCleanup()
848
- } finally {
849
- inCleanup = false
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 cached value without triggering any updates.
1122
- // This ensures cleanup functions see the previous state, not the new pending values.
1123
- // Without this check, checkDirty() could commit pending signal values during cleanup.
1124
- if (inCleanup) return this.value
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) {