@fictjs/runtime 0.8.0 → 0.10.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 (86) hide show
  1. package/README.md +46 -0
  2. package/dist/advanced.cjs +9 -9
  3. package/dist/advanced.d.cts +5 -5
  4. package/dist/advanced.d.ts +5 -5
  5. package/dist/advanced.js +4 -4
  6. package/dist/{effect-DAzpH7Mm.d.ts → binding-DUEukRxl.d.cts} +35 -24
  7. package/dist/{effect-DAzpH7Mm.d.cts → binding-DqxS9ZQf.d.ts} +35 -24
  8. package/dist/{chunk-WRU3IZOA.js → chunk-2JRPPCG7.js} +3 -3
  9. package/dist/{chunk-TLDT76RV.js → chunk-DKA2I6ET.js} +3 -3
  10. package/dist/{chunk-FSCBL7RI.cjs → chunk-EQ5E4WOV.cjs} +702 -534
  11. package/dist/chunk-EQ5E4WOV.cjs.map +1 -0
  12. package/dist/{chunk-7YQK3XKY.js → chunk-F4RVNXOL.js} +687 -519
  13. package/dist/chunk-F4RVNXOL.js.map +1 -0
  14. package/dist/{chunk-PRF4QG73.cjs → chunk-I4GKKAAY.cjs} +469 -248
  15. package/dist/chunk-I4GKKAAY.cjs.map +1 -0
  16. package/dist/{chunk-HHDHQGJY.cjs → chunk-K3DH5SD5.cjs} +17 -17
  17. package/dist/{chunk-HHDHQGJY.cjs.map → chunk-K3DH5SD5.cjs.map} +1 -1
  18. package/dist/chunk-P4TZLFV6.js +768 -0
  19. package/dist/chunk-P4TZLFV6.js.map +1 -0
  20. package/dist/{chunk-4LCHQ7U4.js → chunk-R6FINS25.js} +318 -97
  21. package/dist/chunk-R6FINS25.js.map +1 -0
  22. package/dist/chunk-SZLJCQFZ.cjs +768 -0
  23. package/dist/chunk-SZLJCQFZ.cjs.map +1 -0
  24. package/dist/{chunk-CEV6TO5U.cjs → chunk-V7BC64W2.cjs} +8 -8
  25. package/dist/{chunk-CEV6TO5U.cjs.map → chunk-V7BC64W2.cjs.map} +1 -1
  26. package/dist/{context-BFbHf9nC.d.cts → devtools-C4Hgfa-S.d.ts} +47 -35
  27. package/dist/{context-C4vBQbb4.d.ts → devtools-CMxlJUTx.d.cts} +47 -35
  28. package/dist/index.cjs +42 -42
  29. package/dist/index.d.cts +5 -5
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.dev.js +233 -28
  32. package/dist/index.dev.js.map +1 -1
  33. package/dist/index.js +3 -3
  34. package/dist/internal-list.cjs +12 -0
  35. package/dist/internal-list.cjs.map +1 -0
  36. package/dist/internal-list.d.cts +2 -0
  37. package/dist/internal-list.d.ts +2 -0
  38. package/dist/internal-list.js +12 -0
  39. package/dist/internal-list.js.map +1 -0
  40. package/dist/internal.cjs +6 -746
  41. package/dist/internal.cjs.map +1 -1
  42. package/dist/internal.d.cts +7 -75
  43. package/dist/internal.d.ts +7 -75
  44. package/dist/internal.js +12 -752
  45. package/dist/internal.js.map +1 -1
  46. package/dist/jsx-dev-runtime.d.cts +671 -0
  47. package/dist/jsx-dev-runtime.d.ts +671 -0
  48. package/dist/jsx-runtime.d.cts +671 -0
  49. package/dist/jsx-runtime.d.ts +671 -0
  50. package/dist/list-BBzsJhrm.d.ts +71 -0
  51. package/dist/list-_NJCcjl1.d.cts +71 -0
  52. package/dist/loader.cjs +99 -16
  53. package/dist/loader.cjs.map +1 -1
  54. package/dist/loader.d.cts +17 -3
  55. package/dist/loader.d.ts +17 -3
  56. package/dist/loader.js +92 -9
  57. package/dist/loader.js.map +1 -1
  58. package/dist/{props-84UJeWO8.d.cts → props--zJ4ebbT.d.cts} +3 -3
  59. package/dist/{props-BRhFK50f.d.ts → props-BAGR7j-j.d.ts} +3 -3
  60. package/dist/{resume-i-A3EFox.d.cts → resume-C5IKAIdh.d.ts} +5 -3
  61. package/dist/{resume-CqeQ3v_q.d.ts → resume-DPZxmA95.d.cts} +5 -3
  62. package/dist/{scope-D3DpsfoG.d.ts → scope-CuImnvh1.d.ts} +1 -1
  63. package/dist/{scope-DlCBL1Ft.d.cts → scope-Dq5hOu7c.d.cts} +1 -1
  64. package/dist/{signal-C4ISF17w.d.cts → signal-Z4KkDk9h.d.cts} +12 -1
  65. package/dist/{signal-C4ISF17w.d.ts → signal-Z4KkDk9h.d.ts} +12 -1
  66. package/package.json +9 -2
  67. package/src/binding.ts +113 -36
  68. package/src/cycle-guard.ts +3 -3
  69. package/src/devtools.ts +19 -2
  70. package/src/dom.ts +58 -4
  71. package/src/effect.ts +5 -5
  72. package/src/hooks.ts +13 -5
  73. package/src/internal/list.ts +7 -0
  74. package/src/internal.ts +1 -0
  75. package/src/lifecycle.ts +41 -3
  76. package/src/list-helpers.ts +1 -1
  77. package/src/loader.ts +128 -12
  78. package/src/resume.ts +6 -3
  79. package/src/signal.ts +200 -20
  80. package/src/transition.ts +9 -3
  81. package/dist/chunk-4LCHQ7U4.js.map +0 -1
  82. package/dist/chunk-7YQK3XKY.js.map +0 -1
  83. package/dist/chunk-FSCBL7RI.cjs.map +0 -1
  84. package/dist/chunk-PRF4QG73.cjs.map +0 -1
  85. /package/dist/{chunk-WRU3IZOA.js.map → chunk-2JRPPCG7.js.map} +0 -0
  86. /package/dist/{chunk-TLDT76RV.js.map → chunk-DKA2I6ET.js.map} +0 -0
