@fictjs/runtime 0.0.10 → 0.0.12

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/src/signal.ts CHANGED
@@ -96,6 +96,10 @@ export interface EffectNode extends BaseNode {
96
96
  deps: Link | undefined
97
97
  /** Last dependency link */
98
98
  depsTail: Link | undefined
99
+ /** Optional cleanup runner to be called before checkDirty */
100
+ runCleanup?: () => void
101
+ /** Devtools ID */
102
+ __id?: number | undefined
99
103
  }
100
104
 
101
105
  /**
@@ -202,6 +206,13 @@ const enqueueMicrotask =
202
206
  : (fn: () => void) => {
203
207
  Promise.resolve().then(fn)
204
208
  }
209
+ // Flag to indicate cleanup is running - signal reads should return currentValue without updating
210
+ let inCleanup = false
211
+ // This ensures type detection works correctly even after minification
212
+ const SIGNAL_MARKER = Symbol.for('fict:signal')
213
+ const COMPUTED_MARKER = Symbol.for('fict:computed')
214
+ const EFFECT_MARKER = Symbol.for('fict:effect')
215
+ const EFFECT_SCOPE_MARKER = Symbol.for('fict:effectScope')
205
216
  export const ReactiveFlags = {
206
217
  None: 0,
207
218
  Mutable,
@@ -312,13 +323,21 @@ export function createReactiveSystem({
312
323
  dirty = true
313
324
  }
314
325
  } else if ((depFlags & MutablePending) === MutablePending) {
315
- if (link.nextSub !== undefined || link.prevSub !== undefined) {
316
- stack = { value: link, prev: stack }
326
+ if (!dep.deps) {
327
+ const nextDep = link.nextDep
328
+ if (nextDep !== undefined) {
329
+ link = nextDep
330
+ continue
331
+ }
332
+ } else {
333
+ if (link.nextSub !== undefined || link.prevSub !== undefined) {
334
+ stack = { value: link, prev: stack }
335
+ }
336
+ link = dep.deps
337
+ sub = dep
338
+ ++checkDepth
339
+ continue
317
340
  }
318
- link = dep.deps!
319
- sub = dep
320
- ++checkDepth
321
- continue
322
341
  }
323
342
 
324
343
  if (!dirty) {
@@ -566,13 +585,22 @@ function checkDirty(firstLink: Link, sub: ReactiveNode): boolean {
566
585
  dirty = true
567
586
  }
568
587
  } else if ((depFlags & MutablePending) === MutablePending) {
569
- if (link.nextSub !== undefined || link.prevSub !== undefined) {
570
- stack = { value: link, prev: stack }
588
+ if (!dep.deps) {
589
+ // No dependencies to check, skip this node
590
+ const nextDep = link.nextDep
591
+ if (nextDep !== undefined) {
592
+ link = nextDep
593
+ continue
594
+ }
595
+ } else {
596
+ if (link.nextSub !== undefined || link.prevSub !== undefined) {
597
+ stack = { value: link, prev: stack }
598
+ }
599
+ link = dep.deps
600
+ sub = dep
601
+ ++checkDepth
602
+ continue
571
603
  }
572
- link = dep.deps!
573
- sub = dep
574
- ++checkDepth
575
- continue
576
604
  }
577
605
 
578
606
  if (!dirty) {
@@ -682,8 +710,12 @@ function disposeNode(node: ReactiveNode): void {
682
710
  node.depsTail = undefined
683
711
  node.flags = 0
684
712
  purgeDeps(node)
685
- const sub = node.subs
686
- if (sub !== undefined) unlink(sub, node)
713
+ let sub = node.subs
714
+ while (sub !== undefined) {
715
+ const next = sub.nextSub
716
+ unlink(sub)
717
+ sub = next
718
+ }
687
719
  }
688
720
  /**
689
721
  * Update a signal node
@@ -735,7 +767,16 @@ function updateComputed<T>(c: ComputedNode<T>): boolean {
735
767
  */
