@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.
- package/dist/advanced.cjs +10 -8
- package/dist/advanced.cjs.map +1 -1
- package/dist/advanced.d.cts +8 -16
- package/dist/advanced.d.ts +8 -16
- package/dist/advanced.js +5 -3
- package/dist/advanced.js.map +1 -1
- package/dist/{chunk-2U6M3LKS.cjs → chunk-ID3WBWNO.cjs} +452 -219
- package/dist/chunk-ID3WBWNO.cjs.map +1 -0
- package/dist/{chunk-5YTFFAVU.cjs → chunk-L4DIV3RC.cjs} +7 -7
- package/dist/{chunk-5YTFFAVU.cjs.map → chunk-L4DIV3RC.cjs.map} +1 -1
- package/dist/{chunk-W525IQWC.cjs → chunk-M2TSXZ4C.cjs} +16 -16
- package/dist/{chunk-W525IQWC.cjs.map → chunk-M2TSXZ4C.cjs.map} +1 -1
- package/dist/{chunk-YVDWXY44.js → chunk-SO6X7G5S.js} +450 -217
- package/dist/chunk-SO6X7G5S.js.map +1 -0
- package/dist/{chunk-UHXUEGQH.js → chunk-TWELIZRY.js} +2 -2
- package/dist/{chunk-3WD7QD5G.js → chunk-XLIZJMMJ.js} +2 -2
- package/dist/{context-9gFXOdJl.d.cts → context-B25xyQrJ.d.cts} +36 -2
- package/dist/{context-4woHo7-L.d.ts → context-CGdP7_Jb.d.ts} +36 -2
- package/dist/{effect-ClARNUCc.d.cts → effect-D6kaLM2-.d.cts} +80 -1
- package/dist/{effect-ClARNUCc.d.ts → effect-D6kaLM2-.d.ts} +80 -1
- package/dist/index.cjs +40 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.dev.js +322 -145
- package/dist/index.dev.js.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +39 -35
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +8 -6
- package/dist/internal.d.ts +8 -6
- package/dist/internal.js +7 -3
- package/dist/internal.js.map +1 -1
- package/dist/{props-DAyeRPwH.d.ts → props-BEgIVMRx.d.ts} +8 -15
- package/dist/{props-CBwuh35e.d.cts → props-BIfromL0.d.cts} +8 -15
- package/dist/scope-Cx_3CjIZ.d.cts +18 -0
- package/dist/scope-CzNkn587.d.ts +18 -0
- package/package.json +1 -1
- package/src/advanced.ts +1 -0
- package/src/binding.ts +30 -4
- package/src/constants.ts +5 -0
- package/src/cycle-guard.ts +59 -7
- package/src/devtools.ts +22 -2
- package/src/dom.ts +84 -10
- package/src/hooks.ts +60 -13
- package/src/index.ts +3 -1
- package/src/internal.ts +2 -2
- package/src/lifecycle.ts +13 -5
- package/src/memo.ts +3 -4
- package/src/props.ts +16 -0
- package/src/signal.ts +204 -36
- package/dist/chunk-2U6M3LKS.cjs.map +0 -1
- package/dist/chunk-YVDWXY44.js.map +0 -1
- package/dist/scope-DvgMquEy.d.ts +0 -55
- package/dist/scope-xmdo6lVU.d.cts +0 -55
- /package/dist/{chunk-UHXUEGQH.js.map → chunk-TWELIZRY.js.map} +0 -0
- /package/dist/{chunk-3WD7QD5G.js.map → chunk-XLIZJMMJ.js.map} +0 -0
package/src/cycle-guard.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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: (
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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>(
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|