@fictjs/runtime 0.2.2 → 0.3.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 (58) hide show
  1. package/dist/advanced.cjs +10 -8
  2. package/dist/advanced.cjs.map +1 -1
  3. package/dist/advanced.d.cts +10 -16
  4. package/dist/advanced.d.ts +10 -16
  5. package/dist/advanced.js +5 -3
  6. package/dist/advanced.js.map +1 -1
  7. package/dist/{chunk-3U7EBKEU.cjs → chunk-ID3WBWNO.cjs} +559 -319
  8. package/dist/chunk-ID3WBWNO.cjs.map +1 -0
  9. package/dist/{chunk-3A4VW6AK.cjs → chunk-L4DIV3RC.cjs} +7 -7
  10. package/dist/{chunk-3A4VW6AK.cjs.map → chunk-L4DIV3RC.cjs.map} +1 -1
  11. package/dist/{chunk-URDFDRHR.cjs → chunk-M2TSXZ4C.cjs} +16 -16
  12. package/dist/{chunk-URDFDRHR.cjs.map → chunk-M2TSXZ4C.cjs.map} +1 -1
  13. package/dist/{chunk-YVS4WJ2W.js → chunk-SO6X7G5S.js} +558 -318
  14. package/dist/chunk-SO6X7G5S.js.map +1 -0
  15. package/dist/{chunk-LU2LD2WJ.js → chunk-TWELIZRY.js} +2 -2
  16. package/dist/{chunk-TEYUDPTA.js → chunk-XLIZJMMJ.js} +2 -2
  17. package/dist/{context-9gFXOdJl.d.cts → context-B25xyQrJ.d.cts} +36 -2
  18. package/dist/{context-4woHo7-L.d.ts → context-CGdP7_Jb.d.ts} +36 -2
  19. package/dist/{effect-ClARNUCc.d.cts → effect-D6kaLM2-.d.cts} +80 -1
  20. package/dist/{effect-ClARNUCc.d.ts → effect-D6kaLM2-.d.ts} +80 -1
  21. package/dist/index.cjs +40 -38
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +4 -4
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.dev.js +430 -246
  26. package/dist/index.dev.js.map +1 -1
  27. package/dist/index.js +4 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal.cjs +39 -35
  30. package/dist/internal.cjs.map +1 -1
  31. package/dist/internal.d.cts +8 -6
  32. package/dist/internal.d.ts +8 -6
  33. package/dist/internal.js +7 -3
  34. package/dist/internal.js.map +1 -1
  35. package/dist/{props-DAyeRPwH.d.ts → props-BEgIVMRx.d.ts} +8 -15
  36. package/dist/{props-CBwuh35e.d.cts → props-BIfromL0.d.cts} +8 -15
  37. package/dist/scope-Cx_3CjIZ.d.cts +18 -0
  38. package/dist/scope-CzNkn587.d.ts +18 -0
  39. package/package.json +1 -1
  40. package/src/advanced.ts +1 -0
  41. package/src/binding.ts +30 -4
  42. package/src/constants.ts +5 -0
  43. package/src/cycle-guard.ts +164 -103
  44. package/src/devtools.ts +22 -2
  45. package/src/dom.ts +84 -10
  46. package/src/hooks.ts +60 -13
  47. package/src/index.ts +3 -1
  48. package/src/internal.ts +2 -2
  49. package/src/lifecycle.ts +13 -5
  50. package/src/memo.ts +3 -4
  51. package/src/props.ts +16 -0
  52. package/src/signal.ts +204 -36
  53. package/dist/chunk-3U7EBKEU.cjs.map +0 -1
  54. package/dist/chunk-YVS4WJ2W.js.map +0 -1
  55. package/dist/scope-DvgMquEy.d.ts +0 -55
  56. package/dist/scope-xmdo6lVU.d.cts +0 -55
  57. /package/dist/{chunk-LU2LD2WJ.js.map → chunk-TWELIZRY.js.map} +0 -0
  58. /package/dist/{chunk-TEYUDPTA.js.map → chunk-XLIZJMMJ.js.map} +0 -0
@@ -6,6 +6,8 @@ const isDev =
6
6
  : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
7
7
 
