@fictjs/runtime 0.5.2 → 0.7.0

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 (63) hide show
  1. package/dist/advanced.cjs +13 -9
  2. package/dist/advanced.cjs.map +1 -1
  3. package/dist/advanced.d.cts +4 -4
  4. package/dist/advanced.d.ts +4 -4
  5. package/dist/advanced.js +8 -4
  6. package/dist/advanced.js.map +1 -1
  7. package/dist/{chunk-D2IWOO4X.js → chunk-4LCHQ7U4.js} +250 -99
  8. package/dist/chunk-4LCHQ7U4.js.map +1 -0
  9. package/dist/{chunk-LRFMCJY3.js → chunk-7YQK3XKY.js} +120 -27
  10. package/dist/chunk-7YQK3XKY.js.map +1 -0
  11. package/dist/{chunk-QB2UD62G.cjs → chunk-CEV6TO5U.cjs} +8 -8
  12. package/dist/{chunk-QB2UD62G.cjs.map → chunk-CEV6TO5U.cjs.map} +1 -1
  13. package/dist/{chunk-ZR435MDC.cjs → chunk-FSCBL7RI.cjs} +120 -27
  14. package/dist/chunk-FSCBL7RI.cjs.map +1 -0
  15. package/dist/{chunk-KNGHYGK4.cjs → chunk-HHDHQGJY.cjs} +17 -17
  16. package/dist/{chunk-KNGHYGK4.cjs.map → chunk-HHDHQGJY.cjs.map} +1 -1
  17. package/dist/{chunk-Z6M3HKLG.cjs → chunk-PRF4QG73.cjs} +400 -249
  18. package/dist/chunk-PRF4QG73.cjs.map +1 -0
  19. package/dist/{chunk-4NUHM77Z.js → chunk-TLDT76RV.js} +3 -3
  20. package/dist/{chunk-SLFAEVKJ.js → chunk-WRU3IZOA.js} +3 -3
  21. package/dist/{context-CTBE00S_.d.cts → context-BFbHf9nC.d.cts} +1 -1
  22. package/dist/{context-lkLhbkFJ.d.ts → context-C4vBQbb4.d.ts} +1 -1
  23. package/dist/{effect-BpSNEJJz.d.cts → effect-DAzpH7Mm.d.cts} +33 -1
  24. package/dist/{effect-BpSNEJJz.d.ts → effect-DAzpH7Mm.d.ts} +33 -1
  25. package/dist/index.cjs +42 -42
  26. package/dist/index.d.cts +5 -5
  27. package/dist/index.d.ts +5 -5
  28. package/dist/index.dev.js +206 -46
  29. package/dist/index.dev.js.map +1 -1
  30. package/dist/index.js +3 -3
  31. package/dist/internal.cjs +55 -41
  32. package/dist/internal.cjs.map +1 -1
  33. package/dist/internal.d.cts +3 -3
  34. package/dist/internal.d.ts +3 -3
  35. package/dist/internal.js +17 -3
  36. package/dist/internal.js.map +1 -1
  37. package/dist/loader.cjs +9 -9
  38. package/dist/loader.js +1 -1
  39. package/dist/{props-XTHYD19o.d.cts → props-84UJeWO8.d.cts} +1 -1
  40. package/dist/{props-x-HbI-jX.d.ts → props-BRhFK50f.d.ts} +1 -1
  41. package/dist/{scope-CdbGmsFf.d.ts → scope-D3DpsfoG.d.ts} +1 -1
  42. package/dist/{scope-DfcP9I-A.d.cts → scope-DlCBL1Ft.d.cts} +1 -1
  43. package/package.json +1 -1
  44. package/src/advanced.ts +1 -1
  45. package/src/binding.ts +229 -101
  46. package/src/constants.ts +1 -1
  47. package/src/cycle-guard.ts +4 -3
  48. package/src/dom.ts +15 -4
  49. package/src/hooks.ts +1 -1
  50. package/src/internal.ts +7 -0
  51. package/src/lifecycle.ts +1 -1
  52. package/src/props.ts +60 -1
  53. package/src/signal.ts +60 -10
  54. package/src/store.ts +131 -18
  55. package/src/transition.ts +46 -9
  56. package/dist/chunk-D2IWOO4X.js.map +0 -1
  57. package/dist/chunk-LRFMCJY3.js.map +0 -1
  58. package/dist/chunk-Z6M3HKLG.cjs.map +0 -1
  59. package/dist/chunk-ZR435MDC.cjs.map +0 -1
  60. package/dist/jsx-dev-runtime.d.cts +0 -671
  61. package/dist/jsx-dev-runtime.d.ts +0 -671
  62. /package/dist/{chunk-4NUHM77Z.js.map → chunk-TLDT76RV.js.map} +0 -0
  63. /package/dist/{chunk-SLFAEVKJ.js.map → chunk-WRU3IZOA.js.map} +0 -0