package/src/loader.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { DelegatedEvents } from './constants'
2
2
  import {
3
+ FICT_SSR_SNAPSHOT_SCHEMA_VERSION,
4
+ type SSRState,
3
5
  __fictEnableResumable,
4
6
  __fictEnsureScope,
5
7
  __fictGetResume,
@@ -67,6 +69,11 @@ export interface ResumableLoaderOptions {
67
69
  document?: Document
68
70
  snapshotScriptId?: string
69
71
  events?: string[]
72
+ /**
73
+ * Receives structured snapshot/resume issues detected by the loader.
74
+ * Useful for telemetry and fail-safe fallback orchestration.
75
+ */
76
+ onSnapshotIssue?: (issue: SnapshotIssue) => void
70
77
  /**
71
78
  * Prefetch strategy configuration.
72
79
  * Set to false to disable all prefetching.
@@ -75,6 +82,21 @@ export interface ResumableLoaderOptions {
75
82
  prefetch?: PrefetchStrategy | false
76
83
  }
77
84
 
85
+ export type SnapshotIssueCode =
86
+ | 'snapshot_parse_error'
87
+ | 'snapshot_invalid_shape'
88
+ | 'snapshot_unsupported_version'
89
+ | 'scope_snapshot_missing'
90
+
91
+ export interface SnapshotIssue {
92
+ code: SnapshotIssueCode
93
+ message: string
94
+ source: string
95
+ expectedVersion: number
96
+ actualVersion?: number
97
+ scopeId?: string
98
+ }
99
+
78
100
  // ============================================================================
79
101
  // State
80
102
  // ============================================================================
@@ -85,6 +107,8 @@ let prefetchCleanup: (() => void) | null = null
85
107
  let eventListenerCleanup: (() => void) | null = null
86
108
  let snapshotObserver: MutationObserver | null = null
87
109
  const processedSnapshots = new Set<HTMLScriptElement>()
110
+ let snapshotIssueHandler: ((issue: SnapshotIssue) => void) | null = null
111
+ const emittedIssueKeys = new Set<string>()
88
112
 
89
113
  /**
90
114
  * Reset the hydrated scopes set. Useful for testing.
@@ -130,11 +154,14 @@ export function cleanupEventListeners(): void {
130
154
  export function installResumableLoader(options: ResumableLoaderOptions = {}): void {
131
155
  const doc = options.document ?? window.document
132
156
  const scriptId = options.snapshotScriptId ?? '__FICT_SNAPSHOT__'
157
+ snapshotIssueHandler = options.onSnapshotIssue ?? null
133
158
 
134
159
  // Reset hydrated scopes for fresh loader installation
135
160
  hydratedScopes.clear()
136
161
  prefetchedUrls.clear()
137
162
  processedSnapshots.clear()
163
+ emittedIssueKeys.clear()
164
+ __fictSetSSRState(null)
138
165
 
139
166
  // Clean up previous event listeners
140
167
  if (eventListenerCleanup) {
@@ -155,11 +182,9 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
155
182
 
156
183
  const snapshotEl = doc.getElementById(scriptId)
157
184
  if (snapshotEl?.textContent) {
158
- try {
159
- const state = JSON.parse(snapshotEl.textContent)
185
+ const state = parseSnapshotText(snapshotEl.textContent, `#${scriptId}`)
186
+ if (state) {
160
187
  __fictSetSSRState(state)
161
- } catch {
162
- // Ignore parse errors
163
188
  }
164
189
  }
165
190
 
@@ -224,11 +249,88 @@ function parseSnapshotScript(script: HTMLScriptElement): void {
224
249
  processedSnapshots.add(script)
225
250
  const text = script.textContent
226
251
  if (!text) return
227
- try {
228
- const state = JSON.parse(text)
252
+ const source = script.id ? `#${script.id}` : '<script[data-fict-snapshot]>'
253
+ const state = parseSnapshotText(text, source)
254
+ if (state) {
229
255
  __fictMergeSSRState(state)
256
+ }
257
+ }
258
+
259
+ function parseSnapshotText(text: string, source: string): SSRState | null {
260
+ let parsed: unknown
261
+ try {
262
+ parsed = JSON.parse(text)
230
263
  } catch {
231
- // Ignore parse errors
264
+ emitSnapshotIssue({
265
+ code: 'snapshot_parse_error',
266
+ message: '[fict/loader] Failed to parse SSR snapshot JSON.',
267
+ source,
268
+ expectedVersion: FICT_SSR_SNAPSHOT_SCHEMA_VERSION,
269
+ })
270
+ return null
271
+ }
272
+
273
+ return normalizeSnapshotState(parsed, source)
274
+ }
275
+
276
+ function normalizeSnapshotState(value: unknown, source: string): SSRState | null {
277
+ if (!isRecord(value)) {
278
+ emitSnapshotIssue({
279
+ code: 'snapshot_invalid_shape',
280
+ message: '[fict/loader] Snapshot payload must be an object.',
281
+ source,
282
+ expectedVersion: FICT_SSR_SNAPSHOT_SCHEMA_VERSION,
283
+ })
284
+ return null
285
+ }
286
+
287
+ const rawVersion = value.v
288
+ const version = rawVersion === undefined ? FICT_SSR_SNAPSHOT_SCHEMA_VERSION : rawVersion
289
+ if (!Number.isInteger(version) || version !== FICT_SSR_SNAPSHOT_SCHEMA_VERSION) {
290
+ const versionIssue: SnapshotIssue = {
291
+ code: 'snapshot_unsupported_version',
292
+ message: `[fict/loader] Snapshot schema version ${String(version)} is not supported by this runtime.`,
293
+ source,
294
+ expectedVersion: FICT_SSR_SNAPSHOT_SCHEMA_VERSION,
295
+ }
296
+ if (typeof version === 'number') {
297
+ versionIssue.actualVersion = version
298
+ }
299
+ emitSnapshotIssue({
300
+ ...versionIssue,
301
+ })
302
+ return null
303
+ }
304
+
305
+ const scopes = value.scopes
306
+ if (!isRecord(scopes)) {
307
+ emitSnapshotIssue({
308
+ code: 'snapshot_invalid_shape',
309
+ message: '[fict/loader] Snapshot payload is missing a valid `scopes` object.',
310
+ source,
311
+ expectedVersion: FICT_SSR_SNAPSHOT_SCHEMA_VERSION,
312
+ })
313
+ return null
314
+ }
315
+
316
+ return { v: FICT_SSR_SNAPSHOT_SCHEMA_VERSION, scopes: scopes as SSRState['scopes'] }
317
+ }
318
+
319
+ function isRecord(value: unknown): value is Record<string, unknown> {
320
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
321
+ }
322
+
323
+ function emitSnapshotIssue(issue: SnapshotIssue): void {
324
+ const key =
325
+ `${issue.code}|${issue.source}|${issue.scopeId ?? ''}|` +
326
+ `${issue.actualVersion ?? ''}|${issue.expectedVersion}`
327
+ if (emittedIssueKeys.has(key)) return
328
+ emittedIssueKeys.add(key)
329
+
330
+ snapshotIssueHandler?.(issue)
331
+
332
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
333
+ console.warn(issue.message)
232
334
  }
233
335
  }
234
336
 
@@ -405,9 +507,15 @@ function prefetchQrl(qrl: string): void {
405
507
  function handleResumableEvent(event: Event): void {
406
508
  const promise = handleResumableEventAsync(event)
407
509
  pendingHandlers.add(promise)
408
- promise.finally(() => {
409
- pendingHandlers.delete(promise)
410
- })
510
+ void promise
511
+ .catch(error => {
512
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
513
+ console.error('[fict/loader] Failed to handle resumable event.', error)
514
+ }
515
+ })
516
+ .finally(() => {
517
+ pendingHandlers.delete(promise)
518
+ })
411
519
  }
412
520
 
413
521
  async function handleResumableEventAsync(event: Event): Promise<void> {
@@ -425,9 +533,17 @@ async function handleResumableEventAsync(event: Event): Promise<void> {
425
533
  if (!scopeId) continue
426
534
 
427
535
  const snapshot = __fictGetSSRScope(scopeId)
428
- if (snapshot) {
429
- __fictEnsureScope(scopeId, host, snapshot)
536
+ if (!snapshot) {
537
+ emitSnapshotIssue({
538
+ code: 'scope_snapshot_missing',
539
+ message: `[fict/loader] Missing scope snapshot for ${scopeId}; skipping resumable handler execution.`,
540
+ source: 'event',
541
+ expectedVersion: FICT_SSR_SNAPSHOT_SCHEMA_VERSION,
542
+ scopeId,
543
+ })
544
+ continue
430
545
  }
546
+ __fictEnsureScope(scopeId, host, snapshot)
431
547
 
432
548
  const { url, exportName } = parseQrl(qrl)
433
549
 
package/src/resume.ts CHANGED
@@ -36,7 +36,10 @@ export interface ScopeSnapshot {
36
36
  vars?: Record<string, number>
37
37
  }
38
38
 
39
+ export const FICT_SSR_SNAPSHOT_SCHEMA_VERSION = 1
40
+
39
41
  export interface SSRState {
42
+ v: number
40
43
  scopes: Record<string, ScopeSnapshot>
41
44
  }
42
45
 
@@ -173,7 +176,7 @@ export function __fictSerializeSSRState(): SSRState {
173
176
  scopes[id] = snapshot
174
177
  }
175
178
 
176
- return { scopes }
179
+ return { v: FICT_SSR_SNAPSHOT_SCHEMA_VERSION, scopes }
177
180
  }
178
181
 
179
182
  export function __fictSerializeSSRStateForScopes(scopeIds: Iterable<string>): SSRState {
@@ -198,7 +201,7 @@ export function __fictSerializeSSRStateForScopes(scopeIds: Iterable<string>): SS
198
201
  scopes[id] = snapshot
199
202
  }
200
203
 
201
- return { scopes }
204
+ return { v: FICT_SSR_SNAPSHOT_SCHEMA_VERSION, scopes }
202
205
  }
203
206
 
204
207
  export function __fictSetSSRState(state: SSRState | null): void {
@@ -211,7 +214,7 @@ export function __fictSetSSRState(state: SSRState | null): void {
211
214
  export function __fictMergeSSRState(state: SSRState | null): void {
212
215
  if (!state) return
213
216
  if (!snapshotState) {
214
- snapshotState = { scopes: { ...state.scopes } }
217
+ snapshotState = { v: state.v, scopes: { ...state.scopes } }
215
218
  return
216
219
  }
217
220
  Object.assign(snapshotState.scopes, state.scopes)
package/src/signal.ts CHANGED
@@ -93,6 +93,18 @@ export interface MemoOptions<T> {
93
93
  name?: string
94
94
  /** Source location */
95
95
  devToolsSource?: string
96
+ /** Internal memo created by compiler runtime plumbing (hidden from DevTools) */
97
+ internal?: boolean
98
+ }
99
+
100
+ /**
101
+ * Options for creating an effect
102
+ */
103
+ export interface EffectOptions {
104
+ /** Debug name */
105
+ name?: string
106
+ /** Source location */
107
+ devToolsSource?: string
96
108
  }
97
109
 
98
110
  /**
@@ -145,6 +157,8 @@ export interface ComputedNode<T = unknown> extends BaseNode {
145
157
  name?: string
146
158
  /** Source location */
147
159
  devToolsSource?: string
160
+ /** Hide this computed from DevTools (used by compiler-internal memos) */
161
+ devToolsInternal?: boolean
148
162
  }