736
768
  function runEffect(e: EffectNode): void {
737
769
  const flags = e.flags
738
- if (flags & Dirty || (flags & Pending && e.deps && checkDirty(e.deps, e))) {
770
+ // Run cleanup BEFORE checkDirty so cleanup sees previous signal values
771
+ if (flags & Dirty) {
772
+ if (e.runCleanup) {
773
+ inCleanup = true
774
+ try {
775
+ e.runCleanup()
776
+ } finally {
777
+ inCleanup = false
778
+ }
779
+ }
739
780
  ++cycle
740
781
  effectRunDevtools(e)
741
782
  e.depsTail = undefined
@@ -752,6 +793,36 @@ function runEffect(e: EffectNode): void {
752
793
  e.flags = Watching
753
794
  throw err
754
795
  }
796
+ } else if (flags & Pending && e.deps) {
797
+ // Run cleanup before checkDirty which commits signal values
798
+ if (e.runCleanup) {
799
+ inCleanup = true
800
+ try {
801
+ e.runCleanup()
802
+ } finally {
803
+ inCleanup = false
804
+ }
805
+ }
806
+ if (checkDirty(e.deps, e)) {
807
+ ++cycle
808
+ effectRunDevtools(e)
809
+ e.depsTail = undefined
810
+ e.flags = WatchingRunning
811
+ const prevSub = activeSub
812
+ activeSub = e
813
+ try {
814
+ e.fn()
815
+ activeSub = prevSub
816
+ e.flags = Watching
817
+ purgeDeps(e)
818
+ } catch (err) {
819
+ activeSub = prevSub
820
+ e.flags = Watching
821
+ throw err
822
+ }
823
+ } else {
824
+ e.flags = Watching
825
+ }
755
826
  } else {
756
827
  e.flags = Watching
757
828
  }
@@ -852,7 +923,9 @@ export function signal<T>(initialValue: T): SignalAccessor<T> {
852
923
  __id: undefined as number | undefined,
853
924
  }
854
925
  registerSignalDevtools(initialValue, s)
855
- return signalOper.bind(s) as SignalAccessor<T>
926
+ const accessor = signalOper.bind(s) as SignalAccessor<T> & Record<symbol, boolean>
927
+ accessor[SIGNAL_MARKER] = true
928
+ return accessor as SignalAccessor<T>
856
929
  }
857
930
  function signalOper<T>(this: SignalNode<T>, value?: T): T | void {
858
931
  if (arguments.length > 0) {
@@ -870,7 +943,8 @@ function signalOper<T>(this: SignalNode<T>, value?: T): T | void {
870
943
  }
871
944
 
872
945
  const flags = this.flags
873
- if (flags & Dirty) {
946
+ // During cleanup, don't update signal - return currentValue as-is
947
+ if (flags & Dirty && !inCleanup) {
874
948
  if (updateSignal(this)) {
875
949
  const subs = this.subs
876
950
  if (subs !== undefined) shallowPropagate(subs)
@@ -907,7 +981,9 @@ export function computed<T>(getter: (oldValue?: T) => T): ComputedAccessor<T> {
907
981
  flags: 0,
908
982
  getter,
909
983
  }
910
- const bound = (computedOper as (this: ComputedNode<T>) => T).bind(c)
984
+ const bound = (computedOper as (this: ComputedNode<T>) => T).bind(c) as ComputedAccessor<T> &
985
+ Record<symbol, boolean>
986
+ bound[COMPUTED_MARKER] = true
911
987
  return bound as ComputedAccessor<T>
912
988
  }
913
989
  function computedOper<T>(this: ComputedNode<T>): T {
@@ -974,8 +1050,50 @@ export function effect(fn: () => void): EffectDisposer {
974
1050
  e.flags &= ~Running
975
1051
  }
976
1052
 
977
- return effectOper.bind(e) as EffectDisposer
1053
+ const disposer = effectOper.bind(e) as EffectDisposer & Record<symbol, boolean>
1054
+ disposer[EFFECT_MARKER] = true
1055
+ return disposer as EffectDisposer
1056
+ }
1057
+
1058
+ /**
1059
+ * Create a reactive effect with a custom cleanup runner
1060
+ * The cleanup runner is called BEFORE signal values are committed, allowing
1061
+ * cleanup functions to access the previous values of signals.
1062
+ * @param fn - The effect function
1063
+ * @param cleanupRunner - Function to run cleanups before signal value commit
1064
+ * @returns An effect disposer function
1065
+ */
1066
+ export function effectWithCleanup(fn: () => void, cleanupRunner: () => void): EffectDisposer {
1067
+ const e: EffectNode = {
1068
+ fn,
1069
+ subs: undefined,
1070
+ subsTail: undefined,
1071
+ deps: undefined,
1072
+ depsTail: undefined,
1073
+ flags: WatchingRunning,
1074
+ runCleanup: cleanupRunner,
1075
+ __id: undefined as number | undefined,
1076
+ }
1077
+
1078
+ registerEffectDevtools(e)
1079
+
1080
+ const prevSub = activeSub
1081
+ if (prevSub !== undefined) link(e, prevSub, 0)
1082
+ activeSub = e
1083
+
1084
+ try {
1085
+ effectRunDevtools(e)
1086
+ fn()
1087
+ } finally {
1088
+ activeSub = prevSub
1089
+ e.flags &= ~Running
1090
+ }
1091
+
1092
+ const disposer = effectOper.bind(e) as EffectDisposer & Record<symbol, boolean>
1093
+ disposer[EFFECT_MARKER] = true
1094
+ return disposer as EffectDisposer
978
1095
  }
1096
+
979
1097
  function effectOper(this: EffectNode): void {
980
1098
  disposeNode(this)
981
1099
  }
@@ -1000,7 +1118,9 @@ export function effectScope(fn: () => void): EffectScopeDisposer {
1000
1118
  activeSub = prevSub
1001
1119
  }
1002
1120
 
1003
- return effectScopeOper.bind(e) as EffectScopeDisposer
1121
+ const disposer = effectScopeOper.bind(e) as EffectScopeDisposer & Record<symbol, boolean>
1122
+ disposer[EFFECT_SCOPE_MARKER] = true
1123
+ return disposer as EffectScopeDisposer
1004
1124
  }
1005
1125
  function effectScopeOper(this: EffectScopeNode): void {
1006
1126
  disposeNode(this)
@@ -1119,14 +1239,16 @@ export function untrack<T>(fn: () => T): T {
1119
1239
  export function peek<T>(accessor: () => T): T {
1120
1240
  return untrack(accessor)
1121
1241
  }
1122
- // Type detection - Fixed: using Function.name
1242
+ // This ensures correct detection even after minification
1123
1243
  /**
1124
1244
  * Check if a function is a signal accessor
1125
1245
  * @param fn - The function to check
1126
1246
  * @returns True if the function is a signal accessor
1127
1247
  */
1128
1248
  export function isSignal(fn: unknown): fn is SignalAccessor<unknown> {
1129
- return typeof fn === 'function' && fn.name === 'bound signalOper'
1249
+ return (
1250
+ typeof fn === 'function' && (fn as unknown as Record<symbol, boolean>)[SIGNAL_MARKER] === true
1251
+ )
1130
1252
  }
1131
1253
  /**
1132
1254
  * Check if a function is a computed accessor
@@ -1134,7 +1256,9 @@ export function isSignal(fn: unknown): fn is SignalAccessor<unknown> {
1134
1256
  * @returns True if the function is a computed accessor
1135
1257
  */
1136
1258
  export function isComputed(fn: unknown): fn is ComputedAccessor<unknown> {
1137
- return typeof fn === 'function' && fn.name === 'bound computedOper'
1259
+ return (
1260
+ typeof fn === 'function' && (fn as unknown as Record<symbol, boolean>)[COMPUTED_MARKER] === true
1261
+ )
1138
1262
  }
1139
1263
  /**
1140
1264
  * Check if a function is an effect disposer
@@ -1142,7 +1266,9 @@ export function isComputed(fn: unknown): fn is ComputedAccessor<unknown> {
1142
1266
  * @returns True if the function is an effect disposer
1143
1267
  */
1144
1268
  export function isEffect(fn: unknown): fn is EffectDisposer {
1145
- return typeof fn === 'function' && fn.name === 'bound effectOper'
1269
+ return (
1270
+ typeof fn === 'function' && (fn as unknown as Record<symbol, boolean>)[EFFECT_MARKER] === true
1271
+ )
1146
1272
  }
1147
1273
  /**
1148
1274
  * Check if a function is an effect scope disposer
@@ -1150,7 +1276,10 @@ export function isEffect(fn: unknown): fn is EffectDisposer {
1150
1276
  * @returns True if the function is an effect scope disposer
1151
1277
  */
1152
1278
  export function isEffectScope(fn: unknown): fn is EffectScopeDisposer {
1153
- return typeof fn === 'function' && fn.name === 'bound effectScopeOper'
1279
+ return (
1280
+ typeof fn === 'function' &&
1281
+ (fn as unknown as Record<symbol, boolean>)[EFFECT_SCOPE_MARKER] === true
1282
+ )
1154
1283
  }
1155
1284
  // ============================================================================
1156
1285
  // Transition Context (for priority scheduling)
package/src/suspense.ts CHANGED
@@ -103,7 +103,9 @@ export function Suspense(props: SuspenseProps): FictNode {
103
103
  popRoot(prev)
104
104
  flushOnMount(root)
105
105
  destroyRoot(root)
106
- handleError(err, { source: 'render' })
106
+ if (!handleError(err, { source: 'render' }, hostRoot)) {
107
+ throw err
108
+ }
107
109
  return
108
110
  }
109
111
  popRoot(prev)
@@ -143,18 +145,33 @@ export function Suspense(props: SuspenseProps): FictNode {
143
145
  if (thenable) {
144
146
  thenable.then(
145
147
  () => {
146
- if (epoch !== tokenEpoch) return
147
- pending(Math.max(0, pending() - 1))
148
- if (pending() === 0) {
148
+ // This prevents stale token resolutions from affecting state after
149
+ // a reset. The order is important: check epoch first, then update state.
150
+ if (epoch !== tokenEpoch) {
151
+ // Token is stale (from before a reset), ignore it completely
152
+ return
153
+ }
154
+ // Use Math.max as a defensive measure - pending should never go below 0,
155
+ // but this protects against edge cases where a token might resolve twice
156
+ // or after the component has been reset.
157
+ const newPending = Math.max(0, pending() - 1)
158
+ pending(newPending)
159
+ if (newPending === 0) {
149
160
  switchView(props.children ?? null)
150
161
  onResolveMaybe()
151
162
  }
152
163
  },
153
164
  err => {
154
- if (epoch !== tokenEpoch) return
155
- pending(Math.max(0, pending() - 1))
165
+ // Same epoch check - ignore stale tokens
166
+ if (epoch !== tokenEpoch) {
167
+ return
168
+ }
169
+ const newPending = Math.max(0, pending() - 1)
170
+ pending(newPending)
156
171
  props.onReject?.(err)
157
- handleError(err, { source: 'render' }, hostRoot)
172
+ if (!handleError(err, { source: 'render' }, hostRoot)) {
173
+ throw err
174
+ }
158
175
  },
159
176
  )
160
177
  return true
package/src/transition.ts CHANGED
@@ -71,8 +71,11 @@ export function useTransition(): [() => boolean, (fn: () => void) => void] {
71
71
  const pending = signal(false)
72
72
 
73
73
  const start = (fn: () => void) => {
74
- pending(true)
74
+ // Both pending(true) and pending(false) are now low-priority updates,
75
+ // preventing the race condition where isPending() could be inconsistent
76
+ // with the actual transition execution state.
75
77
  startTransition(() => {
78
+ pending(true)
76
79
  try {
77
80
  fn()
78
81
  } finally {