@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/dist/index.cjs +315 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.dev.js +330 -48
- package/dist/index.dev.js.map +1 -1
- package/dist/index.js +315 -50
- package/dist/index.js.map +1 -1
- package/dist/slim.cjs +293 -40
- package/dist/slim.cjs.map +1 -1
- package/dist/slim.js +293 -40
- package/dist/slim.js.map +1 -1
- package/package.json +1 -1
- package/src/binding.ts +3 -1
- package/src/dev.d.ts +5 -0
- package/src/dom.ts +20 -2
- package/src/effect.ts +16 -5
- package/src/error-boundary.ts +11 -2
- package/src/lifecycle.ts +4 -1
- package/src/list-helpers.ts +243 -22
- package/src/props.ts +2 -4
- package/src/reconcile.ts +4 -0
- package/src/signal.ts +154 -25
- package/src/suspense.ts +24 -7
- package/src/transition.ts +4 -1
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 (
|
|
316
|
-
|
|
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 (
|
|
570
|
-
|
|
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
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
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
|
-
|
|
155
|
-
|
|
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 {
|