149
163
 
150
164
  /**
@@ -161,6 +175,10 @@ export interface EffectNode extends BaseNode {
161
175
  runCleanup?: () => void
162
176
  /** Root context for error/suspense handling */
163
177
  root?: RootContext
178
+ /** Debug name */
179
+ name?: string
180
+ /** Source location */
181
+ devToolsSource?: string
164
182
  /** Devtools ID */
165
183
  __id?: number | undefined
166
184
  }
@@ -788,6 +806,16 @@ function purgeDeps(sub: ReactiveNode): void {
788
806
  * @param node - The node to dispose
789
807
  */
790
808
  function disposeNode(node: ReactiveNode): void {
809
+ if (isDev) {
810
+ if ('fn' in node && typeof node.fn === 'function') {
811
+ disposeEffectDevtools(node as EffectNode)
812
+ } else if ('getter' in node && typeof node.getter === 'function') {
813
+ disposeComputedDevtools(node as ComputedNode)
814
+ } else if ('currentValue' in node) {
815
+ disposeSignalDevtools(node as SignalNode)
816
+ }
817
+ }
818
+
791
819
  node.depsTail = undefined
792
820
  node.flags = 0
793
821
  purgeDeps(node)
@@ -858,6 +886,7 @@ function runEffect(e: EffectNode): void {
858
886
  const flags = e.flags
859
887
  const runCleanup = () => {
860
888
  if (!e.runCleanup) return
889
+ if (isDev) effectCleanupDevtools(e)
861
890
  inCleanup = true
862
891
  activeCleanupFlushId = currentFlushId
863
892
  try {
@@ -871,7 +900,6 @@ function runEffect(e: EffectNode): void {
871
900
  // Run cleanup before re-run; values are still the previous commit.
872
901
  runCleanup()
873
902
  ++cycle
874
- if (isDev) effectRunDevtools(e)
875
903
  e.depsTail = undefined
876
904
  e.flags = WatchingRunning
877
905
  const prevSub = activeSub
@@ -913,7 +941,6 @@ function runEffect(e: EffectNode): void {
913
941
  // Cleanup reads should observe previous values for this flush.
914
942
  runCleanup()
915
943
  ++cycle
916
- if (isDev) effectRunDevtools(e)
917
944
  e.depsTail = undefined
918
945
  e.flags = WatchingRunning
919
946
  const prevSub = activeSub
@@ -956,20 +983,31 @@ export function scheduleFlush(): void {
956
983
  */
957
984
  function flush(): void {
958
985
  beginFlushGuard()
986
+ let flushReported = false
987
+ const finishFlush = () => {
988
+ if (flushReported && isDev) {
989
+ flushEndDevtools()
990
+ }
991
+ endFlushGuard()
992
+ }
959
993
  if (batchDepth > 0) {
960
994
  // If batching is active, defer until the batch completes
961
995
  scheduleFlush()
962
- endFlushGuard()
996
+ finishFlush()
963
997
  return
964
998
  }
965
999
  const hasWork = highPriorityQueue.length > 0 || lowPriorityQueue.length > 0
966
1000
  if (!hasWork) {
967
1001
  flushScheduled = false
968
- endFlushGuard()
1002
+ finishFlush()
969
1003
  return
970
1004
  }
971
1005
  currentFlushId++
972
1006
  flushScheduled = false
1007
+ if (isDev) {
1008
+ flushStartDevtools()
1009
+ flushReported = true
1010
+ }
973
1011
 
974
1012
  // 1. Process all high-priority effects first
975
1013
  let highIndex = 0
@@ -993,7 +1031,7 @@ function flush(): void {
993
1031
  highPriorityQueue.length = 0
994
1032
  lowPriorityQueue.length = 0
995
1033
  flushScheduled = false
996
- endFlushGuard()
1034
+ finishFlush()
997
1035
  return
998
1036
  }
999
1037
  highIndex++
@@ -1011,7 +1049,7 @@ function flush(): void {
1011
1049
  lowPriorityQueue.length -= lowIndex
1012
1050
  }
1013
1051
  scheduleFlush()
1014
- endFlushGuard()
1052
+ finishFlush()
1015
1053
  return
1016
1054
  }
1017
1055
  const e = lowPriorityQueue[lowIndex]!
@@ -1033,7 +1071,7 @@ function flush(): void {
1033
1071
  highPriorityQueue.length = 0
1034
1072
  lowPriorityQueue.length = 0
1035
1073
  flushScheduled = false
1036
- endFlushGuard()
1074
+ finishFlush()
1037
1075
  return
1038
1076
  }
1039
1077
  lowIndex++
@@ -1041,7 +1079,7 @@ function flush(): void {
1041
1079
  }
1042
1080
  lowPriorityQueue.length = 0
1043
1081
 
1044
- endFlushGuard()
1082
+ finishFlush()
1045
1083
  }
1046
1084
  // ============================================================================
1047
1085
  // Signal - Inline optimized version
@@ -1138,6 +1176,7 @@ export function computed<T>(
1138
1176
  if (options?.equals !== undefined) c.equals = options.equals
1139
1177
  if (options?.name !== undefined) c.name = options.name
1140
1178
  if (options?.devToolsSource !== undefined) c.devToolsSource = options.devToolsSource
1179
+ if (options?.internal === true) c.devToolsInternal = true
1141
1180
  if (isDev) registerComputedDevtools(c)
1142
1181
  const bound = (computedOper as (this: ComputedNode<T>) => T).bind(
1143
1182
  c as any,
@@ -1201,9 +1240,10 @@ function computedOper<T>(this: ComputedNode<T>): T {
1201
1240
  /**
1202
1241
  * Create a reactive effect
1203
1242
  * @param fn - The effect function
1243
+ * @param options - Effect options
1204
1244
  * @returns An effect disposer function
1205
1245
  */
1206
- export function effect(fn: () => void): EffectDisposer {
1246
+ export function effect(fn: () => void, options?: EffectOptions): EffectDisposer {
1207
1247
  const e: EffectNode = {
1208
1248
  fn,
1209
1249
  subs: undefined,
@@ -1211,6 +1251,8 @@ export function effect(fn: () => void): EffectDisposer {
1211
1251
  deps: undefined,
1212
1252
  depsTail: undefined,
1213
1253
  flags: WatchingRunning,
1254
+ ...(options?.name !== undefined ? { name: options.name } : {}),
1255
+ ...(options?.devToolsSource !== undefined ? { devToolsSource: options.devToolsSource } : {}),
1214
1256
  __id: undefined as number | undefined,
1215
1257
  }
1216
1258
  const root = getCurrentRoot()
@@ -1219,6 +1261,7 @@ export function effect(fn: () => void): EffectDisposer {
1219
1261
  }
1220
1262
 
1221
1263
  if (isDev) registerEffectDevtools(e)
1264
+ e.fn = wrapEffectFnWithDevtoolsTiming(e, fn)
1222
1265
 
1223
1266
  const prevSub = activeSub
1224
1267
  if (prevSub !== undefined) link(e, prevSub, 0)
@@ -1227,8 +1270,7 @@ export function effect(fn: () => void): EffectDisposer {
1227
1270
  let didThrow = false
1228
1271
  let thrown: unknown
1229
1272
  try {
1230
- if (isDev) effectRunDevtools(e)
1231
- fn()
1273
+ e.fn()
1232
1274
  } catch (err) {
1233
1275
  didThrow = true
1234
1276
  thrown = err
@@ -1261,6 +1303,7 @@ export function effectWithCleanup(
1261
1303
  fn: () => void,
1262
1304
  cleanupRunner: () => void,
1263
1305
  root?: RootContext,
1306
+ options?: EffectOptions,
1264
1307
  ): EffectDisposer {
1265
1308
  const e: EffectNode = {
1266
1309
  fn,
@@ -1270,6 +1313,8 @@ export function effectWithCleanup(
1270
1313
  depsTail: undefined,
1271
1314
  flags: WatchingRunning,
1272
1315
  runCleanup: cleanupRunner,
1316
+ ...(options?.name !== undefined ? { name: options.name } : {}),
1317
+ ...(options?.devToolsSource !== undefined ? { devToolsSource: options.devToolsSource } : {}),
1273
1318
  __id: undefined as number | undefined,
1274
1319
  }
1275
1320
  const resolvedRoot = root ?? getCurrentRoot()
@@ -1278,6 +1323,7 @@ export function effectWithCleanup(
1278
1323
  }
1279
1324
 
1280
1325
  if (isDev) registerEffectDevtools(e)
1326
+ e.fn = wrapEffectFnWithDevtoolsTiming(e, fn)
1281
1327
 
1282
1328
  const prevSub = activeSub
1283
1329
  if (prevSub !== undefined) link(e, prevSub, 0)
@@ -1286,8 +1332,7 @@ export function effectWithCleanup(
1286
1332
  let didThrow = false
1287
1333
  let thrown: unknown
1288
1334
  try {
1289
- if (isDev) effectRunDevtools(e)
1290
- fn()
1335
+ e.fn()
1291
1336
  } catch (err) {
1292
1337
  didThrow = true
1293
1338
  thrown = err
@@ -1385,13 +1430,24 @@ export function trigger(fn: () => void): void {
1385
1430
  * Start a batch of updates
1386
1431
  */
1387
1432
  export function startBatch(): void {
1433
+ const enteringOuterBatch = batchDepth === 0
1388
1434
  ++batchDepth
1435
+ if (enteringOuterBatch && isDev) {
1436
+ batchStartDevtools()
1437
+ }
1389
1438
  }
1390
1439
  /**
1391
1440
  * End a batch of updates and flush effects
1392
1441
  */
1393
1442
  export function endBatch(): void {
1394
- if (--batchDepth === 0) flush()
1443
+ if (batchDepth === 0) return
1444
+ --batchDepth
1445
+ if (batchDepth === 0) {
1446
+ if (isDev) {
1447
+ batchEndDevtools()
1448
+ }
1449
+ flush()
1450
+ }
1395
1451
  }
1396
1452
  /**
1397
1453
  * Execute a function in a batch
@@ -1399,7 +1455,11 @@ export function endBatch(): void {
1399
1455
  * @returns The return value of the function
1400
1456
  */
1401
1457
  export function batch<T>(fn: () => T): T {
1458
+ const enteringOuterBatch = batchDepth === 0
1402
1459
  ++batchDepth
1460
+ if (enteringOuterBatch && isDev) {
1461
+ batchStartDevtools()
1462
+ }
1403
1463
  let result!: T
1404
1464
  let error: unknown
1405
1465
  try {
@@ -1409,6 +1469,9 @@ export function batch<T>(fn: () => T): T {
1409
1469
  } finally {
1410
1470
  --batchDepth
1411
1471
  if (batchDepth === 0) {
1472
+ if (isDev) {
1473
+ batchEndDevtools()
1474
+ }
1412
1475
  try {
1413
1476
  flush()
1414
1477
  } catch (flushErr) {
@@ -1463,6 +1526,7 @@ export function __resetReactiveState(): void {
1463
1526
  cycle = 0
1464
1527
  currentFlushId = 0
1465
1528
  activeCleanupFlushId = 0
1529
+ clearDevtoolsSignalSetters()
1466
1530
  }
1467
1531
  /**
1468
1532
  * Execute a function without tracking dependencies
@@ -1588,17 +1652,56 @@ interface DevtoolsIdentifiable {
1588
1652
 
1589
1653
  let registerSignalDevtools: <T>(node: SignalNode<T>) => number | undefined = () => undefined
1590
1654
  let updateSignalDevtools: <T>(node: SignalNode<T>, value: unknown) => void = () => {}
1655
+ let disposeSignalDevtools: <T>(node: SignalNode<T>) => void = () => {}
1591
1656
  let registerComputedDevtools: <T>(node: ComputedNode<T>) => number | undefined = () => undefined
1592
1657
  let updateComputedDevtools: <T>(node: ComputedNode<T>, value: unknown) => void = () => {}
1658
+ let disposeComputedDevtools: <T>(node: ComputedNode<T>) => void = () => {}
1593
1659
  let registerEffectDevtools: (node: EffectNode) => number | undefined = () => undefined
1594
- let effectRunDevtools: (node: EffectNode) => void = () => {}
1660
+ let effectRunDevtools: (node: EffectNode, duration?: number) => void = () => {}
1661
+ let wrapEffectFnWithDevtoolsTiming: (node: EffectNode, fn: () => void) => () => void = (
1662
+ _node,
1663
+ fn,
1664
+ ) => fn
1665
+ let effectCleanupDevtools: (node: EffectNode) => void = () => {}
1666
+ let disposeEffectDevtools: (node: EffectNode) => void = () => {}
1595
1667
  let trackDependencyDevtools: (dep: ReactiveNode, sub: ReactiveNode) => void = () => {}
1596
1668
  let untrackDependencyDevtools: (dep: ReactiveNode, sub: ReactiveNode) => void = () => {}
1597
-
1598
- if (isDev) {
1669
+ let batchStartDevtools: () => void = () => {}
1670
+ let batchEndDevtools: () => void = () => {}
1671
+ let flushStartDevtools: () => void = () => {}
1672
+ let flushEndDevtools: () => void = () => {}
1673
+ let clearDevtoolsSignalSetters: () => void = () => {}
1674
+
1675
+ // Keep this as a direct conditional expression (instead of `if (isDev)`) so
1676
+ // bundlers can eliminate the entire devtools setup block when `__DEV__` is
1677
+ // defined as `false` in production builds.
1678
+ if (
1679
+ typeof __DEV__ !== 'undefined'
1680
+ ? __DEV__
1681
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
1682
+ ) {
1599
1683
  // Unified ID counter for all reactive nodes (signal/computed/effect)
1600
1684
  // to prevent ID collisions when storing in single devtools maps
1601
1685
  let nextDevtoolsId = 0
1686
+ const getSignalSetterMap = () => {
1687
+ if (typeof globalThis === 'undefined') return undefined
1688
+ const global = globalThis as typeof globalThis & {
1689
+ __FICT_DEVTOOLS_SIGNALS__?: Map<number, (value: unknown) => void>
1690
+ }
1691
+ if (!global.__FICT_DEVTOOLS_SIGNALS__) {
1692
+ global.__FICT_DEVTOOLS_SIGNALS__ = new Map<number, (value: unknown) => void>()
1693
+ }
1694
+ return global.__FICT_DEVTOOLS_SIGNALS__
1695
+ }
1696
+
1697
+ const getExistingSignalSetterMap = () => {
1698
+ if (typeof globalThis === 'undefined') return undefined
1699
+ return (
1700
+ globalThis as typeof globalThis & {
1701
+ __FICT_DEVTOOLS_SIGNALS__?: Map<number, (value: unknown) => void>
1702
+ }
1703
+ ).__FICT_DEVTOOLS_SIGNALS__
1704
+ }
1602
1705
 
1603
1706
  registerSignalDevtools = node => {
1604
1707
  const hook = getDevtoolsHook()
@@ -1611,6 +1714,9 @@ if (isDev) {
1611
1714
  if (ownerId !== undefined) (options as any).ownerId = ownerId
1612
1715
  hook.registerSignal(id, node.currentValue, options)
1613
1716
  ;(node as SignalNode & DevtoolsIdentifiable).__id = id
1717
+ getSignalSetterMap()?.set(id, value => {
1718
+ signalOper.call(node as SignalNode<unknown>, value)
1719
+ })
1614
1720
  return id
1615
1721
  }
1616
1722
 
@@ -1621,9 +1727,20 @@ if (isDev) {
1621
1727
  if (id) hook.updateSignal(id, value)
1622
1728
  }
1623
1729
 
1730
+ disposeSignalDevtools = node => {
1731
+ const identifiable = node as SignalNode & DevtoolsIdentifiable
1732
+ const id = identifiable.__id
1733
+ if (!id) return
1734
+ const hook = getDevtoolsHook()
1735
+ hook?.disposeSignal?.(id)
1736
+ getExistingSignalSetterMap()?.delete(id)
1737
+ delete identifiable.__id
1738
+ }
1739
+
1624
1740
  registerComputedDevtools = node => {
1625
1741
  const hook = getDevtoolsHook()
1626
1742
  if (!hook) return undefined
1743
+ if (node.devToolsInternal) return undefined
1627
1744
  const id = ++nextDevtoolsId
1628
1745
  const options: { name?: string; source?: string } = {}
1629
1746
  if (node.name !== undefined) options.name = node.name
@@ -1643,21 +1760,60 @@ if (isDev) {
1643
1760
  if (id) hook.updateComputed(id, value)
1644
1761
  }
1645
1762
 
1763
+ disposeComputedDevtools = node => {
1764
+ const identifiable = node as ComputedNode & DevtoolsIdentifiable
1765
+ const id = identifiable.__id
1766
+ if (!id) return
1767
+ const hook = getDevtoolsHook()
1768
+ hook?.disposeComputed?.(id)
1769
+ delete identifiable.__id
1770
+ }
1771
+
1646
1772
  registerEffectDevtools = node => {
1647
1773
  const hook = getDevtoolsHook()
1648
1774
  if (!hook) return undefined
1649
1775
  const id = ++nextDevtoolsId
1776
+ const options: { ownerId?: number; source?: string } = {}
1650
1777
  const ownerId = __fictGetCurrentComponentId()
1651
- hook.registerEffect(id, ownerId !== undefined ? { ownerId } : undefined)
1778
+ if (ownerId !== undefined) options.ownerId = ownerId
1779
+ if (node.devToolsSource !== undefined) options.source = node.devToolsSource
1780
+ hook.registerEffect(id, Object.keys(options).length > 0 ? options : undefined)
1652
1781
  ;(node as EffectNode & DevtoolsIdentifiable).__id = id
1653
1782
  return id
1654
1783
  }
1655
1784
 
1656
- effectRunDevtools = node => {
1785
+ effectRunDevtools = (node, duration) => {
1786
+ const hook = getDevtoolsHook()
1787
+ if (!hook) return
1788
+ const id = (node as EffectNode & DevtoolsIdentifiable).__id
1789
+ if (id) hook.effectRun(id, duration)
1790
+ }
1791
+
1792
+ wrapEffectFnWithDevtoolsTiming = (node, fn) => {
1793
+ return () => {
1794
+ const startedAt = performance.now()
1795
+ try {
1796
+ fn()
1797
+ } finally {
1798
+ effectRunDevtools(node, performance.now() - startedAt)
1799
+ }
1800
+ }
1801
+ }
1802
+
1803
+ effectCleanupDevtools = node => {
1657
1804
  const hook = getDevtoolsHook()
1658
1805
  if (!hook) return
1659
1806
  const id = (node as EffectNode & DevtoolsIdentifiable).__id
1660
- if (id) hook.effectRun(id)
1807
+ if (id) hook.effectCleanup?.(id)
1808
+ }
1809
+
1810
+ disposeEffectDevtools = node => {
1811
+ const identifiable = node as EffectNode & DevtoolsIdentifiable
1812
+ const id = identifiable.__id
1813
+ if (!id) return
1814
+ const hook = getDevtoolsHook()
1815
+ hook?.disposeEffect?.(id)
1816
+ delete identifiable.__id
1661
1817
  }
1662
1818
 
1663
1819
  trackDependencyDevtools = (dep, sub) => {
@@ -1675,6 +1831,30 @@ if (isDev) {
1675
1831
  const subId = (sub as ReactiveNode & DevtoolsIdentifiable).__id
1676
1832
  if (depId && subId) hook.untrackDependency(subId, depId)
1677
1833
  }
1834
+
1835
+ batchStartDevtools = () => {
1836
+ const hook = getDevtoolsHook()
1837
+ hook?.batchStart?.()
1838
+ }
1839
+
1840
+ batchEndDevtools = () => {
1841
+ const hook = getDevtoolsHook()
1842
+ hook?.batchEnd?.()
1843
+ }
1844
+
1845
+ flushStartDevtools = () => {
1846
+ const hook = getDevtoolsHook()
1847
+ hook?.flushStart?.()
1848
+ }
1849
+
1850
+ flushEndDevtools = () => {
1851
+ const hook = getDevtoolsHook()
1852
+ hook?.flushEnd?.()
1853
+ }
1854
+
1855
+ clearDevtoolsSignalSetters = () => {
1856
+ getExistingSignalSetterMap()?.clear()
1857
+ }
1678
1858
  }
1679
1859
 
1680
1860
  // ============================================================================