@fictjs/runtime 0.2.3 → 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 +8 -16
  4. package/dist/advanced.d.ts +8 -16
  5. package/dist/advanced.js +5 -3
  6. package/dist/advanced.js.map +1 -1
  7. package/dist/{chunk-2U6M3LKS.cjs → chunk-ID3WBWNO.cjs} +452 -219
  8. package/dist/chunk-ID3WBWNO.cjs.map +1 -0
  9. package/dist/{chunk-5YTFFAVU.cjs → chunk-L4DIV3RC.cjs} +7 -7
  10. package/dist/{chunk-5YTFFAVU.cjs.map → chunk-L4DIV3RC.cjs.map} +1 -1
  11. package/dist/{chunk-W525IQWC.cjs → chunk-M2TSXZ4C.cjs} +16 -16
  12. package/dist/{chunk-W525IQWC.cjs.map → chunk-M2TSXZ4C.cjs.map} +1 -1
  13. package/dist/{chunk-YVDWXY44.js → chunk-SO6X7G5S.js} +450 -217
  14. package/dist/chunk-SO6X7G5S.js.map +1 -0
  15. package/dist/{chunk-UHXUEGQH.js → chunk-TWELIZRY.js} +2 -2
  16. package/dist/{chunk-3WD7QD5G.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 +322 -145
  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 +59 -7
  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-2U6M3LKS.cjs.map +0 -1
  54. package/dist/chunk-YVDWXY44.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-UHXUEGQH.js.map → chunk-TWELIZRY.js.map} +0 -0
  58. /package/dist/{chunk-3WD7QD5G.js.map → chunk-XLIZJMMJ.js.map} +0 -0
@@ -15,6 +15,10 @@ export interface CycleProtectionOptions {
15
15
  maxRootReentrantDepth?: number
16
16
  enableWindowWarning?: boolean
17
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
18
22
  }
19
23
 
20
24
  interface CycleWindowEntry {
@@ -31,14 +35,17 @@ let enterRootGuard: (root: object) => boolean = () => true
31
35
  let exitRootGuard: (root: object) => void = () => {}
32
36
 
33
37
  const defaultOptions = {
34
- enabled: isDev,
38
+ enabled: true,
35
39
  maxFlushCyclesPerMicrotask: 10_000,
36
40
  maxEffectRunsPerFlush: 20_000,
37
41
  windowSize: 5,
38
42
  highUsageRatio: 0.8,
39
43
  maxRootReentrantDepth: 10,
40
44
  enableWindowWarning: true,
41
- devMode: false,
45
+ devMode: isDev,
46
+ // Backoff warning options
47
+ enableBackoffWarning: isDev,
48
+ backoffWarningRatio: 0.5,
42
49
  }
43
50
 
44
51
  let enabled = defaultOptions.enabled
@@ -52,6 +59,9 @@ let rootDepth = new WeakMap<object, number>()
52
59
  let flushWarned = false
53
60
  let rootWarned = false
54
61
  let windowWarned = false
62
+ // Backoff warning state
63
+ let backoffWarned50 = false
64
+ let backoffWarned75 = false
55
65
 
56
66
  setCycleProtectionOptions = opts => {
57
67
  if (typeof opts.enabled === 'boolean') {
@@ -69,6 +79,9 @@ resetCycleProtectionStateForTests = () => {
69
79
  flushWarned = false
70
80
  rootWarned = false
71
81
  windowWarned = false
82
+ // Reset backoff state
83
+ backoffWarned50 = false
84
+ backoffWarned75 = false
72
85
  }
73
86
 
74
87
  beginFlushGuard = () => {
@@ -76,19 +89,53 @@ beginFlushGuard = () => {
76
89
  effectRunsThisFlush = 0
77
90
  flushWarned = false
78
91
  windowWarned = false
92
+ // Reset backoff state for new flush
93
+ backoffWarned50 = false
94
+ backoffWarned75 = false
79
95
  }
80
96
 
81
97
  beforeEffectRunGuard = () => {
82
98
  if (!enabled) return true
83
99
  const next = ++effectRunsThisFlush
84
- if (next > options.maxFlushCyclesPerMicrotask || next > options.maxEffectRunsPerFlush) {
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
+ )
123
+ }
124
+ }
125
+
126
+ if (next > limit) {
85
127
  const message = `[fict] cycle protection triggered: flush-budget-exceeded`
86
128
  if (options.devMode) {
87
- throw new Error(message)
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
+ )
88
135
  }
89
136
  if (!flushWarned) {
90
137
  flushWarned = true
91
- console.warn(message, { effectRuns: next })
138
+ console.warn(message, { effectRuns: next, limit })
92
139
  }
93
140
  return false
94
141
  }
@@ -107,11 +154,16 @@ enterRootGuard = root => {
107
154
  if (depth > options.maxRootReentrantDepth) {
108
155
  const message = `[fict] cycle protection triggered: root-reentry`
109
156
  if (options.devMode) {
110
- throw new Error(message)
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.`,
162
+ )
111
163
  }
112
164
  if (!rootWarned) {
113
165
  rootWarned = true
114
- console.warn(message, { depth })
166
+ console.warn(message, { depth, maxAllowed: options.maxRootReentrantDepth })
115
167
  }
116
168
  return false
117
169
  }
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> {
package/src/props.ts CHANGED
@@ -203,6 +203,22 @@ export type PropGetter<T> = (() => T) & { __fictProp: true }
203
203
  export interface PropOptions {
204
204
  unwrap?: boolean
205
205
  }
206
+
207
+ /**
208
+ * Create a keyed prop getter that tracks both the key and the target access.
209
+ * Useful for dynamic property access like obj[key] where key is reactive.
210
+ */
211
+ export function keyed<T, K extends string | number | symbol>(
212
+ target: T | PropGetter<T>,
213
+ key: K | (() => K),
214
+ options?: PropOptions,
215
+ ): PropGetter<unknown> {
216
+ return prop(() => {
217
+ const resolvedTarget = isPropGetter(target) ? (target as () => T)() : target
218
+ const resolvedKey = typeof key === 'function' ? (key as () => K)() : key
219
+ return (resolvedTarget as Record<string | number | symbol, unknown>)[resolvedKey]
220
+ }, options)
221
+ }
206
222
  /**
207
223
  * Memoize a prop getter to cache expensive computations.
208
224
  * Use when prop expressions involve heavy calculations or you need lazy, reactive props.