package/src/signal.ts CHANGED
@@ -13,7 +13,7 @@ import type { SuspenseToken } from './types'
13
13
  const isDev =
14
14
  typeof __DEV__ !== 'undefined'
15
15
  ? __DEV__
16
- : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
16
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
17
17
 
18
18
  // ============================================================================
19
19
  // Type Definitions
@@ -844,6 +844,9 @@ function updateComputed<T>(c: ComputedNode<T>): boolean {
844
844
  } catch (e) {
845
845
  activeSub = prevSub
846
846
  c.flags &= ~Running
847
+ // Keep dependency graph consistent even when getter throws.
848
+ // Without this, stale old deps can remain subscribed.
849
+ purgeDeps(c)
847
850
  throw e
848
851
  }
849
852
  }
@@ -881,6 +884,9 @@ function runEffect(e: EffectNode): void {
881
884
  } catch (err) {
882
885
  activeSub = prevSub
883
886
  e.flags = Watching
887
+ // Keep dependency graph consistent even when effect throws.
888
+ // Without this, stale old deps can remain subscribed.
889
+ purgeDeps(e)
884
890
  throw err
885
891
  }
886
892
  } else if (flags & Pending && e.deps) {
@@ -920,6 +926,9 @@ function runEffect(e: EffectNode): void {
920
926
  } catch (err) {
921
927
  activeSub = prevSub
922
928
  e.flags = Watching
929
+ // Keep dependency graph consistent even when effect throws.
930
+ // Without this, stale old deps can remain subscribed.
931
+ purgeDeps(e)
923
932
  throw err
924
933
  }
925
934
  } else {
@@ -1050,10 +1059,10 @@ export function signal<T>(initialValue: T, options?: SignalOptions<T>): SignalAc
1050
1059
  subsTail: undefined,
1051
1060
  flags: Mutable,
1052
1061
  __id: undefined as number | undefined,
1053
- ...(options?.equals !== undefined ? { equals: options.equals } : {}),
1054
- ...(options?.name !== undefined ? { name: options.name } : {}),
1055
- ...(options?.devToolsSource !== undefined ? { devToolsSource: options.devToolsSource } : {}),
1056
1062
  }
1063
+ if (options?.equals !== undefined) s.equals = options.equals
1064
+ if (options?.name !== undefined) s.name = options.name
1065
+ if (options?.devToolsSource !== undefined) s.devToolsSource = options.devToolsSource
1057
1066
  if (isDev) registerSignalDevtools(s)
1058
1067
  const accessor = signalOper.bind(s as any) as SignalAccessor<T> & Record<symbol, boolean>
1059
1068
  accessor[SIGNAL_MARKER] = true
@@ -1125,10 +1134,10 @@ export function computed<T>(
1125
1134
  flags: 0,
1126
1135
  getter,
1127
1136
  __id: undefined as number | undefined,
1128
- ...(options?.equals !== undefined ? { equals: options.equals } : {}),
1129
- ...(options?.name !== undefined ? { name: options.name } : {}),
1130
- ...(options?.devToolsSource !== undefined ? { devToolsSource: options.devToolsSource } : {}),
1131
1137
  }
1138
+ if (options?.equals !== undefined) c.equals = options.equals
1139
+ if (options?.name !== undefined) c.name = options.name
1140
+ if (options?.devToolsSource !== undefined) c.devToolsSource = options.devToolsSource
1132
1141
  if (isDev) registerComputedDevtools(c)
