@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.
- package/dist/advanced.cjs +10 -8
- package/dist/advanced.cjs.map +1 -1
- package/dist/advanced.d.cts +10 -16
- package/dist/advanced.d.ts +10 -16
- package/dist/advanced.js +5 -3
- package/dist/advanced.js.map +1 -1
- package/dist/{chunk-3U7EBKEU.cjs → chunk-ID3WBWNO.cjs} +559 -319
- package/dist/chunk-ID3WBWNO.cjs.map +1 -0
- package/dist/{chunk-3A4VW6AK.cjs → chunk-L4DIV3RC.cjs} +7 -7
- package/dist/{chunk-3A4VW6AK.cjs.map → chunk-L4DIV3RC.cjs.map} +1 -1
- package/dist/{chunk-URDFDRHR.cjs → chunk-M2TSXZ4C.cjs} +16 -16
- package/dist/{chunk-URDFDRHR.cjs.map → chunk-M2TSXZ4C.cjs.map} +1 -1
- package/dist/{chunk-YVS4WJ2W.js → chunk-SO6X7G5S.js} +558 -318
- package/dist/chunk-SO6X7G5S.js.map +1 -0
- package/dist/{chunk-LU2LD2WJ.js → chunk-TWELIZRY.js} +2 -2
- package/dist/{chunk-TEYUDPTA.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 +430 -246
- 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 +164 -103
- 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-3U7EBKEU.cjs.map +0 -1
- package/dist/chunk-YVS4WJ2W.js.map +0 -1
- package/dist/scope-DvgMquEy.d.ts +0 -55
- package/dist/scope-xmdo6lVU.d.cts +0 -55
- /package/dist/{chunk-LU2LD2WJ.js.map → chunk-TWELIZRY.js.map} +0 -0
- /package/dist/{chunk-TEYUDPTA.js.map → chunk-XLIZJMMJ.js.map} +0 -0
package/src/cycle-guard.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
|
|
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: (
|
|
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> {
|