8
8
  export interface CycleProtectionOptions {
9
+ /** Enable cycle protection guards (defaults to dev-only) */
10
+ enabled?: boolean
9
11
  maxFlushCyclesPerMicrotask?: number
10
12
  maxEffectRunsPerFlush?: number
11
13
  windowSize?: number
@@ -13,6 +15,10 @@ export interface CycleProtectionOptions {
13
15
  maxRootReentrantDepth?: number
14
16
  enableWindowWarning?: boolean
15
17
  devMode?: boolean
18
+ /** Enable backoff warnings at 50% and 75% of limits */
19
+ enableBackoffWarning?: boolean
20
+ /** Ratio at which to show first backoff warning (default 0.5) */
21
+ backoffWarningRatio?: number
16
22
  }
17
23
 
18
24
  interface CycleWindowEntry {
@@ -28,126 +34,181 @@ let endFlushGuard: () => void = () => {}
28
34
  let enterRootGuard: (root: object) => boolean = () => true
29
35
  let exitRootGuard: (root: object) => void = () => {}
30
36
 
31
- if (isDev) {
32
- const defaultOptions = {
33
- maxFlushCyclesPerMicrotask: 10_000,
34
- maxEffectRunsPerFlush: 20_000,
35
- windowSize: 5,
36
- highUsageRatio: 0.8,
37
- maxRootReentrantDepth: 10,
38
- enableWindowWarning: true,
39
- devMode: false,
40
- }
41
-
42
- let options: Required<CycleProtectionOptions> = {
43
- ...defaultOptions,
44
- } as Required<CycleProtectionOptions>
45
-
46
- let effectRunsThisFlush = 0
47
- let windowUsage: CycleWindowEntry[] = []
48
- let rootDepth = new WeakMap<object, number>()
49
- let flushWarned = false
50
- let rootWarned = false
51
- let windowWarned = false
37
+ const defaultOptions = {
38
+ enabled: true,
39
+ maxFlushCyclesPerMicrotask: 10_000,
40
+ maxEffectRunsPerFlush: 20_000,
41
+ windowSize: 5,
42
+ highUsageRatio: 0.8,
43
+ maxRootReentrantDepth: 10,
44
+ enableWindowWarning: true,
45
+ devMode: isDev,
46
+ // Backoff warning options
47
+ enableBackoffWarning: isDev,
48
+ backoffWarningRatio: 0.5,
49
+ }
52
50
 
53
- setCycleProtectionOptions = opts => {
54
- options = { ...options, ...opts }
51
+ let enabled = defaultOptions.enabled
52
+ let options: Required<CycleProtectionOptions> = {
53
+ ...defaultOptions,
54
+ } as Required<CycleProtectionOptions>
55
+
56
+ let effectRunsThisFlush = 0
57
+ let windowUsage: CycleWindowEntry[] = []
58
+ let rootDepth = new WeakMap<object, number>()
59
+ let flushWarned = false
60
+ let rootWarned = false
61
+ let windowWarned = false
62
+ // Backoff warning state
63
+ let backoffWarned50 = false
64
+ let backoffWarned75 = false
65
+
66
+ setCycleProtectionOptions = opts => {
67
+ if (typeof opts.enabled === 'boolean') {
68
+ enabled = opts.enabled
55
69
  }
70
+ options = { ...options, ...opts }
71
+ }
56
72
 
57
- resetCycleProtectionStateForTests = () => {
58
- options = { ...defaultOptions } as Required<CycleProtectionOptions>
59
- effectRunsThisFlush = 0
60
- windowUsage = []
61
- rootDepth = new WeakMap<object, number>()
62
- flushWarned = false
63
- rootWarned = false
64
- windowWarned = false
65
- }
73
+ resetCycleProtectionStateForTests = () => {
74
+ options = { ...defaultOptions } as Required<CycleProtectionOptions>
75
+ enabled = defaultOptions.enabled
76
+ effectRunsThisFlush = 0
77
+ windowUsage = []
78
+ rootDepth = new WeakMap<object, number>()
79
+ flushWarned = false
80
+ rootWarned = false
81
+ windowWarned = false
82
+ // Reset backoff state
83
+ backoffWarned50 = false
84
+ backoffWarned75 = false
85
+ }
66
86
 
67
- beginFlushGuard = () => {
68
- effectRunsThisFlush = 0
69
- flushWarned = false
70
- windowWarned = false
71
- }
87
+ beginFlushGuard = () => {
88
+ if (!enabled) return
89
+ effectRunsThisFlush = 0
90
+ flushWarned = false
91
+ windowWarned = false
92
+ // Reset backoff state for new flush
93
+ backoffWarned50 = false
94
+ backoffWarned75 = false
95
+ }
72
96
 
73
- beforeEffectRunGuard = () => {
74
- const next = ++effectRunsThisFlush
75
- if (next > options.maxFlushCyclesPerMicrotask || next > options.maxEffectRunsPerFlush) {
76
- const message = `[fict] cycle protection triggered: flush-budget-exceeded`
77
- if (options.devMode) {
78
- throw new Error(message)
79
- }
80
- if (!flushWarned) {
81
- flushWarned = true
82
- console.warn(message, { effectRuns: next })
83
- }
84
- return false
97
+ beforeEffectRunGuard = () => {
98
+ if (!enabled) return true
99
+ const next = ++effectRunsThisFlush
100
+ const limit = Math.min(options.maxFlushCyclesPerMicrotask, options.maxEffectRunsPerFlush)
101
+
102
+ // Backoff warnings at 50% and 75% of limit
103
+ if (options.enableBackoffWarning && isDev) {
104
+ const ratio = next / limit
105
+ const backoffRatio = options.backoffWarningRatio ?? 0.5
106
+
107
+ if (!backoffWarned50 && ratio >= backoffRatio && ratio < backoffRatio + 0.25) {
108
+ backoffWarned50 = true
109
+ console.warn(
110
+ `[fict] cycle guard: approaching effect limit (${Math.round(ratio * 100)}% of budget used)\n` +
111
+ ` - Current: ${next} effects, Limit: ${limit}\n` +
112
+ ` - Tip: Check for effects that trigger other effects in a loop.\n` +
113
+ ` - Common causes: signal updates inside effects that read and write the same signal.`,
114
+ )
115
+ } else if (!backoffWarned75 && ratio >= backoffRatio + 0.25 && ratio < 1) {
116
+ backoffWarned75 = true
117
+ console.warn(
118
+ `[fict] cycle guard: nearing effect limit (${Math.round(ratio * 100)}% of budget used)\n` +
119
+ ` - Current: ${next} effects, Limit: ${limit}\n` +
120
+ ` - Warning: Consider breaking the reactive dependency cycle.\n` +
121
+ ` - Debug: Use browser devtools to identify the recursive effect chain.`,
122
+ )
85
123
  }
86
- return true
87
124
  }
88
125
 
89
- endFlushGuard = () => {
90
- recordWindowUsage(effectRunsThisFlush, options.maxFlushCyclesPerMicrotask)
91
- effectRunsThisFlush = 0
92
- }
93
-
94
- enterRootGuard = root => {
95
- const depth = (rootDepth.get(root) ?? 0) + 1
96
- if (depth > options.maxRootReentrantDepth) {
97
- const message = `[fict] cycle protection triggered: root-reentry`
98
- if (options.devMode) {
99
- throw new Error(message)
100
- }
101
- if (!rootWarned) {
102
- rootWarned = true
103
- console.warn(message, { depth })
104
- }
105
- return false
126
+ if (next > limit) {
127
+ const message = `[fict] cycle protection triggered: flush-budget-exceeded`
128
+ if (options.devMode) {
129
+ throw new Error(
130
+ message +
131
+ `\n - Effect runs: ${next}, Limit: ${limit}` +
132
+ `\n - This indicates a reactive cycle where effects keep triggering each other.` +
133
+ `\n - Check for patterns like: createEffect(() => { signal(); signal(newValue); })`,
134
+ )
106
135
  }
107
- rootDepth.set(root, depth)
108
- return true
109
- }
110
-
111
- exitRootGuard = root => {
112
- const depth = rootDepth.get(root)
113
- if (depth === undefined) return
114
- if (depth <= 1) {
115
- rootDepth.delete(root)
116
- } else {
117
- rootDepth.set(root, depth - 1)
136
+ if (!flushWarned) {
137
+ flushWarned = true
138
+ console.warn(message, { effectRuns: next, limit })
118
139
  }
140
+ return false
119
141
  }
142
+ return true
143
+ }
120
144
 
121
- const recordWindowUsage = (used: number, budget: number): void => {
122
- if (!options.enableWindowWarning) return
123
- const entry = { used, budget }
124
- windowUsage.push(entry)
125
- if (windowUsage.length > options.windowSize) {
126
- windowUsage.shift()
127
- }
128
- if (windowWarned) return
129
- if (
130
- windowUsage.length >= options.windowSize &&
131
- windowUsage.every(
132
- item => item.budget > 0 && item.used / item.budget >= options.highUsageRatio,
145
+ endFlushGuard = () => {
146
+ if (!enabled) return
147
+ recordWindowUsage(effectRunsThisFlush, options.maxFlushCyclesPerMicrotask)
148
+ effectRunsThisFlush = 0
149
+ }
150
+
151
+ enterRootGuard = root => {
152
+ if (!enabled) return true
153
+ const depth = (rootDepth.get(root) ?? 0) + 1
154
+ if (depth > options.maxRootReentrantDepth) {
155
+ const message = `[fict] cycle protection triggered: root-reentry`
156
+ if (options.devMode) {
157
+ throw new Error(
158
+ message +
159
+ `\n - Re-entry depth: ${depth}, Max allowed: ${options.maxRootReentrantDepth}` +
160
+ `\n - This indicates recursive render() or component initialization.` +
161
+ `\n - Check for components that trigger re-renders during their own render phase.`,
133
162
  )
134
- ) {
135
- windowWarned = true
136
- reportCycle('high-usage-window', {
137
- windowSize: options.windowSize,
138
- ratio: options.highUsageRatio,
139
- })
140
163
  }
164
+ if (!rootWarned) {
165
+ rootWarned = true
166
+ console.warn(message, { depth, maxAllowed: options.maxRootReentrantDepth })
167
+ }
168
+ return false
141
169
  }
170
+ rootDepth.set(root, depth)
171
+ return true
172
+ }
173
+
174
+ exitRootGuard = root => {
175
+ if (!enabled) return
176
+ const depth = rootDepth.get(root)
177
+ if (depth === undefined) return
178
+ if (depth <= 1) {
179
+ rootDepth.delete(root)
180
+ } else {
181
+ rootDepth.set(root, depth - 1)
182
+ }
183
+ }
142
184
 
143
- const reportCycle = (
144
- reason: string,
145
- detail: Record<string, unknown> | undefined = undefined,
146
- ): void => {
147
- const hook = getDevtoolsHook()
148
- hook?.cycleDetected?.(detail ? { reason, detail } : { reason })
149
- console.warn(`[fict] cycle protection triggered: ${reason}`, detail ?? '')
185
+ const recordWindowUsage = (used: number, budget: number): void => {
186
+ if (!options.enableWindowWarning) return
187
+ const entry = { used, budget }
188
+ windowUsage.push(entry)
189
+ if (windowUsage.length > options.windowSize) {
190
+ windowUsage.shift()
150
191
  }
192
+ if (windowWarned) return
193
+ if (
194
+ windowUsage.length >= options.windowSize &&
195
+ windowUsage.every(item => item.budget > 0 && item.used / item.budget >= options.highUsageRatio)
196
+ ) {
197
+ windowWarned = true
198
+ reportCycle('high-usage-window', {
199
+ windowSize: options.windowSize,
200
+ ratio: options.highUsageRatio,
201
+ })
202
+ }
203
+ }
204
+
205
+ const reportCycle = (
206
+ reason: string,
207
+ detail: Record<string, unknown> | undefined = undefined,
208
+ ): void => {
209
+ const hook = getDevtoolsHook()
210
+ hook?.cycleDetected?.(detail ? { reason, detail } : { reason })
211
+ console.warn(`[fict] cycle protection triggered: ${reason}`, detail ?? '')
151
212
  }
152
213
 
153
214
  export {
package/src/devtools.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  export interface FictDevtoolsHook {
2
- registerSignal: (id: number, value: unknown) => void
2
+ registerSignal: (
3
+ id: number,
4
+ value: unknown,
5
+ options?: { name?: string; source?: string; ownerId?: number },
6
+ ) => void
3
7
  updateSignal: (id: number, value: unknown) => void
4
- registerEffect: (id: number) => void
8
+ registerComputed: (
9
+ id: number,
10
+ value: unknown,
11
+ options?: { name?: string; source?: string; ownerId?: number; hasValue?: boolean },
12
+ ) => void
13
+ updateComputed: (id: number, value: unknown) => void
14
+ registerEffect: (id: number, options?: { ownerId?: number; source?: string }) => void
5
15
  effectRun: (id: number) => void
16
+ /** Track a dependency relationship between subscriber and dependency */
17
+ trackDependency?: (subscriberId: number, dependencyId: number) => void
18
+ /** Remove a dependency relationship when unlinked */
19
+ untrackDependency?: (subscriberId: number, dependencyId: number) => void
6
20
  cycleDetected?: (payload: { reason: string; detail?: Record<string, unknown> }) => void
21
+
22
+ // Component lifecycle
23
+ registerComponent?: (id: number, name: string, parentId?: number, source?: any) => void
24
+ componentMount?: (id: number, elements?: HTMLElement[]) => void
25
+ componentUnmount?: (id: number) => void
26
+ componentRender?: (id: number) => void
7
27
  }
8
28
 
9
29
  function getGlobalHook(): FictDevtoolsHook | undefined {
package/src/dom.ts CHANGED
@@ -25,7 +25,8 @@ import {
25
25
  type BindingHandle,
26
26
  } from './binding'
27
27
  import { Properties, ChildProperties, getPropAlias, SVGElements, SVGNamespace } from './constants'
28
- import { __fictPushContext, __fictPopContext } from './hooks'
28
+ import { getDevtoolsHook } from './devtools'
29
+ import { __fictPushContext, __fictPopContext, __fictGetCurrentComponentId } from './hooks'
29
30
  import { Fragment } from './jsx'
30
31
  import {
31
32
  createRootContext,
@@ -37,6 +38,8 @@ import {
37
38
  popRoot,
38
39
  registerRootCleanup,
39
40
  getCurrentRoot,
41
+ onMount,
42
+ onCleanup,
40
43
  } from './lifecycle'
41
44
  import { createPropsProxy, unwrapProps } from './props'
42
45
  import { untrack } from './scheduler'
@@ -51,6 +54,8 @@ const isDev =
51
54
  ? __DEV__
52
55
  : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
53
56
 
57
+ let nextComponentId = 1
58
+
54
59
  // ============================================================================
55
60
  // Main Render Function
56
61
  // ============================================================================
@@ -209,9 +214,34 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
209
214
  const props = createPropsProxy(baseProps)
210
215
  // Create a fresh hook context for this component instance.
211
216
  // This preserves slot state across re-renders driven by __fictRender.
212
- __fictPushContext()
217
+ const hook = isDev ? getDevtoolsHook() : undefined
218
+ const parentId = hook ? __fictGetCurrentComponentId() : undefined
219
+ const componentId = hook ? nextComponentId++ : undefined
220
+
221
+ // Register component
222
+ if (hook?.registerComponent && componentId !== undefined) {
223
+ hook.registerComponent(componentId, vnode.type.name || 'Anonymous', parentId)
224
+ }
225
+
226
+ const ctx = __fictPushContext()
227
+ if (componentId !== undefined) {
228
+ ctx.componentId = componentId
229
+ if (parentId !== undefined) {
230
+ ctx.parentId = parentId
231
+ }
232
+ }
233
+
213
234
  try {
214
235
  const rendered = vnode.type(props)
236
+
237
+ // Register lifecycle hooks
238
+ if (hook && componentId !== undefined) {
239
+ onMount(() => {
240
+ hook.componentMount?.(componentId)
241
+ })
242
+ onCleanup(() => hook.componentUnmount?.(componentId))
243
+ }
244
+
215
245
  return createElementWithContext(rendered as FictNode, namespace)
216
246
  } catch (err) {
217
247
  if (handleSuspend(err as any)) {
@@ -268,19 +298,63 @@ export function template(
268
298
  let node: Node | null = null
269
299
 
270
300
  const create = (): Node => {
271
- const t = isMathML
272
- ? document.createElementNS(MATHML_NS, 'template')
273
- : document.createElement('template')
274
- t.innerHTML = html
301
+ const t = document.createElement('template')
275
302
 
276
303
  if (isSVG) {
277
- // For SVG, get the nested content
278
- return (t as HTMLTemplateElement).content.firstChild!.firstChild!
304
+ // fix: Wrap HTML in <svg> to parse content in SVG namespace
305
+ // Then extract the actual content (firstChild of the wrapper svg)
306
+ t.innerHTML = `<svg>${html}</svg>`
307
+ const wrapper = (t as HTMLTemplateElement).content.firstChild!
308
+ // Dev check for multi-root SVG templates
309
+ if (isDev && wrapper.childNodes.length !== 1) {
310
+ console.warn(
311
+ `[fict] template() received multi-root SVG content (${wrapper.childNodes.length} nodes). ` +
312
+ `Returning a DocumentFragment. This may indicate a compiler bug or invalid JSX structure.`,
313
+ )
314
+ }
315
+ if (wrapper.childNodes.length === 1) {
316
+ return wrapper.firstChild!
317
+ }
318
+ // Preserve all root nodes by returning a fragment
319
+ const fragment = document.createDocumentFragment()
320
+ fragment.append(...Array.from(wrapper.childNodes))
321
+ return fragment
279
322
  }
280
323
  if (isMathML) {
281
- return t.firstChild!
324
+ // fix: Wrap HTML in <math> to parse content in MathML namespace
325
+ // Then extract the actual content (firstChild of the wrapper math)
326
+ t.innerHTML = `<math>${html}</math>`
327
+ const wrapper = (t as HTMLTemplateElement).content.firstChild!
328
+ // Dev check for multi-root MathML templates
329
+ if (isDev && wrapper.childNodes.length !== 1) {
330
+ console.warn(
331
+ `[fict] template() received multi-root MathML content (${wrapper.childNodes.length} nodes). ` +
332
+ `Returning a DocumentFragment. This may indicate a compiler bug or invalid JSX structure.`,
333
+ )
334
+ }
335
+ if (wrapper.childNodes.length === 1) {
336
+ return wrapper.firstChild!
337
+ }
338
+ // Preserve all root nodes by returning a fragment
339
+ const fragment = document.createDocumentFragment()
340
+ fragment.append(...Array.from(wrapper.childNodes))
341
+ return fragment
342
+ }
343
+
344
+ t.innerHTML = html
345
+ const content = (t as HTMLTemplateElement).content
346
+ // Dev check for multi-root templates
347
+ if (isDev && content.childNodes.length !== 1) {
348
+ console.warn(
349
+ `[fict] template() received multi-root content (${content.childNodes.length} nodes). ` +
350
+ `Returning a DocumentFragment. This may indicate a compiler bug or invalid JSX structure.`,
351
+ )
352
+ }
353
+ if (content.childNodes.length === 1) {
354
+ return content.firstChild!
282
355
  }
283
- return (t as HTMLTemplateElement).content.firstChild!
356
+ // Preserve all root nodes by returning a fragment
357
+ return content
284
358
  }
285
359
 
286
360
  // Create the cloning function
package/src/hooks.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { createEffect } from './effect'
2
2
  import { createMemo } from './memo'
3
- import { createSignal, type SignalAccessor, type ComputedAccessor } from './signal'
3
+ import {
4
+ createSignal,
5
+ type SignalAccessor,
6
+ type ComputedAccessor,
7
+ type MemoOptions,
8
+ type SignalOptions,
9
+ } from './signal'
4
10
 
5
11
  const isDev =
6
12
  typeof __DEV__ !== 'undefined'
@@ -11,6 +17,8 @@ interface HookContext {
11
17
  slots: unknown[]
12
18
  cursor: number
13
19
  rendering?: boolean
20
+ componentId?: number
21
+ parentId?: number
14
22
  }
15
23
 
16
24
  const ctxStack: HookContext[] = []
@@ -26,13 +34,22 @@ function assertRenderContext(ctx: HookContext, hookName: string): void {
26
34
 
27
35
  export function __fictUseContext(): HookContext {
28
36
  if (ctxStack.length === 0) {
29
- const ctx: HookContext = { slots: [], cursor: 0, rendering: true }
30
- ctxStack.push(ctx)
31
- return ctx
37
+ // fix: Don't silently create context when called outside render.
38
+ // This would cause a memory leak and undefined behavior.
39
+ const message = isDev
40
+ ? 'Invalid hook call: hooks can only be used while rendering a component. ' +
41
+ 'Make sure you are not calling hooks in event handlers or outside of components.'
42
+ : 'FICT:E_HOOK_OUTSIDE_RENDER'
43
+ throw new Error(message)
32
44
  }
33
45
  const ctx = ctxStack[ctxStack.length - 1]!
34
- ctx.cursor = 0
35
- ctx.rendering = true
46
+ // fix: Only reset cursor when starting a new render, not during an existing render.
47
+ // This allows custom hooks to share the same hook slot sequence as the calling component,
48
+ // similar to React's "rules of hooks" where hooks are called in consistent order.
49
+ if (!ctx.rendering) {
50
+ ctx.cursor = 0
51
+ ctx.rendering = true
52
+ }
36
53
  return ctx
37
54
  }
38
55
 
@@ -42,19 +59,32 @@ export function __fictPushContext(): HookContext {
42
59
  return ctx
43
60
  }
44
61
 
62
+ export function __fictGetCurrentComponentId(): number | undefined {
63
+ return ctxStack[ctxStack.length - 1]?.componentId
64
+ }
65
+
45
66
  export function __fictPopContext(): void {
46
- ctxStack.pop()
67
+ // fix: Reset rendering flag when popping to avoid state leakage
68
+ const ctx = ctxStack.pop()
69
+ if (ctx) ctx.rendering = false
47
70
  }
48
71
 
49
72
  export function __fictResetContext(): void {
50
73
  ctxStack.length = 0
51
74
  }
52
75
 
53
- export function __fictUseSignal<T>(ctx: HookContext, initial: T, slot?: number): SignalAccessor<T> {
76
+ export function __fictUseSignal<T>(
77
+ ctx: HookContext,
78
+ initial: T,
79
+ optionsOrSlot?: number | SignalOptions<T>,
80
+ slot?: number,
81
+ ): SignalAccessor<T> {
54
82
  assertRenderContext(ctx, '__fictUseSignal')
55
- const index = slot ?? ctx.cursor++
83
+ const options = typeof optionsOrSlot === 'number' ? undefined : optionsOrSlot
84
+ const resolvedSlot = typeof optionsOrSlot === 'number' ? optionsOrSlot : slot
85
+ const index = resolvedSlot ?? ctx.cursor++
56
86
  if (!ctx.slots[index]) {
57
- ctx.slots[index] = createSignal(initial)
87
+ ctx.slots[index] = createSignal(initial, options)
58
88
  }
59
89
  return ctx.slots[index] as SignalAccessor<T>
60
90
  }
@@ -62,19 +92,36 @@ export function __fictUseSignal<T>(ctx: HookContext, initial: T, slot?: number):
62
92
  export function __fictUseMemo<T>(
63
93
  ctx: HookContext,
64
94
  fn: () => T,
95
+ optionsOrSlot?: number | MemoOptions<T>,
65
96
  slot?: number,
66
97
  ): ComputedAccessor<T> {
67
98
  assertRenderContext(ctx, '__fictUseMemo')
68
- const index = slot ?? ctx.cursor++
99
+ const options = typeof optionsOrSlot === 'number' ? undefined : optionsOrSlot
100
+ const resolvedSlot = typeof optionsOrSlot === 'number' ? optionsOrSlot : slot
101
+ const index = resolvedSlot ?? ctx.cursor++
69
102
  if (!ctx.slots[index]) {
70
- ctx.slots[index] = createMemo(fn)
103
+ ctx.slots[index] = createMemo(fn, options)
71
104
  }
72
105
  return ctx.slots[index] as ComputedAccessor<T>
73
106
  }
74
107
 
75
108
  export function __fictUseEffect(ctx: HookContext, fn: () => void, slot?: number): void {
109
+ // fix: When a slot number is provided, we trust the compiler has allocated this slot.
110
+ // This allows effects inside conditional callbacks to work even outside render context.
111
+ // The slot number proves this is a known, statically-allocated effect location.
112
+ if (slot !== undefined) {
113
+ if (ctx.slots[slot]) {
114
+ // Effect already exists, nothing to do
115
+ return
116
+ }
117
+ // Create the effect even outside render context - the slot number proves validity
118
+ ctx.slots[slot] = createEffect(fn)
119
+ return
120
+ }
121
+
122
+ // For cursor-based allocation (no slot number), we need render context
76
123
  assertRenderContext(ctx, '__fictUseEffect')
77
- const index = slot ?? ctx.cursor++
124
+ const index = ctx.cursor++
78
125
  if (!ctx.slots[index]) {
79
126
  ctx.slots[index] = createEffect(fn)
80
127
  }
package/src/index.ts CHANGED
@@ -73,7 +73,7 @@ export { createContext, useContext, hasContext, type Context, type ProviderProps
73
73
  // Props Utilities (Public)
74
74
  // ============================================================================
75
75
 
76
- export { prop, mergeProps } from './props'
76
+ export { prop, mergeProps, keyed } from './props'
77
77
 
78
78
  // ============================================================================
79
79
  // Types
@@ -96,3 +96,5 @@ export type {
96
96
  ErrorInfo,
97
97
  SuspenseToken,
98
98
  } from './types'
99
+
100
+ export type { FictDevtoolsHook } from './devtools'
package/src/internal.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // Core Primitives (also exported from main, but needed by compiler)
13
13
  // ============================================================================
14
14
 
15
- export { createSignal, createSelector } from './signal'
15
+ export { createSignal, createSelector, __resetReactiveState } from './signal'
16
16
  export { createStore, type Store } from './store'
17
17
  export { createMemo } from './memo'
18
18
  export { createEffect } from './effect'
@@ -37,7 +37,7 @@ export {
37
37
  // Props Helpers (Compiler-generated code)
38
38
  // ============================================================================
39
39
 
40
- export { __fictProp, __fictPropsRest, createPropsProxy, mergeProps, prop } from './props'
40
+ export { __fictProp, __fictPropsRest, createPropsProxy, mergeProps, prop, keyed } from './props'
41
41
 
42
42
  // ============================================================================
43
43
  // DOM Bindings (Compiler-generated code)
package/src/lifecycle.ts CHANGED
@@ -77,13 +77,21 @@ export function onCleanup(fn: Cleanup): void {
77
77
  export function flushOnMount(root: RootContext): void {
78
78
  const cbs = root.onMountCallbacks
79
79
  if (!cbs || cbs.length === 0) return
80
- for (let i = 0; i < cbs.length; i++) {
81
- const cleanup = cbs[i]!()
82
- if (typeof cleanup === 'function') {
83
- root.cleanups.push(cleanup)
80
+ // Temporarily restore root context so onCleanup calls inside
81
+ // mount callbacks register correctly
82
+ const prevRoot = currentRoot
83
+ currentRoot = root
84
+ try {
85
+ for (let i = 0; i < cbs.length; i++) {
86
+ const cleanup = cbs[i]!()
87
+ if (typeof cleanup === 'function') {
88
+ root.cleanups.push(cleanup)
89
+ }
84
90
  }
91
+ } finally {
92
+ currentRoot = prevRoot
93
+ cbs.length = 0
85
94
  }
86
- cbs.length = 0
87
95
  }
88
96
 
89
97
  export function registerRootCleanup(fn: Cleanup): void {
package/src/memo.ts CHANGED
@@ -1,10 +1,9 @@
1
- import { computed } from './signal'
2
- import type { Signal } from './signal'
1
+ import { computed, type Signal, type MemoOptions } from './signal'
3
2
 
4
3
  export type Memo<T> = () => T
5
4
 
6
- export function createMemo<T>(fn: () => T): Memo<T> {
7
- return computed(fn)
5
+ export function createMemo<T>(fn: () => T, options?: MemoOptions<T>): Memo<T> {
6
+ return computed(fn, options)
8
7
  }
9
8
 
10
9
  export function fromSignal<T>(signal: Signal<T>): Memo<T> {