1133
1142
  const bound = (computedOper as (this: ComputedNode<T>) => T).bind(
1134
1143
  c as any,
@@ -1163,14 +1172,23 @@ function computedOper<T>(this: ComputedNode<T>): T {
1163
1172
  this.flags = flags & ~Pending
1164
1173
  }
1165
1174
  } else if (!flags) {
1175
+ this.depsTail = undefined
1166
1176
  this.flags = MutableRunning
1167
1177
  const prevSub = setActiveSub(this)
1168
1178
  try {
1169
1179
  this.value = this.getter(undefined)
1170
1180
  if (isDev) updateComputedDevtools(this, this.value)
1181
+ } catch (err) {
1182
+ // Initial evaluation failed: remove partially tracked dependencies
1183
+ // and allow a future read to retry from a clean slate.
1184
+ this.flags = 0
1185
+ purgeDeps(this)
1186
+ throw err
1171
1187
  } finally {
1172
1188
  setActiveSub(prevSub)
1173
- this.flags &= ~Running
1189
+ if (this.flags & Running) {
1190
+ this.flags &= ~Running
1191
+ }
1174
1192
  }
1175
1193
  }
1176
1194
 
@@ -1206,13 +1224,24 @@ export function effect(fn: () => void): EffectDisposer {
1206
1224
  if (prevSub !== undefined) link(e, prevSub, 0)
1207
1225
  activeSub = e
1208
1226
 
1227
+ let didThrow = false
1228
+ let thrown: unknown
1209
1229
  try {
1210
1230
  if (isDev) effectRunDevtools(e)
1211
1231
  fn()
1232
+ } catch (err) {
1233
+ didThrow = true
1234
+ thrown = err
1212
1235
  } finally {
1213
1236
  activeSub = prevSub
1214
- e.flags &= ~Running
1237
+ if (didThrow) {
1238
+ // Initial execution failed: fully detach partially collected graph links.
1239
+ disposeNode(e)
1240
+ } else {
1241
+ e.flags &= ~Running
1242
+ }
1215
1243
  }
1244
+ if (didThrow) throw thrown
1216
1245
 
1217
1246
  const disposer = effectOper.bind(e) as EffectDisposer & Record<symbol, boolean>
1218
1247
  disposer[EFFECT_MARKER] = true
@@ -1254,13 +1283,24 @@ export function effectWithCleanup(
1254
1283
  if (prevSub !== undefined) link(e, prevSub, 0)
1255
1284
  activeSub = e
1256
1285
 
1286
+ let didThrow = false
1287
+ let thrown: unknown
1257
1288
  try {
1258
1289
  if (isDev) effectRunDevtools(e)
1259
1290
  fn()
1291
+ } catch (err) {
1292
+ didThrow = true
1293
+ thrown = err
1260
1294
  } finally {
1261
1295
  activeSub = prevSub
1262
- e.flags &= ~Running
1296
+ if (didThrow) {
1297
+ // Initial execution failed: fully detach partially collected graph links.
1298
+ disposeNode(e)
1299
+ } else {
1300
+ e.flags &= ~Running
1301
+ }
1263
1302
  }
1303
+ if (didThrow) throw thrown
1264
1304
 
1265
1305
  const disposer = effectOper.bind(e) as EffectDisposer & Record<symbol, boolean>
1266
1306
  disposer[EFFECT_MARKER] = true
@@ -1285,11 +1325,21 @@ export function effectScope(fn: () => void): EffectScopeDisposer {
1285
1325
  if (prevSub !== undefined) link(e, prevSub, 0)
1286
1326
  activeSub = e
1287
1327
 
1328
+ let didThrow = false
1329
+ let thrown: unknown
1288
1330
  try {
1289
1331
  fn()
1332
+ } catch (err) {
1333
+ didThrow = true
1334
+ thrown = err
1290
1335
  } finally {
1291
1336
  activeSub = prevSub
1337
+ if (didThrow) {
1338
+ // Scope construction failed: detach nested effects/memos linked to this scope.
1339
+ disposeNode(e)
1340
+ }
1292
1341
  }
1342
+ if (didThrow) throw thrown
1293
1343
 
1294
1344
  const disposer = effectScopeOper.bind(e) as EffectScopeDisposer & Record<symbol, boolean>
1295
1345
  disposer[EFFECT_SCOPE_MARKER] = true
package/src/store.ts CHANGED
@@ -38,6 +38,24 @@ export function createStore<T extends object>(
38
38
  const proxyCache = new WeakMap<object, unknown>()
39
39
  // Map of target object -> Map<key, Signal>
40
40
  const signalCache = new WeakMap<object, Map<string | symbol, SignalAccessor<unknown>>>()
41
+ // Map of target object -> monotonically increasing iterate version
42
+ const iterateVersionCache = new WeakMap<object, number>()
43
+
44
+ function getIterateVersion(target: object): number {
45
+ return iterateVersionCache.get(target) ?? 0
46
+ }
47
+
48
+ function bumpIterateVersion(target: object): number {
49
+ const next = getIterateVersion(target) + 1
50
+ iterateVersionCache.set(target, next)
51
+ return next
52
+ }
53
+
54
+ function isArrayIndexKey(prop: string | symbol): prop is string {
55
+ if (typeof prop !== 'string') return false
56
+ const index = Number(prop)
57
+ return Number.isInteger(index) && index >= 0 && String(index) === prop
58
+ }
41
59
 
42
60
  function wrap<T>(value: T): T {
43
61
  if (value === null || typeof value !== 'object') return value
@@ -75,7 +93,9 @@ function wrap<T>(value: T): T {
75
93
  if (prop === PROXY || prop === TARGET) return false
76
94
 
77
95
  const isArrayLength = Array.isArray(target) && prop === 'length'
78
- const oldLength = isArrayLength ? target.length : undefined
96
+ const isArrayIndex = Array.isArray(target) && isArrayIndexKey(prop)
97
+ const oldLength =
98
+ (isArrayLength || isArrayIndex) && Array.isArray(target) ? target.length : undefined
79
99
  const hadKey = Object.prototype.hasOwnProperty.call(target, prop)
80
100
  const oldValue = Reflect.get(target, prop, receiver)
81
101
  if (oldValue === value) return true
@@ -86,6 +106,12 @@ function wrap<T>(value: T): T {
86
106
  if (!hadKey) {
87
107
  trigger(target, ITERATE_KEY)
88
108
  }
109
+ if (isArrayIndex) {
110
+ const nextLength = target.length
111
+ if (typeof oldLength === 'number' && nextLength !== oldLength) {
112
+ trigger(target, 'length')
113
+ }
114
+ }
89
115
  if (isArrayLength) {
90
116
  const nextLength = target.length
91
117
  if (typeof oldLength === 'number' && nextLength < oldLength) {
@@ -148,8 +174,7 @@ function track(target: object, prop: string | symbol) {
148
174
 
149
175
  let s = signals.get(prop)
150
176
  if (!s) {
151
- const initial =
152
- prop === ITERATE_KEY ? (Reflect.ownKeys(target).length as number) : getLastValue(target, prop)
177
+ const initial = prop === ITERATE_KEY ? getIterateVersion(target) : getLastValue(target, prop)
153
178
  s = signal(initial)
154
179
  signals.set(prop, s)
155
180
  }
@@ -162,7 +187,7 @@ function trigger(target: object, prop: string | symbol) {
162
187
  const s = signals.get(prop)
163
188
  if (s) {
164
189
  if (prop === ITERATE_KEY) {
165
- s(Reflect.ownKeys(target).length)
190
+ s(bumpIterateVersion(target))
166
191
  } else {
167
192
  s(getLastValue(target, prop)) // notify with new value
168
193
  }
@@ -174,10 +199,28 @@ function getLastValue(target: object, prop: string | symbol) {
174
199
  return Reflect.get(target, prop)
175
200
  }
176
201
 
202
+ function isPlainObject(value: object): boolean {
203
+ const proto = Object.getPrototypeOf(value)
204
+ return proto === Object.prototype || proto === null
205
+ }
206
+
207
+ function isReconcilableObject(value: unknown): value is object {
208
+ if (value === null || typeof value !== 'object') return false
209
+ const raw = unwrap(value as object)
210
+ return Array.isArray(raw) || isPlainObject(raw)
211
+ }
212
+
213
+ function canReconcileNestedValues(current: unknown, next: unknown): current is object {
214
+ if (!isReconcilableObject(current) || !isReconcilableObject(next)) return false
215
+ const currentRaw = unwrap(current as object)
216
+ const nextRaw = unwrap(next as object)
217
+ return Array.isArray(currentRaw) === Array.isArray(nextRaw)
218
+ }
219
+
177
220
  /**
178
- * Reconcile a store path with a new value (shallow merge/diff)
221
+ * Reconcile a store path with a new value (recursive structural diff)
179
222
  */
180
- function reconcile(target: object, value: unknown) {
223
+ function reconcile(target: object, value: unknown, seenPairs?: WeakMap<object, WeakSet<object>>) {
181
224
  if (target === value) return
182
225
  if (value === null || typeof value !== 'object') {
183
226
  throw new Error(
@@ -189,23 +232,43 @@ function reconcile(target: object, value: unknown) {
189
232
 
190
233
  const realTarget = unwrap(target)
191
234
  const realValue = unwrap(value)
235
+ const seen = seenPairs ?? new WeakMap<object, WeakSet<object>>()
236
+ let visitedValues = seen.get(realTarget)
237
+ if (!visitedValues) {
238
+ visitedValues = new WeakSet<object>()
239
+ seen.set(realTarget, visitedValues)
240
+ }
241
+ if (visitedValues.has(realValue)) return
242
+ visitedValues.add(realValue)
192
243
 
193
244
  const keys = new Set([...Object.keys(realTarget), ...Object.keys(realValue)])
194
245
  for (const key of keys) {
195
246
  const rTarget = realTarget as Record<string, unknown>
196
247
  const rValue = realValue as Record<string, unknown>
248
+ const hasCurrent = Object.prototype.hasOwnProperty.call(rTarget, key)
249
+ const hasNext = Object.prototype.hasOwnProperty.call(rValue, key)
250
+ const current = rTarget[key]
251
+ const next = rValue[key]
197
252
 
198
- if (rValue[key] === undefined && rTarget[key] !== undefined) {
253
+ if (!hasNext && hasCurrent) {
199
254
  // deleted
200
255
  delete (target as Record<string, unknown>)[key] // Triggers proxy trap
201
- } else if (rTarget[key] !== rValue[key]) {
202
- ;(target as Record<string, unknown>)[key] = rValue[key] // Triggers proxy trap
256
+ } else if (hasNext && (!hasCurrent || current !== next)) {
257
+ if (canReconcileNestedValues(current, next)) {
258
+ reconcile((target as Record<string, unknown>)[key] as object, next, seen)
259
+ } else {
260
+ ;(target as Record<string, unknown>)[key] = next // Triggers proxy trap
261
+ }
203
262
  }
204
263
  }
205
264
 
206
265
  // Fix array length if needed
207
- if (Array.isArray(target) && Array.isArray(realValue) && target.length !== realValue.length) {
208
- target.length = realValue.length
266
+ if (
267
+ Array.isArray(realTarget) &&
268
+ Array.isArray(realValue) &&
269
+ realTarget.length !== realValue.length
270
+ ) {
271
+ ;(target as unknown as unknown[]).length = realValue.length
209
272
  }
210
273
  }
211
274
 
@@ -222,6 +285,16 @@ export function createDiffingSignal<T extends object>(initialValue: T) {
222
285
  let currentValue = unwrap(initialValue)
223
286
  const signals = new Map<string | symbol, SignalAccessor<unknown>>()
224
287
  let iterateSignal: SignalAccessor<number> | undefined
288
+ let iterateVersion = 0
289
+ let ownKeysSnapshot = Reflect.ownKeys(currentValue as object)
290
+
291
+ const hasSameOwnKeys = (aKeys: (string | symbol)[], bKeys: (string | symbol)[]): boolean => {
292
+ if (aKeys.length !== bKeys.length) return false
293
+ for (let i = 0; i < aKeys.length; i++) {
294
+ if (aKeys[i] !== bKeys[i]) return false
295
+ }
296
+ return true
297
+ }
225
298
 
226
299
  const getPropSignal = (prop: string | symbol) => {
227
300
  let s = signals.get(prop)
@@ -234,14 +307,23 @@ export function createDiffingSignal<T extends object>(initialValue: T) {
234
307
 
235
308
  const trackIterate = () => {
236
309
  if (!iterateSignal) {
237
- iterateSignal = signal(Reflect.ownKeys(currentValue).length)
310
+ iterateSignal = signal(iterateVersion)
238
311
  }
239
312
  iterateSignal()
240
313
  }
241
314
 
242
- const updateIterate = (value: T) => {
243
- if (iterateSignal) {
244
- iterateSignal(Reflect.ownKeys(value).length)
315
+ const bumpIterate = () => {
316
+ if (!iterateSignal) return
317
+ iterateVersion += 1
318
+ iterateSignal(iterateVersion)
319
+ }
320
+
321
+ const updateIterateFromOwnKeys = (next: object): void => {
322
+ const nextKeys = Reflect.ownKeys(next)
323
+ const changed = !hasSameOwnKeys(ownKeysSnapshot, nextKeys)
324
+ ownKeysSnapshot = nextKeys
325
+ if (changed) {
326
+ bumpIterate()
245
327
  }
246
328
  }
247
329
 
@@ -265,7 +347,38 @@ export function createDiffingSignal<T extends object>(initialValue: T) {
265
347
  },
266
348
  getOwnPropertyDescriptor(target, prop) {
267
349
  getPropSignal(prop)()
268
- return Reflect.getOwnPropertyDescriptor(currentValue, prop)
350
+ const descriptor = Reflect.getOwnPropertyDescriptor(currentValue, prop)
351
+ if (!descriptor) return undefined
352
+
353
+ // Proxy target is a synthetic empty object. Returning a non-configurable
354
+ // descriptor from the wrapped value can violate Proxy invariants.
355
+ if (descriptor.configurable !== false) {
356
+ return descriptor
357
+ }
358
+
359
+ if ('value' in descriptor) {
360
+ return {
361
+ configurable: true,
362
+ enumerable: descriptor.enumerable ?? true,
363
+ writable: descriptor.writable ?? true,
364
+ value: descriptor.value,
365
+ }
366
+ }
367
+
368
+ const normalized: PropertyDescriptor = {
369
+ configurable: true,
370
+ enumerable: descriptor.enumerable ?? true,
371
+ }
372
+ if (descriptor.get) normalized.get = descriptor.get
373
+ if (descriptor.set) normalized.set = descriptor.set
374
+ return normalized
375
+ },
376
+ set(_, prop) {
377
+ throw new Error(
378
+ `[Fict] Cannot set "${String(
379
+ prop,
380
+ )}" on a diffing signal proxy directly. Update the source value and call its writer instead.`,
381
+ )
269
382
  },
270
383
  })
271
384
 
@@ -283,7 +396,7 @@ export function createDiffingSignal<T extends object>(initialValue: T) {
283
396
  const newVal = Reflect.get(next as object, prop)
284
397
  s(newVal)
285
398
  }
286
- updateIterate(next)
399
+ updateIterateFromOwnKeys(next as object)
287
400
  return
288
401
  }
289
402
 
@@ -297,7 +410,7 @@ export function createDiffingSignal<T extends object>(initialValue: T) {
297
410
  s(newVal)
298
411
  }
299
412
  }
300
- updateIterate(next)
413
+ updateIterateFromOwnKeys(next as object)
301
414
 
302
415
  // Note: If new properties appeared that weren't tracked, we don't care
303
416
  // because no one is listening.
package/src/transition.ts CHANGED
@@ -67,21 +67,58 @@ export function startTransition(fn: () => void): void {
67
67
  * }
68
68
  * ```
69
69
  */
70
- export function useTransition(): [() => boolean, (fn: () => void) => void] {
70
+ export function useTransition(): [() => boolean, (fn: () => void | PromiseLike<unknown>) => void] {
71
71
  const pending = signal(false)
72
+ let pendingCount = 0
72
73
 
73
- const start = (fn: () => void) => {
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.
77
- startTransition(() => {
74
+ const beginPending = () => {
75
+ pendingCount += 1
76
+ if (pendingCount === 1) {
78
77
  pending(true)
78
+ }
79
+ }
80
+
81
+ const endPending = () => {
82
+ if (pendingCount === 0) return
83
+ pendingCount -= 1
84
+ if (pendingCount === 0) {
85
+ pending(false)
86
+ }
87
+ }
88
+
89
+ const start = (fn: () => void | PromiseLike<unknown>) => {
90
+ beginPending()
91
+ let result: void | PromiseLike<unknown> | undefined
92
+ let thrown: unknown
93
+ let didThrow = false
94
+
95
+ startTransition(() => {
79
96
  try {
80
- fn()
81
- } finally {
82
- pending(false)
97
+ result = fn()
98
+ } catch (err) {
99
+ thrown = err
100
+ didThrow = true
83
101
  }
84
102
  })
103
+
104
+ if (didThrow) {
105
+ endPending()
106
+ throw thrown
107
+ }
108
+
109
+ if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
110
+ Promise.resolve(result).finally(() => {
111
+ endPending()
112
+ })
113
+ return
114
+ }
115
+
116
+ // Keep pending true for at least one microtask so UI can observe it.
117
+ if (typeof queueMicrotask === 'function') {
118
+ queueMicrotask(endPending)
119
+ } else {
120
+ Promise.resolve().then(endPending)
121
+ }
85
122
  }
86
123
 
87
124
  return [() => pending(), start]