@fictjs/runtime 0.0.2
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/README.md +17 -0
- package/dist/index.cjs +4224 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1572 -0
- package/dist/index.d.ts +1572 -0
- package/dist/index.dev.js +4240 -0
- package/dist/index.dev.js.map +1 -0
- package/dist/index.js +4133 -0
- package/dist/index.js.map +1 -0
- package/dist/jsx-dev-runtime.cjs +44 -0
- package/dist/jsx-dev-runtime.cjs.map +1 -0
- package/dist/jsx-dev-runtime.js +14 -0
- package/dist/jsx-dev-runtime.js.map +1 -0
- package/dist/jsx-runtime.cjs +44 -0
- package/dist/jsx-runtime.cjs.map +1 -0
- package/dist/jsx-runtime.js +14 -0
- package/dist/jsx-runtime.js.map +1 -0
- package/dist/slim.cjs +3384 -0
- package/dist/slim.cjs.map +1 -0
- package/dist/slim.d.cts +475 -0
- package/dist/slim.d.ts +475 -0
- package/dist/slim.js +3335 -0
- package/dist/slim.js.map +1 -0
- package/package.json +68 -0
- package/src/binding.ts +2127 -0
- package/src/constants.ts +456 -0
- package/src/cycle-guard.ts +134 -0
- package/src/devtools.ts +17 -0
- package/src/dom.ts +683 -0
- package/src/effect.ts +83 -0
- package/src/error-boundary.ts +118 -0
- package/src/hooks.ts +72 -0
- package/src/index.ts +184 -0
- package/src/jsx-dev-runtime.ts +2 -0
- package/src/jsx-runtime.ts +2 -0
- package/src/jsx.ts +786 -0
- package/src/lifecycle.ts +273 -0
- package/src/list-helpers.ts +619 -0
- package/src/memo.ts +14 -0
- package/src/node-ops.ts +185 -0
- package/src/props.ts +212 -0
- package/src/reconcile.ts +151 -0
- package/src/ref.ts +25 -0
- package/src/scheduler.ts +12 -0
- package/src/signal.ts +1278 -0
- package/src/slim.ts +68 -0
- package/src/store.ts +210 -0
- package/src/suspense.ts +187 -0
- package/src/transition.ts +128 -0
- package/src/types.ts +172 -0
- package/src/versioned-signal.ts +58 -0
package/src/binding.ts
ADDED
|
@@ -0,0 +1,2127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fict Reactive DOM Binding System
|
|
3
|
+
*
|
|
4
|
+
* This module provides the core mechanisms for reactive DOM updates.
|
|
5
|
+
* It bridges the gap between Fict's reactive system (signals, effects)
|
|
6
|
+
* and the DOM, enabling fine-grained updates without a virtual DOM.
|
|
7
|
+
*
|
|
8
|
+
* Design Philosophy:
|
|
9
|
+
* - Values wrapped in functions `() => T` are treated as reactive
|
|
10
|
+
* - Static values are applied once without tracking
|
|
11
|
+
* - The compiler transforms JSX expressions to use these primitives
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
$$EVENTS,
|
|
16
|
+
DelegatedEvents,
|
|
17
|
+
UnitlessStyles,
|
|
18
|
+
Properties,
|
|
19
|
+
ChildProperties,
|
|
20
|
+
getPropAlias,
|
|
21
|
+
SVGNamespace,
|
|
22
|
+
Aliases,
|
|
23
|
+
} from './constants'
|
|
24
|
+
import { createRenderEffect } from './effect'
|
|
25
|
+
import { Fragment } from './jsx'
|
|
26
|
+
import {
|
|
27
|
+
clearRoot,
|
|
28
|
+
createRootContext,
|
|
29
|
+
destroyRoot,
|
|
30
|
+
flushOnMount,
|
|
31
|
+
getCurrentRoot,
|
|
32
|
+
handleError,
|
|
33
|
+
handleSuspend,
|
|
34
|
+
pushRoot,
|
|
35
|
+
popRoot,
|
|
36
|
+
registerRootCleanup,
|
|
37
|
+
type RootContext,
|
|
38
|
+
} from './lifecycle'
|
|
39
|
+
import { toNodeArray, removeNodes, insertNodesBefore } from './node-ops'
|
|
40
|
+
import { computed, createSignal, untrack, type Signal } from './signal'
|
|
41
|
+
import type { Cleanup, FictNode } from './types'
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Type Definitions
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/** A reactive value that can be either static or a getter function */
|
|
48
|
+
export type MaybeReactive<T> = T | (() => T)
|
|
49
|
+
|
|
50
|
+
/** Internal type for createElement function reference */
|
|
51
|
+
export type CreateElementFn = (node: FictNode) => Node
|
|
52
|
+
|
|
53
|
+
/** Handle returned by conditional/list bindings for cleanup */
|
|
54
|
+
export interface BindingHandle {
|
|
55
|
+
/** Marker node(s) used for positioning */
|
|
56
|
+
marker: Comment | DocumentFragment
|
|
57
|
+
/** Flush pending content - call after markers are inserted into DOM */
|
|
58
|
+
flush?: () => void
|
|
59
|
+
/** Dispose function to clean up the binding */
|
|
60
|
+
dispose: Cleanup
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Managed child node with its dispose function */
|
|
64
|
+
interface ManagedBlock<T = unknown> {
|
|
65
|
+
nodes: Node[]
|
|
66
|
+
root: RootContext
|
|
67
|
+
value: Signal<T>
|
|
68
|
+
index: Signal<number>
|
|
69
|
+
version: Signal<number>
|
|
70
|
+
start: Comment
|
|
71
|
+
end: Comment
|
|
72
|
+
valueProxy: T
|
|
73
|
+
renderCurrent: () => FictNode
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Utility Functions
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a value is reactive (a getter function)
|
|
82
|
+
* Note: Event handlers (functions that take arguments) are NOT reactive values
|
|
83
|
+
*/
|
|
84
|
+
export function isReactive(value: unknown): value is () => unknown {
|
|
85
|
+
return typeof value === 'function' && value.length === 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Unwrap a potentially reactive value to get the actual value
|
|
90
|
+
*/
|
|
91
|
+
export function unwrap<T>(value: MaybeReactive<T>): T {
|
|
92
|
+
return isReactive(value) ? (value as () => T)() : value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const PRIMITIVE_PROXY = Symbol('fict:primitive-proxy')
|
|
96
|
+
const PRIMITIVE_PROXY_RAW_VALUE = Symbol('fict:primitive-proxy:raw-value')
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Unwrap a primitive proxy value to get the raw primitive value.
|
|
100
|
+
* This is primarily useful for advanced scenarios where you need the actual
|
|
101
|
+
* primitive type (e.g., for typeof checks or strict equality comparisons).
|
|
102
|
+
*
|
|
103
|
+
* @param value - A potentially proxied primitive value
|
|
104
|
+
* @returns The raw primitive value
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* createList(
|
|
109
|
+
* () => [1, 2, 3],
|
|
110
|
+
* (item) => {
|
|
111
|
+
* const raw = unwrapPrimitive(item)
|
|
112
|
+
* typeof raw === 'number' // true
|
|
113
|
+
* raw === 1 // true (for first item)
|
|
114
|
+
* },
|
|
115
|
+
* item => item
|
|
116
|
+
* )
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export function unwrapPrimitive<T>(value: T): T {
|
|
120
|
+
if (value && typeof value === 'object' && PRIMITIVE_PROXY in value) {
|
|
121
|
+
// Use the internal raw value getter
|
|
122
|
+
const getRawValue = (value as Record<PropertyKey, unknown>)[PRIMITIVE_PROXY_RAW_VALUE]
|
|
123
|
+
if (typeof getRawValue === 'function') {
|
|
124
|
+
return (getRawValue as () => T)()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createValueProxy<T>(read: () => T): T {
|
|
131
|
+
const getPrimitivePrototype = (value: unknown): Record<PropertyKey, unknown> | undefined => {
|
|
132
|
+
switch (typeof value) {
|
|
133
|
+
case 'string':
|
|
134
|
+
return String.prototype as unknown as Record<PropertyKey, unknown>
|
|
135
|
+
case 'number':
|
|
136
|
+
return Number.prototype as unknown as Record<PropertyKey, unknown>
|
|
137
|
+
case 'boolean':
|
|
138
|
+
return Boolean.prototype as unknown as Record<PropertyKey, unknown>
|
|
139
|
+
case 'bigint':
|
|
140
|
+
return BigInt.prototype as unknown as Record<PropertyKey, unknown>
|
|
141
|
+
case 'symbol':
|
|
142
|
+
return Symbol.prototype as unknown as Record<PropertyKey, unknown>
|
|
143
|
+
default:
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const target: Record<PropertyKey, unknown> = {}
|
|
149
|
+
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
|
|
150
|
+
get(_target, prop, receiver) {
|
|
151
|
+
if (prop === PRIMITIVE_PROXY) {
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
if (prop === PRIMITIVE_PROXY_RAW_VALUE) {
|
|
155
|
+
return () => read()
|
|
156
|
+
}
|
|
157
|
+
if (prop === Symbol.toPrimitive) {
|
|
158
|
+
return (hint: 'string' | 'number' | 'default') => {
|
|
159
|
+
const value = read() as unknown
|
|
160
|
+
if (value != null && (typeof value === 'object' || typeof value === 'function')) {
|
|
161
|
+
const toPrimitive = (value as { [Symbol.toPrimitive]?: (hint: string) => unknown })[
|
|
162
|
+
Symbol.toPrimitive
|
|
163
|
+
]
|
|
164
|
+
if (typeof toPrimitive === 'function') {
|
|
165
|
+
return toPrimitive.call(value, hint)
|
|
166
|
+
}
|
|
167
|
+
if (hint === 'string') return value.toString?.() ?? '[object Object]'
|
|
168
|
+
if (hint === 'number') return value.valueOf?.() ?? value
|
|
169
|
+
return value.valueOf?.() ?? value
|
|
170
|
+
}
|
|
171
|
+
return value
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (prop === 'valueOf') {
|
|
175
|
+
return () => {
|
|
176
|
+
const value = read() as unknown
|
|
177
|
+
if (value != null && (typeof value === 'object' || typeof value === 'function')) {
|
|
178
|
+
return typeof (value as { valueOf?: () => unknown }).valueOf === 'function'
|
|
179
|
+
? (value as { valueOf: () => unknown }).valueOf()
|
|
180
|
+
: value
|
|
181
|
+
}
|
|
182
|
+
return value
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (prop === 'toString') {
|
|
186
|
+
return () => String(read())
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const value = read() as unknown
|
|
190
|
+
if (value != null && (typeof value === 'object' || typeof value === 'function')) {
|
|
191
|
+
return Reflect.get(value as object, prop, receiver === _target ? value : receiver)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const proto = getPrimitivePrototype(value)
|
|
195
|
+
if (proto && prop in proto) {
|
|
196
|
+
const descriptor = Reflect.get(proto, prop, value)
|
|
197
|
+
return typeof descriptor === 'function' ? descriptor.bind(value) : descriptor
|
|
198
|
+
}
|
|
199
|
+
return undefined
|
|
200
|
+
},
|
|
201
|
+
set(_target, prop, newValue, receiver) {
|
|
202
|
+
const value = read() as unknown
|
|
203
|
+
if (value != null && (typeof value === 'object' || typeof value === 'function')) {
|
|
204
|
+
return Reflect.set(value as object, prop, newValue, receiver === _target ? value : receiver)
|
|
205
|
+
}
|
|
206
|
+
return false
|
|
207
|
+
},
|
|
208
|
+
has(_target, prop) {
|
|
209
|
+
if (prop === PRIMITIVE_PROXY || prop === PRIMITIVE_PROXY_RAW_VALUE) {
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
const value = read() as unknown
|
|
213
|
+
if (value != null && (typeof value === 'object' || typeof value === 'function')) {
|
|
214
|
+
return prop in (value as object)
|
|
215
|
+
}
|
|
216
|
+
const proto = getPrimitivePrototype(value)
|
|
217
|
+
return proto ? prop in proto : false
|
|
218
|
+
},
|
|
219
|
+
ownKeys() {
|
|
220
|
+
const value = read() as unknown
|
|
221
|
+
if (value != null && (typeof value === 'object' || typeof value === 'function')) {
|
|
222
|
+
return Reflect.ownKeys(value as object)
|
|
223
|
+
}
|
|
224
|
+
const proto = getPrimitivePrototype(value)
|
|
225
|
+
return proto ? Reflect.ownKeys(proto) : []
|
|
226
|
+
},
|
|
227
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
228
|
+
const value = read() as unknown
|
|
229
|
+
if (value != null && (typeof value === 'object' || typeof value === 'function')) {
|
|
230
|
+
return Object.getOwnPropertyDescriptor(value as object, prop)
|
|
231
|
+
}
|
|
232
|
+
const proto = getPrimitivePrototype(value)
|
|
233
|
+
return proto ? Object.getOwnPropertyDescriptor(proto, prop) || undefined : undefined
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return new Proxy(target, handler) as T
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// Text Binding
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create a text node that reactively updates when the value changes.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```ts
|
|
249
|
+
* // Static text
|
|
250
|
+
* createTextBinding("Hello")
|
|
251
|
+
*
|
|
252
|
+
* // Reactive text (compiler output)
|
|
253
|
+
* createTextBinding(() => $count())
|
|
254
|
+
* ```
|
|
255
|
+
*/
|
|
256
|
+
export function createTextBinding(value: MaybeReactive<unknown>): Text {
|
|
257
|
+
const text = document.createTextNode('')
|
|
258
|
+
|
|
259
|
+
if (isReactive(value)) {
|
|
260
|
+
// Reactive: create effect to update text when value changes
|
|
261
|
+
createRenderEffect(() => {
|
|
262
|
+
const v = (value as () => unknown)()
|
|
263
|
+
const fmt = formatTextValue(v)
|
|
264
|
+
if (text.data !== fmt) {
|
|
265
|
+
text.data = fmt
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
} else {
|
|
269
|
+
// Static: set once
|
|
270
|
+
text.data = formatTextValue(value)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return text
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Bind a reactive value to an existing text node.
|
|
278
|
+
* This is a convenience function for binding to existing DOM nodes.
|
|
279
|
+
*/
|
|
280
|
+
export function bindText(textNode: Text, getValue: () => unknown): Cleanup {
|
|
281
|
+
return createRenderEffect(() => {
|
|
282
|
+
const value = formatTextValue(getValue())
|
|
283
|
+
if (textNode.data !== value) {
|
|
284
|
+
textNode.data = value
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Format a value for text content
|
|
291
|
+
*/
|
|
292
|
+
function formatTextValue(value: unknown): string {
|
|
293
|
+
if (value == null || value === false) {
|
|
294
|
+
return ''
|
|
295
|
+
}
|
|
296
|
+
return String(value)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ============================================================================
|
|
300
|
+
// Attribute Binding
|
|
301
|
+
// ============================================================================
|
|
302
|
+
|
|
303
|
+
/** Attribute setter function type */
|
|
304
|
+
export type AttributeSetter = (el: HTMLElement, key: string, value: unknown) => void
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Create a reactive attribute binding on an element.
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```ts
|
|
311
|
+
* // Static attribute
|
|
312
|
+
* createAttributeBinding(button, 'disabled', false, setAttribute)
|
|
313
|
+
*
|
|
314
|
+
* // Reactive attribute (compiler output)
|
|
315
|
+
* createAttributeBinding(button, 'disabled', () => !$isValid(), setAttribute)
|
|
316
|
+
* ```
|
|
317
|
+
*/
|
|
318
|
+
export function createAttributeBinding(
|
|
319
|
+
el: HTMLElement,
|
|
320
|
+
key: string,
|
|
321
|
+
value: MaybeReactive<unknown>,
|
|
322
|
+
setter: AttributeSetter,
|
|
323
|
+
): void {
|
|
324
|
+
if (isReactive(value)) {
|
|
325
|
+
// Reactive: create effect to update attribute when value changes
|
|
326
|
+
createRenderEffect(() => {
|
|
327
|
+
setter(el, key, (value as () => unknown)())
|
|
328
|
+
})
|
|
329
|
+
} else {
|
|
330
|
+
// Static: set once
|
|
331
|
+
setter(el, key, value)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Bind a reactive value to an element's attribute.
|
|
337
|
+
*/
|
|
338
|
+
export function bindAttribute(el: HTMLElement, key: string, getValue: () => unknown): Cleanup {
|
|
339
|
+
let prevValue: unknown = undefined
|
|
340
|
+
return createRenderEffect(() => {
|
|
341
|
+
const value = getValue()
|
|
342
|
+
if (value === prevValue) return
|
|
343
|
+
prevValue = value
|
|
344
|
+
|
|
345
|
+
if (value === undefined || value === null || value === false) {
|
|
346
|
+
el.removeAttribute(key)
|
|
347
|
+
} else if (value === true) {
|
|
348
|
+
el.setAttribute(key, '')
|
|
349
|
+
} else {
|
|
350
|
+
el.setAttribute(key, String(value))
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Bind a reactive value to an element's property.
|
|
357
|
+
*/
|
|
358
|
+
export function bindProperty(el: HTMLElement, key: string, getValue: () => unknown): Cleanup {
|
|
359
|
+
// Keep behavior aligned with the legacy createElement+applyProps path in `dom.ts`,
|
|
360
|
+
// where certain keys must behave like DOM properties and nullish clears should
|
|
361
|
+
// reset to sensible defaults (e.g. value -> '', checked -> false).
|
|
362
|
+
const PROPERTY_BINDING_KEYS = new Set([
|
|
363
|
+
'value',
|
|
364
|
+
'checked',
|
|
365
|
+
'selected',
|
|
366
|
+
'disabled',
|
|
367
|
+
'readOnly',
|
|
368
|
+
'multiple',
|
|
369
|
+
'muted',
|
|
370
|
+
])
|
|
371
|
+
|
|
372
|
+
let prevValue: unknown = undefined
|
|
373
|
+
return createRenderEffect(() => {
|
|
374
|
+
const next = getValue()
|
|
375
|
+
if (next === prevValue) return
|
|
376
|
+
prevValue = next
|
|
377
|
+
|
|
378
|
+
if (PROPERTY_BINDING_KEYS.has(key) && (next === undefined || next === null)) {
|
|
379
|
+
const fallback = key === 'checked' || key === 'selected' ? false : ''
|
|
380
|
+
;(el as unknown as Record<string, unknown>)[key] = fallback
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
;(el as unknown as Record<string, unknown>)[key] = next
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ============================================================================
|
|
388
|
+
// Style Binding
|
|
389
|
+
// ============================================================================
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Apply styles to an element, supporting reactive style objects/strings.
|
|
393
|
+
*/
|
|
394
|
+
export function createStyleBinding(
|
|
395
|
+
el: HTMLElement,
|
|
396
|
+
value: MaybeReactive<string | Record<string, string | number> | null | undefined>,
|
|
397
|
+
): void {
|
|
398
|
+
if (isReactive(value)) {
|
|
399
|
+
let prev: unknown
|
|
400
|
+
createRenderEffect(() => {
|
|
401
|
+
const next = (value as () => unknown)()
|
|
402
|
+
applyStyle(el, next, prev)
|
|
403
|
+
prev = next
|
|
404
|
+
})
|
|
405
|
+
} else {
|
|
406
|
+
applyStyle(el, value, undefined)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Bind a reactive style value to an existing element.
|
|
412
|
+
*/
|
|
413
|
+
export function bindStyle(
|
|
414
|
+
el: HTMLElement,
|
|
415
|
+
getValue: () => string | Record<string, string | number> | null | undefined,
|
|
416
|
+
): Cleanup {
|
|
417
|
+
let prev: unknown
|
|
418
|
+
return createRenderEffect(() => {
|
|
419
|
+
const next = getValue()
|
|
420
|
+
applyStyle(el, next, prev)
|
|
421
|
+
prev = next
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Apply a style value to an element
|
|
427
|
+
*/
|
|
428
|
+
function applyStyle(el: HTMLElement, value: unknown, prev: unknown): void {
|
|
429
|
+
if (typeof value === 'string') {
|
|
430
|
+
el.style.cssText = value
|
|
431
|
+
} else if (value && typeof value === 'object') {
|
|
432
|
+
const styles = value as Record<string, string | number>
|
|
433
|
+
|
|
434
|
+
// If we previously set styles via string, clear before applying object map
|
|
435
|
+
if (typeof prev === 'string') {
|
|
436
|
+
el.style.cssText = ''
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Remove styles that were present in prev but not in current
|
|
440
|
+
if (prev && typeof prev === 'object') {
|
|
441
|
+
const prevStyles = prev as Record<string, string | number>
|
|
442
|
+
for (const key of Object.keys(prevStyles)) {
|
|
443
|
+
if (!(key in styles)) {
|
|
444
|
+
const cssProperty = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
|
445
|
+
el.style.removeProperty(cssProperty)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const [prop, v] of Object.entries(styles)) {
|
|
451
|
+
if (v != null) {
|
|
452
|
+
// Handle camelCase to kebab-case conversion
|
|
453
|
+
const cssProperty = prop.replace(/([A-Z])/g, '-$1').toLowerCase()
|
|
454
|
+
const unitless = isUnitlessStyleProperty(prop) || isUnitlessStyleProperty(cssProperty)
|
|
455
|
+
const valueStr = typeof v === 'number' && !unitless ? `${v}px` : String(v)
|
|
456
|
+
el.style.setProperty(cssProperty, valueStr)
|
|
457
|
+
} else {
|
|
458
|
+
const cssProperty = prop.replace(/([A-Z])/g, '-$1').toLowerCase()
|
|
459
|
+
el.style.removeProperty(cssProperty) // Handle null/undefined values by removing
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
// If value is null/undefined, we might want to clear styles set by PREVIOUS binding?
|
|
464
|
+
// But blindly clearing cssText is dangerous.
|
|
465
|
+
// Ideally we remove keys from prev.
|
|
466
|
+
if (prev && typeof prev === 'object') {
|
|
467
|
+
const prevStyles = prev as Record<string, string | number>
|
|
468
|
+
for (const key of Object.keys(prevStyles)) {
|
|
469
|
+
const cssProperty = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
|
470
|
+
el.style.removeProperty(cssProperty)
|
|
471
|
+
}
|
|
472
|
+
} else if (typeof prev === 'string') {
|
|
473
|
+
el.style.cssText = ''
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isUnitlessStyleProperty(prop: string): boolean {
|
|
479
|
+
return UnitlessStyles.has(prop)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ============================================================================
|
|
483
|
+
// Class Binding
|
|
484
|
+
// ============================================================================
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Apply class to an element, supporting reactive class values.
|
|
488
|
+
*/
|
|
489
|
+
export function createClassBinding(
|
|
490
|
+
el: HTMLElement,
|
|
491
|
+
value: MaybeReactive<string | Record<string, boolean> | null | undefined>,
|
|
492
|
+
): void {
|
|
493
|
+
if (isReactive(value)) {
|
|
494
|
+
let prev: Record<string, boolean> = {}
|
|
495
|
+
createRenderEffect(() => {
|
|
496
|
+
const next = (value as () => unknown)()
|
|
497
|
+
prev = applyClass(el, next, prev)
|
|
498
|
+
})
|
|
499
|
+
} else {
|
|
500
|
+
applyClass(el, value, {})
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Bind a reactive class value to an existing element.
|
|
506
|
+
*/
|
|
507
|
+
export function bindClass(
|
|
508
|
+
el: HTMLElement,
|
|
509
|
+
getValue: () => string | Record<string, boolean> | null | undefined,
|
|
510
|
+
): Cleanup {
|
|
511
|
+
let prev: Record<string, boolean> = {}
|
|
512
|
+
return createRenderEffect(() => {
|
|
513
|
+
const next = getValue()
|
|
514
|
+
prev = applyClass(el, next, prev)
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Toggle a class key (supports space-separated class names)
|
|
520
|
+
*/
|
|
521
|
+
function toggleClassKey(node: HTMLElement, key: string, value: boolean): void {
|
|
522
|
+
const classNames = key.trim().split(/\s+/)
|
|
523
|
+
for (let i = 0, len = classNames.length; i < len; i++) {
|
|
524
|
+
node.classList.toggle(classNames[i]!, value)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Apply a class value to an element using classList.toggle for efficient updates.
|
|
530
|
+
* Returns the new prev state for tracking.
|
|
531
|
+
*/
|
|
532
|
+
function applyClass(el: HTMLElement, value: unknown, prev: unknown): Record<string, boolean> {
|
|
533
|
+
const prevState = (prev && typeof prev === 'object' ? prev : {}) as Record<string, boolean>
|
|
534
|
+
|
|
535
|
+
// Handle string value - full replacement
|
|
536
|
+
if (typeof value === 'string') {
|
|
537
|
+
el.className = value
|
|
538
|
+
// Clear prev state since we're doing full replacement
|
|
539
|
+
return {}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Handle object value - incremental updates
|
|
543
|
+
if (value && typeof value === 'object') {
|
|
544
|
+
const classes = value as Record<string, boolean>
|
|
545
|
+
const classKeys = Object.keys(classes)
|
|
546
|
+
const prevKeys = Object.keys(prevState)
|
|
547
|
+
|
|
548
|
+
// Remove classes that were true but are now false or missing
|
|
549
|
+
for (let i = 0, len = prevKeys.length; i < len; i++) {
|
|
550
|
+
const key = prevKeys[i]!
|
|
551
|
+
if (!key || key === 'undefined' || classes[key]) continue
|
|
552
|
+
toggleClassKey(el, key, false)
|
|
553
|
+
delete prevState[key]
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Add classes that are now true
|
|
557
|
+
for (let i = 0, len = classKeys.length; i < len; i++) {
|
|
558
|
+
const key = classKeys[i]!
|
|
559
|
+
const classValue = !!classes[key]
|
|
560
|
+
if (!key || key === 'undefined' || prevState[key] === classValue || !classValue) continue
|
|
561
|
+
toggleClassKey(el, key, true)
|
|
562
|
+
prevState[key] = classValue
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return prevState
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Handle null/undefined - clear all tracked classes
|
|
569
|
+
if (!value) {
|
|
570
|
+
for (const key of Object.keys(prevState)) {
|
|
571
|
+
if (key && key !== 'undefined') {
|
|
572
|
+
toggleClassKey(el, key, false)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return {}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return prevState
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Exported classList function for direct use (compatible with dom-expressions)
|
|
583
|
+
*/
|
|
584
|
+
export function classList(
|
|
585
|
+
node: HTMLElement,
|
|
586
|
+
value: Record<string, boolean> | null | undefined,
|
|
587
|
+
prev: Record<string, boolean> = {},
|
|
588
|
+
): Record<string, boolean> {
|
|
589
|
+
return applyClass(node, value, prev)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ============================================================================
|
|
593
|
+
// Child/Insert Binding (Dynamic Children)
|
|
594
|
+
// ============================================================================
|
|
595
|
+
|
|
596
|
+
/** Managed child node with its dispose function */
|
|
597
|
+
interface ManagedBlock<T = unknown> {
|
|
598
|
+
nodes: Node[]
|
|
599
|
+
root: RootContext
|
|
600
|
+
value: Signal<T>
|
|
601
|
+
index: Signal<number>
|
|
602
|
+
version: Signal<number>
|
|
603
|
+
start: Comment
|
|
604
|
+
end: Comment
|
|
605
|
+
valueProxy: T
|
|
606
|
+
renderCurrent: () => FictNode
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Insert reactive content into a parent element.
|
|
611
|
+
* This is a simpler API than createChildBinding for basic cases.
|
|
612
|
+
*
|
|
613
|
+
* @param parent - The parent element to insert into
|
|
614
|
+
* @param getValue - Function that returns the value to render
|
|
615
|
+
* @param markerOrCreateElement - Optional marker node to insert before, or createElementFn
|
|
616
|
+
* @param createElementFn - Optional function to create DOM elements (when marker is provided)
|
|
617
|
+
*/
|
|
618
|
+
export function insert(
|
|
619
|
+
parent: HTMLElement | DocumentFragment,
|
|
620
|
+
getValue: () => FictNode,
|
|
621
|
+
markerOrCreateElement?: Node | CreateElementFn,
|
|
622
|
+
createElementFn?: CreateElementFn,
|
|
623
|
+
): Cleanup {
|
|
624
|
+
let marker: Node
|
|
625
|
+
let ownsMarker = false
|
|
626
|
+
let createFn: CreateElementFn | undefined = createElementFn
|
|
627
|
+
|
|
628
|
+
if (markerOrCreateElement instanceof Node) {
|
|
629
|
+
marker = markerOrCreateElement
|
|
630
|
+
createFn = createElementFn
|
|
631
|
+
} else {
|
|
632
|
+
marker = document.createComment('fict:insert')
|
|
633
|
+
parent.appendChild(marker)
|
|
634
|
+
createFn = markerOrCreateElement as CreateElementFn | undefined
|
|
635
|
+
ownsMarker = true
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let currentNodes: Node[] = []
|
|
639
|
+
let currentText: Text | null = null
|
|
640
|
+
let currentRoot: RootContext | null = null
|
|
641
|
+
|
|
642
|
+
const clearCurrentNodes = () => {
|
|
643
|
+
if (currentNodes.length > 0) {
|
|
644
|
+
removeNodes(currentNodes)
|
|
645
|
+
currentNodes = []
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const setTextNode = (textValue: string, shouldInsert: boolean, parentNode: ParentNode & Node) => {
|
|
650
|
+
if (!currentText) {
|
|
651
|
+
currentText = document.createTextNode(textValue)
|
|
652
|
+
} else if (currentText.data !== textValue) {
|
|
653
|
+
currentText.data = textValue
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!shouldInsert) {
|
|
657
|
+
clearCurrentNodes()
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (currentNodes.length === 1 && currentNodes[0] === currentText) {
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
clearCurrentNodes()
|
|
666
|
+
insertNodesBefore(parentNode, [currentText], marker)
|
|
667
|
+
currentNodes = [currentText]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const dispose = createRenderEffect(() => {
|
|
671
|
+
const value = getValue()
|
|
672
|
+
const parentNode = marker.parentNode as (ParentNode & Node) | null
|
|
673
|
+
const isPrimitive =
|
|
674
|
+
value == null ||
|
|
675
|
+
value === false ||
|
|
676
|
+
typeof value === 'string' ||
|
|
677
|
+
typeof value === 'number' ||
|
|
678
|
+
typeof value === 'boolean'
|
|
679
|
+
|
|
680
|
+
if (isPrimitive) {
|
|
681
|
+
if (currentRoot) {
|
|
682
|
+
destroyRoot(currentRoot)
|
|
683
|
+
currentRoot = null
|
|
684
|
+
}
|
|
685
|
+
if (!parentNode) {
|
|
686
|
+
clearCurrentNodes()
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
const textValue = value == null || value === false ? '' : String(value)
|
|
690
|
+
const shouldInsert = value != null && value !== false
|
|
691
|
+
setTextNode(textValue, shouldInsert, parentNode)
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (currentRoot) {
|
|
696
|
+
destroyRoot(currentRoot)
|
|
697
|
+
currentRoot = null
|
|
698
|
+
}
|
|
699
|
+
clearCurrentNodes()
|
|
700
|
+
|
|
701
|
+
const root = createRootContext()
|
|
702
|
+
const prev = pushRoot(root)
|
|
703
|
+
let nodes: Node[] = []
|
|
704
|
+
try {
|
|
705
|
+
let newNode: Node | Node[]
|
|
706
|
+
|
|
707
|
+
if (value instanceof Node) {
|
|
708
|
+
newNode = value
|
|
709
|
+
} else if (Array.isArray(value)) {
|
|
710
|
+
if (value.every(v => v instanceof Node)) {
|
|
711
|
+
newNode = value as Node[]
|
|
712
|
+
} else {
|
|
713
|
+
newNode = createFn ? createFn(value) : document.createTextNode(String(value))
|
|
714
|
+
}
|
|
715
|
+
} else {
|
|
716
|
+
newNode = createFn ? createFn(value) : document.createTextNode(String(value))
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
nodes = toNodeArray(newNode)
|
|
720
|
+
if (parentNode) {
|
|
721
|
+
insertNodesBefore(parentNode, nodes, marker)
|
|
722
|
+
}
|
|
723
|
+
} finally {
|
|
724
|
+
popRoot(prev)
|
|
725
|
+
flushOnMount(root)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
currentRoot = root
|
|
729
|
+
currentNodes = nodes
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
return () => {
|
|
733
|
+
dispose()
|
|
734
|
+
if (currentRoot) {
|
|
735
|
+
destroyRoot(currentRoot)
|
|
736
|
+
currentRoot = null
|
|
737
|
+
}
|
|
738
|
+
clearCurrentNodes()
|
|
739
|
+
if (ownsMarker) {
|
|
740
|
+
marker.parentNode?.removeChild(marker)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Create a reactive child binding that updates when the child value changes.
|
|
747
|
+
* This is used for dynamic expressions like `{show && <Modal />}` or `{items.map(...)}`.
|
|
748
|
+
*
|
|
749
|
+
* @example
|
|
750
|
+
* ```ts
|
|
751
|
+
* // Reactive child (compiler output for {count})
|
|
752
|
+
* createChildBinding(parent, () => $count(), createElement)
|
|
753
|
+
*
|
|
754
|
+
* // Reactive conditional (compiler output for {show && <Modal />})
|
|
755
|
+
* createChildBinding(parent, () => $show() && jsx(Modal, {}), createElement)
|
|
756
|
+
* ```
|
|
757
|
+
*/
|
|
758
|
+
export function createChildBinding(
|
|
759
|
+
parent: HTMLElement | DocumentFragment,
|
|
760
|
+
getValue: () => FictNode,
|
|
761
|
+
createElementFn: CreateElementFn,
|
|
762
|
+
): BindingHandle {
|
|
763
|
+
const marker = document.createComment('fict:child')
|
|
764
|
+
parent.appendChild(marker)
|
|
765
|
+
|
|
766
|
+
const dispose = createRenderEffect(() => {
|
|
767
|
+
const root = createRootContext()
|
|
768
|
+
const prev = pushRoot(root)
|
|
769
|
+
let nodes: Node[] = []
|
|
770
|
+
let handledError = false
|
|
771
|
+
try {
|
|
772
|
+
const value = getValue()
|
|
773
|
+
|
|
774
|
+
// Skip if value is null/undefined/false
|
|
775
|
+
if (value == null || value === false) {
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const output = createElementFn(value)
|
|
780
|
+
nodes = toNodeArray(output)
|
|
781
|
+
const parentNode = marker.parentNode as (ParentNode & Node) | null
|
|
782
|
+
if (parentNode) {
|
|
783
|
+
insertNodesBefore(parentNode, nodes, marker)
|
|
784
|
+
}
|
|
785
|
+
return () => {
|
|
786
|
+
destroyRoot(root)
|
|
787
|
+
removeNodes(nodes)
|
|
788
|
+
}
|
|
789
|
+
} catch (err) {
|
|
790
|
+
if (handleSuspend(err as any, root)) {
|
|
791
|
+
handledError = true
|
|
792
|
+
destroyRoot(root)
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
if (handleError(err, { source: 'renderChild' }, root)) {
|
|
796
|
+
handledError = true
|
|
797
|
+
destroyRoot(root)
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
throw err
|
|
801
|
+
} finally {
|
|
802
|
+
popRoot(prev)
|
|
803
|
+
if (!handledError) {
|
|
804
|
+
flushOnMount(root)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
return {
|
|
810
|
+
marker,
|
|
811
|
+
dispose: () => {
|
|
812
|
+
dispose()
|
|
813
|
+
marker.parentNode?.removeChild(marker)
|
|
814
|
+
},
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ============================================================================
|
|
819
|
+
// Event Delegation System
|
|
820
|
+
// ============================================================================
|
|
821
|
+
|
|
822
|
+
// Extend HTMLElement/Document type to support event delegation
|
|
823
|
+
declare global {
|
|
824
|
+
interface HTMLElement {
|
|
825
|
+
_$host?: HTMLElement
|
|
826
|
+
[key: `$$${string}`]: EventListener | [EventListener, unknown] | undefined
|
|
827
|
+
[key: `$$${string}Data`]: unknown
|
|
828
|
+
}
|
|
829
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
830
|
+
interface Document extends Record<string, unknown> {}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Initialize event delegation for a set of event names.
|
|
835
|
+
* Events will be handled at the document level and dispatched to the appropriate handlers.
|
|
836
|
+
*
|
|
837
|
+
* @param eventNames - Array of event names to delegate
|
|
838
|
+
* @param doc - The document to attach handlers to (default: window.document)
|
|
839
|
+
*
|
|
840
|
+
* @example
|
|
841
|
+
* ```ts
|
|
842
|
+
* // Called automatically by the compiler for delegated events
|
|
843
|
+
* delegateEvents(['click', 'input', 'keydown'])
|
|
844
|
+
* ```
|
|
845
|
+
*/
|
|
846
|
+
export function delegateEvents(eventNames: string[], doc: Document = window.document): void {
|
|
847
|
+
const e = (doc[$$EVENTS] as Set<string>) || (doc[$$EVENTS] = new Set<string>())
|
|
848
|
+
for (let i = 0, l = eventNames.length; i < l; i++) {
|
|
849
|
+
const name = eventNames[i]!
|
|
850
|
+
if (!e.has(name)) {
|
|
851
|
+
e.add(name)
|
|
852
|
+
doc.addEventListener(name, globalEventHandler)
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Clear all delegated event handlers from a document.
|
|
859
|
+
*
|
|
860
|
+
* @param doc - The document to clear handlers from (default: window.document)
|
|
861
|
+
*/
|
|
862
|
+
export function clearDelegatedEvents(doc: Document = window.document): void {
|
|
863
|
+
const e = doc[$$EVENTS] as Set<string> | undefined
|
|
864
|
+
if (e) {
|
|
865
|
+
for (const name of e.keys()) {
|
|
866
|
+
doc.removeEventListener(name, globalEventHandler)
|
|
867
|
+
}
|
|
868
|
+
delete doc[$$EVENTS]
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Global event handler for delegated events.
|
|
874
|
+
* Walks up the DOM tree to find and call handlers stored as $$eventName properties.
|
|
875
|
+
*/
|
|
876
|
+
function globalEventHandler(e: Event): void {
|
|
877
|
+
let node = e.target as HTMLElement | null
|
|
878
|
+
const key = `$$${e.type}` as const
|
|
879
|
+
const dataKey = `${key}Data` as `$$${string}Data`
|
|
880
|
+
const oriTarget = e.target
|
|
881
|
+
const oriCurrentTarget = e.currentTarget
|
|
882
|
+
|
|
883
|
+
// Retarget helper for shadow DOM and portals
|
|
884
|
+
const retarget = (value: EventTarget) =>
|
|
885
|
+
Object.defineProperty(e, 'target', {
|
|
886
|
+
configurable: true,
|
|
887
|
+
value,
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
// Handler for each node in the bubble path
|
|
891
|
+
const handleNode = (): boolean => {
|
|
892
|
+
if (!node) return false
|
|
893
|
+
const handler = node[key]
|
|
894
|
+
if (handler && !(node as HTMLButtonElement).disabled) {
|
|
895
|
+
const resolveData = (value: unknown): unknown => {
|
|
896
|
+
if (typeof value === 'function') {
|
|
897
|
+
try {
|
|
898
|
+
const fn = value as (event?: Event) => unknown
|
|
899
|
+
return fn.length > 0 ? fn(e) : fn()
|
|
900
|
+
} catch {
|
|
901
|
+
return (value as () => unknown)()
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return value
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const rawData = (node as any)[dataKey] as unknown
|
|
908
|
+
const hasData = rawData !== undefined
|
|
909
|
+
const resolvedNodeData = hasData ? resolveData(rawData) : undefined
|
|
910
|
+
if (typeof handler === 'function') {
|
|
911
|
+
// Handler with optional data: handler(data, event?) or handler(event)
|
|
912
|
+
if (hasData) {
|
|
913
|
+
;(handler as (data: unknown, e: Event) => void).call(node, resolvedNodeData, e)
|
|
914
|
+
} else {
|
|
915
|
+
;(handler as EventListener).call(node, e)
|
|
916
|
+
}
|
|
917
|
+
} else if (Array.isArray(handler)) {
|
|
918
|
+
const tupleData = resolveData(handler[1])
|
|
919
|
+
;(handler[0] as (data: unknown, e: Event) => void).call(node, tupleData, e)
|
|
920
|
+
}
|
|
921
|
+
if (e.cancelBubble) return false
|
|
922
|
+
}
|
|
923
|
+
// Handle shadow DOM host retargeting
|
|
924
|
+
const shadowHost = (node as unknown as ShadowRoot).host
|
|
925
|
+
if (
|
|
926
|
+
shadowHost &&
|
|
927
|
+
typeof shadowHost !== 'string' &&
|
|
928
|
+
!(shadowHost as HTMLElement)._$host &&
|
|
929
|
+
node.contains(e.target as Node)
|
|
930
|
+
) {
|
|
931
|
+
retarget(shadowHost as EventTarget)
|
|
932
|
+
}
|
|
933
|
+
return true
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Walk up tree helper
|
|
937
|
+
const walkUpTree = (): void => {
|
|
938
|
+
while (handleNode() && node) {
|
|
939
|
+
node = (node._$host ||
|
|
940
|
+
node.parentNode ||
|
|
941
|
+
(node as unknown as ShadowRoot).host) as HTMLElement | null
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Simulate currentTarget
|
|
946
|
+
Object.defineProperty(e, 'currentTarget', {
|
|
947
|
+
configurable: true,
|
|
948
|
+
get() {
|
|
949
|
+
return node || document
|
|
950
|
+
},
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
// Use composedPath for shadow DOM support
|
|
954
|
+
if (e.composedPath) {
|
|
955
|
+
const path = e.composedPath()
|
|
956
|
+
retarget(path[0] as EventTarget)
|
|
957
|
+
for (let i = 0; i < path.length - 2; i++) {
|
|
958
|
+
node = path[i] as HTMLElement
|
|
959
|
+
if (!handleNode()) break
|
|
960
|
+
// Handle portal event bubbling
|
|
961
|
+
if (node._$host) {
|
|
962
|
+
node = node._$host
|
|
963
|
+
walkUpTree()
|
|
964
|
+
break
|
|
965
|
+
}
|
|
966
|
+
// Don't bubble above root of event delegation
|
|
967
|
+
if (node.parentNode === oriCurrentTarget) {
|
|
968
|
+
break
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
} else {
|
|
972
|
+
// Fallback for browsers without composedPath
|
|
973
|
+
walkUpTree()
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Reset target
|
|
977
|
+
retarget(oriTarget as EventTarget)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Add an event listener to an element.
|
|
982
|
+
* If the event is in DelegatedEvents, it uses event delegation for better performance.
|
|
983
|
+
*
|
|
984
|
+
* @param node - The element to add the listener to
|
|
985
|
+
* @param name - The event name (lowercase)
|
|
986
|
+
* @param handler - The event handler or [handler, data] tuple
|
|
987
|
+
* @param delegate - Whether to use delegation (auto-detected based on event name)
|
|
988
|
+
*/
|
|
989
|
+
export function addEventListener(
|
|
990
|
+
node: HTMLElement,
|
|
991
|
+
name: string,
|
|
992
|
+
handler: EventListener | [EventListener, unknown] | null | undefined,
|
|
993
|
+
delegate?: boolean,
|
|
994
|
+
): void {
|
|
995
|
+
if (handler == null) return
|
|
996
|
+
|
|
997
|
+
if (delegate) {
|
|
998
|
+
// Event delegation: store handler on element
|
|
999
|
+
if (Array.isArray(handler)) {
|
|
1000
|
+
;(node as unknown as Record<string, unknown>)[`$$${name}`] = handler[0]
|
|
1001
|
+
;(node as unknown as Record<string, unknown>)[`$$${name}Data`] = handler[1]
|
|
1002
|
+
} else {
|
|
1003
|
+
;(node as unknown as Record<string, unknown>)[`$$${name}`] = handler
|
|
1004
|
+
}
|
|
1005
|
+
} else if (Array.isArray(handler)) {
|
|
1006
|
+
// Non-delegated with data binding
|
|
1007
|
+
const handlerFn = handler[0] as (data: unknown, e: Event) => void
|
|
1008
|
+
node.addEventListener(name, (e: Event) => handlerFn.call(node, handler[1], e))
|
|
1009
|
+
} else {
|
|
1010
|
+
// Regular event listener
|
|
1011
|
+
node.addEventListener(name, handler as EventListener)
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ============================================================================
|
|
1016
|
+
// Event Binding
|
|
1017
|
+
// ============================================================================
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Bind an event listener to an element.
|
|
1021
|
+
* Uses event delegation for better performance when applicable.
|
|
1022
|
+
*
|
|
1023
|
+
* @example
|
|
1024
|
+
* ```ts
|
|
1025
|
+
* // Static event
|
|
1026
|
+
* bindEvent(button, 'click', handleClick)
|
|
1027
|
+
*
|
|
1028
|
+
* // Reactive event (compiler output)
|
|
1029
|
+
* bindEvent(button, 'click', () => $handler())
|
|
1030
|
+
*
|
|
1031
|
+
* // With modifiers
|
|
1032
|
+
* bindEvent(button, 'click', handler, { capture: true, passive: true, once: true })
|
|
1033
|
+
* ```
|
|
1034
|
+
*/
|
|
1035
|
+
export function bindEvent(
|
|
1036
|
+
el: HTMLElement,
|
|
1037
|
+
eventName: string,
|
|
1038
|
+
handler: EventListenerOrEventListenerObject | null | undefined,
|
|
1039
|
+
options?: boolean | AddEventListenerOptions,
|
|
1040
|
+
): Cleanup {
|
|
1041
|
+
if (handler == null) return () => {}
|
|
1042
|
+
const rootRef = getCurrentRoot()
|
|
1043
|
+
|
|
1044
|
+
// Optimization: Global Event Delegation
|
|
1045
|
+
// If the event is delegatable and no special options (capture, passive) are used,
|
|
1046
|
+
// we attach the handler to the element property and rely on the global listener.
|
|
1047
|
+
if (DelegatedEvents.has(eventName) && !options) {
|
|
1048
|
+
const key = `$$${eventName}`
|
|
1049
|
+
|
|
1050
|
+
// Ensure global delegation is active for this event
|
|
1051
|
+
delegateEvents([eventName])
|
|
1052
|
+
|
|
1053
|
+
const createWrapped = (
|
|
1054
|
+
resolve: () => EventListenerOrEventListenerObject | null | undefined,
|
|
1055
|
+
) => {
|
|
1056
|
+
const wrapped = function (this: any, ...args: any[]) {
|
|
1057
|
+
try {
|
|
1058
|
+
const fn = resolve()
|
|
1059
|
+
if (typeof fn === 'function') {
|
|
1060
|
+
return (fn as EventListener).apply(this, args as [Event])
|
|
1061
|
+
} else if (fn && typeof fn.handleEvent === 'function') {
|
|
1062
|
+
return fn.handleEvent.apply(fn, args as [Event])
|
|
1063
|
+
}
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
handleError(err, { source: 'event', eventName }, rootRef)
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return wrapped
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const resolveHandler = isReactive(handler)
|
|
1072
|
+
? (handler as () => EventListenerOrEventListenerObject | null | undefined)
|
|
1073
|
+
: () => handler
|
|
1074
|
+
|
|
1075
|
+
// Cache a single wrapper that resolves the latest handler when invoked
|
|
1076
|
+
// @ts-expect-error - using dynamic property for delegation
|
|
1077
|
+
el[key] = createWrapped(resolveHandler)
|
|
1078
|
+
|
|
1079
|
+
// Cleanup: remove property (no effect needed for static or reactive)
|
|
1080
|
+
return () => {
|
|
1081
|
+
// @ts-expect-error - using dynamic property for delegation
|
|
1082
|
+
el[key] = undefined
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Fallback: Native addEventListener
|
|
1087
|
+
// Used for non-delegated events or when options are present
|
|
1088
|
+
const getHandler = isReactive(handler) ? (handler as () => unknown) : () => handler
|
|
1089
|
+
|
|
1090
|
+
// Create wrapped handler that resolves reactive handlers
|
|
1091
|
+
const wrapped: EventListener = event => {
|
|
1092
|
+
try {
|
|
1093
|
+
const resolved = getHandler()
|
|
1094
|
+
if (typeof resolved === 'function') {
|
|
1095
|
+
;(resolved as EventListener)(event)
|
|
1096
|
+
} else if (resolved && typeof (resolved as EventListenerObject).handleEvent === 'function') {
|
|
1097
|
+
;(resolved as EventListenerObject).handleEvent(event)
|
|
1098
|
+
}
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
if (handleError(err, { source: 'event', eventName }, rootRef)) {
|
|
1101
|
+
return
|
|
1102
|
+
}
|
|
1103
|
+
throw err
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
el.addEventListener(eventName, wrapped, options)
|
|
1108
|
+
const cleanup = () => el.removeEventListener(eventName, wrapped, options)
|
|
1109
|
+
registerRootCleanup(cleanup)
|
|
1110
|
+
return cleanup
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ============================================================================
|
|
1114
|
+
// Ref Binding
|
|
1115
|
+
// ============================================================================
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Bind a ref to an element.
|
|
1119
|
+
* Supports both callback refs and ref objects.
|
|
1120
|
+
*
|
|
1121
|
+
* @param el - The element to bind the ref to
|
|
1122
|
+
* @param ref - Either a callback function, a ref object, or a reactive getter
|
|
1123
|
+
* @returns Cleanup function
|
|
1124
|
+
*
|
|
1125
|
+
* @example
|
|
1126
|
+
* ```ts
|
|
1127
|
+
* // Callback ref
|
|
1128
|
+
* bindRef(el, (element) => { store.input = element })
|
|
1129
|
+
*
|
|
1130
|
+
* // Ref object
|
|
1131
|
+
* const inputRef = createRef()
|
|
1132
|
+
* bindRef(el, inputRef)
|
|
1133
|
+
*
|
|
1134
|
+
* // Reactive ref (compiler output)
|
|
1135
|
+
* bindRef(el, () => props.ref)
|
|
1136
|
+
* ```
|
|
1137
|
+
*/
|
|
1138
|
+
export function bindRef(el: HTMLElement, ref: unknown): Cleanup {
|
|
1139
|
+
if (ref == null) return () => {}
|
|
1140
|
+
|
|
1141
|
+
// Handle reactive refs (getters)
|
|
1142
|
+
const getRef = isReactive(ref) ? (ref as () => unknown) : () => ref
|
|
1143
|
+
|
|
1144
|
+
const applyRef = (refValue: unknown) => {
|
|
1145
|
+
if (refValue == null) return
|
|
1146
|
+
|
|
1147
|
+
if (typeof refValue === 'function') {
|
|
1148
|
+
// Callback ref: call with element
|
|
1149
|
+
;(refValue as (el: HTMLElement) => void)(el)
|
|
1150
|
+
} else if (typeof refValue === 'object' && 'current' in refValue) {
|
|
1151
|
+
// Ref object: set current property
|
|
1152
|
+
;(refValue as { current: HTMLElement | null }).current = el
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Apply ref initially
|
|
1157
|
+
const initialRef = getRef()
|
|
1158
|
+
applyRef(initialRef)
|
|
1159
|
+
|
|
1160
|
+
// For reactive refs, track changes
|
|
1161
|
+
if (isReactive(ref)) {
|
|
1162
|
+
const cleanup = createRenderEffect(() => {
|
|
1163
|
+
const currentRef = getRef()
|
|
1164
|
+
applyRef(currentRef)
|
|
1165
|
+
})
|
|
1166
|
+
registerRootCleanup(cleanup)
|
|
1167
|
+
|
|
1168
|
+
// On cleanup, null out the ref
|
|
1169
|
+
const nullifyCleanup = () => {
|
|
1170
|
+
const currentRef = getRef()
|
|
1171
|
+
if (currentRef && typeof currentRef === 'object' && 'current' in currentRef) {
|
|
1172
|
+
;(currentRef as { current: HTMLElement | null }).current = null
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
registerRootCleanup(nullifyCleanup)
|
|
1176
|
+
|
|
1177
|
+
return () => {
|
|
1178
|
+
cleanup()
|
|
1179
|
+
nullifyCleanup()
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// For static refs, register cleanup to null out on unmount
|
|
1184
|
+
const cleanup = () => {
|
|
1185
|
+
const refValue = getRef()
|
|
1186
|
+
if (refValue && typeof refValue === 'object' && 'current' in refValue) {
|
|
1187
|
+
;(refValue as { current: HTMLElement | null }).current = null
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
registerRootCleanup(cleanup)
|
|
1191
|
+
|
|
1192
|
+
return cleanup
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// ============================================================================
|
|
1196
|
+
// Spread Props
|
|
1197
|
+
// ============================================================================
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Apply spread props to an element with reactive updates.
|
|
1201
|
+
* This handles dynamic spread like `<div {...props}>`.
|
|
1202
|
+
*
|
|
1203
|
+
* @param node - The element to apply props to
|
|
1204
|
+
* @param props - The props object (may have reactive getters)
|
|
1205
|
+
* @param isSVG - Whether this is an SVG element
|
|
1206
|
+
* @param skipChildren - Whether to skip children handling
|
|
1207
|
+
* @returns The previous props for tracking changes
|
|
1208
|
+
*
|
|
1209
|
+
* @example
|
|
1210
|
+
* ```ts
|
|
1211
|
+
* // Compiler output for <div {...props} />
|
|
1212
|
+
* spread(el, props, false, false)
|
|
1213
|
+
* ```
|
|
1214
|
+
*/
|
|
1215
|
+
export function spread(
|
|
1216
|
+
node: HTMLElement,
|
|
1217
|
+
props: Record<string, unknown> = {},
|
|
1218
|
+
isSVG = false,
|
|
1219
|
+
skipChildren = false,
|
|
1220
|
+
): Record<string, unknown> {
|
|
1221
|
+
const prevProps: Record<string, unknown> = {}
|
|
1222
|
+
|
|
1223
|
+
// Handle children if not skipped
|
|
1224
|
+
if (!skipChildren && 'children' in props) {
|
|
1225
|
+
createRenderEffect(() => {
|
|
1226
|
+
prevProps.children = props.children
|
|
1227
|
+
})
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Handle ref
|
|
1231
|
+
createRenderEffect(() => {
|
|
1232
|
+
if (typeof props.ref === 'function') {
|
|
1233
|
+
;(props.ref as (el: HTMLElement) => void)(node)
|
|
1234
|
+
}
|
|
1235
|
+
})
|
|
1236
|
+
|
|
1237
|
+
// Handle all other props
|
|
1238
|
+
createRenderEffect(() => {
|
|
1239
|
+
assign(node, props, isSVG, true, prevProps, true)
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
return prevProps
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Assign props to a node, tracking previous values for efficient updates.
|
|
1247
|
+
* This is the core prop assignment logic used by spread.
|
|
1248
|
+
*
|
|
1249
|
+
* @param node - The element to assign props to
|
|
1250
|
+
* @param props - New props object
|
|
1251
|
+
* @param isSVG - Whether this is an SVG element
|
|
1252
|
+
* @param skipChildren - Whether to skip children handling
|
|
1253
|
+
* @param prevProps - Previous props for comparison
|
|
1254
|
+
* @param skipRef - Whether to skip ref handling
|
|
1255
|
+
*/
|
|
1256
|
+
export function assign(
|
|
1257
|
+
node: HTMLElement,
|
|
1258
|
+
props: Record<string, unknown>,
|
|
1259
|
+
isSVG = false,
|
|
1260
|
+
skipChildren = false,
|
|
1261
|
+
prevProps: Record<string, unknown> = {},
|
|
1262
|
+
skipRef = false,
|
|
1263
|
+
): void {
|
|
1264
|
+
props = props || {}
|
|
1265
|
+
|
|
1266
|
+
// Remove props that are no longer present
|
|
1267
|
+
for (const prop in prevProps) {
|
|
1268
|
+
if (!(prop in props)) {
|
|
1269
|
+
if (prop === 'children') continue
|
|
1270
|
+
prevProps[prop] = assignProp(node, prop, null, prevProps[prop], isSVG, skipRef, props)
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Set or update props
|
|
1275
|
+
for (const prop in props) {
|
|
1276
|
+
if (prop === 'children') {
|
|
1277
|
+
if (!skipChildren) {
|
|
1278
|
+
// Handle children insertion
|
|
1279
|
+
prevProps.children = props.children
|
|
1280
|
+
}
|
|
1281
|
+
continue
|
|
1282
|
+
}
|
|
1283
|
+
const value = props[prop]
|
|
1284
|
+
prevProps[prop] = assignProp(node, prop, value, prevProps[prop], isSVG, skipRef, props)
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Assign a single prop to a node.
|
|
1290
|
+
*/
|
|
1291
|
+
function assignProp(
|
|
1292
|
+
node: HTMLElement,
|
|
1293
|
+
prop: string,
|
|
1294
|
+
value: unknown,
|
|
1295
|
+
prev: unknown,
|
|
1296
|
+
isSVG: boolean,
|
|
1297
|
+
skipRef: boolean,
|
|
1298
|
+
props: Record<string, unknown>,
|
|
1299
|
+
): unknown {
|
|
1300
|
+
// Style handling
|
|
1301
|
+
if (prop === 'style') {
|
|
1302
|
+
applyStyle(node, value, prev)
|
|
1303
|
+
return value
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// classList handling
|
|
1307
|
+
if (prop === 'classList') {
|
|
1308
|
+
return applyClass(node, value, prev)
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Skip if value unchanged
|
|
1312
|
+
if (value === prev) return prev
|
|
1313
|
+
|
|
1314
|
+
// Ref handling
|
|
1315
|
+
if (prop === 'ref') {
|
|
1316
|
+
if (!skipRef && typeof value === 'function') {
|
|
1317
|
+
;(value as (el: HTMLElement) => void)(node)
|
|
1318
|
+
}
|
|
1319
|
+
return value
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Event handling: on:eventname
|
|
1323
|
+
if (prop.slice(0, 3) === 'on:') {
|
|
1324
|
+
const eventName = prop.slice(3)
|
|
1325
|
+
if (prev) node.removeEventListener(eventName, prev as EventListener)
|
|
1326
|
+
if (value) node.addEventListener(eventName, value as EventListener)
|
|
1327
|
+
return value
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Capture event handling: oncapture:eventname
|
|
1331
|
+
if (prop.slice(0, 10) === 'oncapture:') {
|
|
1332
|
+
const eventName = prop.slice(10)
|
|
1333
|
+
if (prev) node.removeEventListener(eventName, prev as EventListener, true)
|
|
1334
|
+
if (value) node.addEventListener(eventName, value as EventListener, true)
|
|
1335
|
+
return value
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Standard event handling: onClick, onInput, etc.
|
|
1339
|
+
if (prop.slice(0, 2) === 'on') {
|
|
1340
|
+
const eventName = prop.slice(2).toLowerCase()
|
|
1341
|
+
const shouldDelegate = DelegatedEvents.has(eventName)
|
|
1342
|
+
if (!shouldDelegate && prev) {
|
|
1343
|
+
const handler = Array.isArray(prev) ? prev[0] : prev
|
|
1344
|
+
node.removeEventListener(eventName, handler as EventListener)
|
|
1345
|
+
}
|
|
1346
|
+
if (shouldDelegate || value) {
|
|
1347
|
+
addEventListener(node, eventName, value as EventListener, shouldDelegate)
|
|
1348
|
+
if (shouldDelegate) delegateEvents([eventName])
|
|
1349
|
+
}
|
|
1350
|
+
return value
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Explicit attribute: attr:name
|
|
1354
|
+
if (prop.slice(0, 5) === 'attr:') {
|
|
1355
|
+
if (value == null) node.removeAttribute(prop.slice(5))
|
|
1356
|
+
else node.setAttribute(prop.slice(5), String(value))
|
|
1357
|
+
return value
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Explicit boolean attribute: bool:name
|
|
1361
|
+
if (prop.slice(0, 5) === 'bool:') {
|
|
1362
|
+
if (value) node.setAttribute(prop.slice(5), '')
|
|
1363
|
+
else node.removeAttribute(prop.slice(5))
|
|
1364
|
+
return value
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// Explicit property: prop:name
|
|
1368
|
+
if (prop.slice(0, 5) === 'prop:') {
|
|
1369
|
+
;(node as unknown as Record<string, unknown>)[prop.slice(5)] = value
|
|
1370
|
+
return value
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// Class/className handling
|
|
1374
|
+
if (prop === 'class' || prop === 'className') {
|
|
1375
|
+
if (value == null) node.removeAttribute('class')
|
|
1376
|
+
else node.className = String(value)
|
|
1377
|
+
return value
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Check if custom element
|
|
1381
|
+
const isCE = node.nodeName.includes('-') || 'is' in props
|
|
1382
|
+
|
|
1383
|
+
// Property handling (for non-SVG elements)
|
|
1384
|
+
if (!isSVG) {
|
|
1385
|
+
const propAlias = getPropAlias(prop, node.tagName)
|
|
1386
|
+
const isProperty = Properties.has(prop)
|
|
1387
|
+
const isChildProp = ChildProperties.has(prop)
|
|
1388
|
+
|
|
1389
|
+
if (propAlias || isProperty || isChildProp || isCE) {
|
|
1390
|
+
const propName = propAlias || prop
|
|
1391
|
+
if (isCE && !isProperty && !isChildProp) {
|
|
1392
|
+
;(node as unknown as Record<string, unknown>)[toPropertyName(propName)] = value
|
|
1393
|
+
} else {
|
|
1394
|
+
;(node as unknown as Record<string, unknown>)[propName] = value
|
|
1395
|
+
}
|
|
1396
|
+
return value
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// SVG namespace handling
|
|
1401
|
+
if (isSVG && prop.indexOf(':') > -1) {
|
|
1402
|
+
const [prefix, name] = prop.split(':')
|
|
1403
|
+
const ns = SVGNamespace[prefix!]
|
|
1404
|
+
if (ns) {
|
|
1405
|
+
if (value == null) node.removeAttributeNS(ns, name!)
|
|
1406
|
+
else node.setAttributeNS(ns, name!, String(value))
|
|
1407
|
+
return value
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Default: set as attribute
|
|
1412
|
+
const attrName = Aliases[prop] || prop
|
|
1413
|
+
if (value == null) node.removeAttribute(attrName)
|
|
1414
|
+
else node.setAttribute(attrName, String(value))
|
|
1415
|
+
return value
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Convert kebab-case to camelCase for property names
|
|
1420
|
+
*/
|
|
1421
|
+
function toPropertyName(name: string): string {
|
|
1422
|
+
return name.toLowerCase().replace(/-([a-z])/g, (_, w) => w.toUpperCase())
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ============================================================================
|
|
1426
|
+
// Conditional Rendering
|
|
1427
|
+
// ============================================================================
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Create a conditional rendering binding.
|
|
1431
|
+
* Efficiently renders one of two branches based on a condition.
|
|
1432
|
+
*
|
|
1433
|
+
* This is an optimized version for `{condition ? <A /> : <B />}` patterns
|
|
1434
|
+
* where both branches are known statically.
|
|
1435
|
+
*
|
|
1436
|
+
* @example
|
|
1437
|
+
* ```ts
|
|
1438
|
+
* // Compiler output for {show ? <A /> : <B />}
|
|
1439
|
+
* createConditional(
|
|
1440
|
+
* () => $show(),
|
|
1441
|
+
* () => jsx(A, {}),
|
|
1442
|
+
* () => jsx(B, {}),
|
|
1443
|
+
* createElement
|
|
1444
|
+
* )
|
|
1445
|
+
* ```
|
|
1446
|
+
*/
|
|
1447
|
+
export function createConditional(
|
|
1448
|
+
condition: () => boolean,
|
|
1449
|
+
renderTrue: () => FictNode,
|
|
1450
|
+
createElementFn: CreateElementFn,
|
|
1451
|
+
renderFalse?: () => FictNode,
|
|
1452
|
+
): BindingHandle {
|
|
1453
|
+
const startMarker = document.createComment('fict:cond:start')
|
|
1454
|
+
const endMarker = document.createComment('fict:cond:end')
|
|
1455
|
+
const fragment = document.createDocumentFragment()
|
|
1456
|
+
fragment.append(startMarker, endMarker)
|
|
1457
|
+
|
|
1458
|
+
let currentNodes: Node[] = []
|
|
1459
|
+
let currentRoot: RootContext | null = null
|
|
1460
|
+
let lastCondition: boolean | undefined = undefined
|
|
1461
|
+
let pendingRender = false
|
|
1462
|
+
|
|
1463
|
+
// Use computed to memoize condition value - this prevents the effect from
|
|
1464
|
+
// re-running when condition dependencies change but the boolean result stays same.
|
|
1465
|
+
// This is critical because re-running the effect would purge child effect deps
|
|
1466
|
+
// (like bindText) even if we early-return, breaking fine-grained reactivity.
|
|
1467
|
+
const conditionMemo = computed(condition)
|
|
1468
|
+
|
|
1469
|
+
const runConditional = () => {
|
|
1470
|
+
const cond = conditionMemo()
|
|
1471
|
+
const parent = startMarker.parentNode as (ParentNode & Node) | null
|
|
1472
|
+
if (!parent) {
|
|
1473
|
+
pendingRender = true
|
|
1474
|
+
return
|
|
1475
|
+
}
|
|
1476
|
+
pendingRender = false
|
|
1477
|
+
|
|
1478
|
+
if (lastCondition === cond && currentNodes.length > 0) {
|
|
1479
|
+
return
|
|
1480
|
+
}
|
|
1481
|
+
if (lastCondition === cond && lastCondition === false && renderFalse === undefined) {
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
lastCondition = cond
|
|
1485
|
+
|
|
1486
|
+
if (currentRoot) {
|
|
1487
|
+
destroyRoot(currentRoot)
|
|
1488
|
+
currentRoot = null
|
|
1489
|
+
}
|
|
1490
|
+
removeNodes(currentNodes)
|
|
1491
|
+
currentNodes = []
|
|
1492
|
+
|
|
1493
|
+
const render = cond ? renderTrue : renderFalse
|
|
1494
|
+
if (!render) {
|
|
1495
|
+
return
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const root = createRootContext()
|
|
1499
|
+
const prev = pushRoot(root)
|
|
1500
|
+
let handledError = false
|
|
1501
|
+
try {
|
|
1502
|
+
// Use untrack to prevent render function's signal accesses from being
|
|
1503
|
+
// tracked by createConditional's effect. This ensures that signals used
|
|
1504
|
+
// inside the render function (e.g., nested if conditions) don't cause
|
|
1505
|
+
// createConditional to re-run, which would purge child effect deps.
|
|
1506
|
+
const output = untrack(render)
|
|
1507
|
+
if (output == null || output === false) {
|
|
1508
|
+
return
|
|
1509
|
+
}
|
|
1510
|
+
const el = createElementFn(output)
|
|
1511
|
+
const nodes = toNodeArray(el)
|
|
1512
|
+
insertNodesBefore(parent, nodes, endMarker)
|
|
1513
|
+
currentNodes = nodes
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
if (handleSuspend(err as any, root)) {
|
|
1516
|
+
handledError = true
|
|
1517
|
+
destroyRoot(root)
|
|
1518
|
+
return
|
|
1519
|
+
}
|
|
1520
|
+
if (handleError(err, { source: 'renderChild' }, root)) {
|
|
1521
|
+
handledError = true
|
|
1522
|
+
destroyRoot(root)
|
|
1523
|
+
return
|
|
1524
|
+
}
|
|
1525
|
+
throw err
|
|
1526
|
+
} finally {
|
|
1527
|
+
popRoot(prev)
|
|
1528
|
+
if (!handledError) {
|
|
1529
|
+
flushOnMount(root)
|
|
1530
|
+
currentRoot = root
|
|
1531
|
+
} else {
|
|
1532
|
+
currentRoot = null
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const dispose = createRenderEffect(runConditional)
|
|
1538
|
+
|
|
1539
|
+
return {
|
|
1540
|
+
marker: fragment,
|
|
1541
|
+
flush: () => {
|
|
1542
|
+
if (pendingRender) {
|
|
1543
|
+
runConditional()
|
|
1544
|
+
}
|
|
1545
|
+
},
|
|
1546
|
+
dispose: () => {
|
|
1547
|
+
dispose()
|
|
1548
|
+
if (currentRoot) {
|
|
1549
|
+
destroyRoot(currentRoot)
|
|
1550
|
+
}
|
|
1551
|
+
removeNodes(currentNodes)
|
|
1552
|
+
currentNodes = []
|
|
1553
|
+
startMarker.parentNode?.removeChild(startMarker)
|
|
1554
|
+
endMarker.parentNode?.removeChild(endMarker)
|
|
1555
|
+
},
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// ============================================================================
|
|
1560
|
+
// List Rendering
|
|
1561
|
+
// ============================================================================
|
|
1562
|
+
|
|
1563
|
+
/** Key extractor function type */
|
|
1564
|
+
export type KeyFn<T> = (item: T, index: number) => string | number
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Create a reactive list rendering binding with optional keying.
|
|
1568
|
+
*/
|
|
1569
|
+
export function createList<T>(
|
|
1570
|
+
items: () => T[],
|
|
1571
|
+
renderItem: (item: T, index: number) => FictNode,
|
|
1572
|
+
createElementFn: CreateElementFn,
|
|
1573
|
+
getKey?: KeyFn<T>,
|
|
1574
|
+
): BindingHandle {
|
|
1575
|
+
const startMarker = document.createComment('fict:list:start')
|
|
1576
|
+
const endMarker = document.createComment('fict:list:end')
|
|
1577
|
+
const fragment = document.createDocumentFragment()
|
|
1578
|
+
fragment.append(startMarker, endMarker)
|
|
1579
|
+
|
|
1580
|
+
const nodeMap = new Map<string | number, ManagedBlock<T>>()
|
|
1581
|
+
let pendingItems: T[] | null = null
|
|
1582
|
+
|
|
1583
|
+
const runListUpdate = () => {
|
|
1584
|
+
const arr = items()
|
|
1585
|
+
const parent = startMarker.parentNode as (ParentNode & Node) | null
|
|
1586
|
+
if (!parent) {
|
|
1587
|
+
pendingItems = arr
|
|
1588
|
+
return
|
|
1589
|
+
}
|
|
1590
|
+
pendingItems = null
|
|
1591
|
+
|
|
1592
|
+
const newNodeMap = new Map<string | number, ManagedBlock<T>>()
|
|
1593
|
+
const blocks: ManagedBlock<T>[] = []
|
|
1594
|
+
|
|
1595
|
+
for (let i = 0; i < arr.length; i++) {
|
|
1596
|
+
const item = arr[i]! as T
|
|
1597
|
+
const key = getKey ? getKey(item, i) : i
|
|
1598
|
+
const existing = nodeMap.get(key)
|
|
1599
|
+
|
|
1600
|
+
let block: ManagedBlock<T>
|
|
1601
|
+
if (existing) {
|
|
1602
|
+
const previousValue = existing.value()
|
|
1603
|
+
if (!getKey && previousValue !== item) {
|
|
1604
|
+
destroyRoot(existing.root)
|
|
1605
|
+
removeBlockNodes(existing)
|
|
1606
|
+
block = mountBlock(item, i, renderItem, parent, endMarker, createElementFn)
|
|
1607
|
+
} else {
|
|
1608
|
+
const previousIndex = existing.index()
|
|
1609
|
+
existing.value(item)
|
|
1610
|
+
existing.index(i)
|
|
1611
|
+
|
|
1612
|
+
if (previousValue === item) {
|
|
1613
|
+
bumpBlockVersion(existing)
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const needsRerender = getKey ? true : previousValue !== item || previousIndex !== i
|
|
1617
|
+
block = needsRerender ? rerenderBlock(existing, createElementFn) : existing
|
|
1618
|
+
}
|
|
1619
|
+
} else {
|
|
1620
|
+
block = mountBlock(item, i, renderItem, parent, endMarker, createElementFn)
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
newNodeMap.set(key, block)
|
|
1624
|
+
blocks.push(block)
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
for (const [key, managed] of nodeMap) {
|
|
1628
|
+
if (!newNodeMap.has(key)) {
|
|
1629
|
+
destroyRoot(managed.root)
|
|
1630
|
+
removeBlockNodes(managed)
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
let anchor: Node = endMarker
|
|
1635
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
1636
|
+
const block = blocks[i]!
|
|
1637
|
+
insertNodesBefore(parent, block.nodes, anchor)
|
|
1638
|
+
if (block.nodes.length > 0) {
|
|
1639
|
+
anchor = block.nodes[0]!
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
nodeMap.clear()
|
|
1644
|
+
for (const [k, v] of newNodeMap) {
|
|
1645
|
+
nodeMap.set(k, v)
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const dispose = createRenderEffect(runListUpdate)
|
|
1650
|
+
|
|
1651
|
+
return {
|
|
1652
|
+
marker: fragment,
|
|
1653
|
+
flush: () => {
|
|
1654
|
+
if (pendingItems !== null) {
|
|
1655
|
+
runListUpdate()
|
|
1656
|
+
}
|
|
1657
|
+
},
|
|
1658
|
+
dispose: () => {
|
|
1659
|
+
dispose()
|
|
1660
|
+
for (const [, managed] of nodeMap) {
|
|
1661
|
+
destroyRoot(managed.root)
|
|
1662
|
+
removeBlockNodes(managed)
|
|
1663
|
+
}
|
|
1664
|
+
nodeMap.clear()
|
|
1665
|
+
startMarker.parentNode?.removeChild(startMarker)
|
|
1666
|
+
endMarker.parentNode?.removeChild(endMarker)
|
|
1667
|
+
},
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// ============================================================================
|
|
1672
|
+
// Show/Hide Helper
|
|
1673
|
+
// ==========================================================================
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Create a show/hide binding that uses CSS display instead of DOM manipulation.
|
|
1677
|
+
* More efficient than conditional when the content is expensive to create.
|
|
1678
|
+
*
|
|
1679
|
+
* @example
|
|
1680
|
+
* ```ts
|
|
1681
|
+
* createShow(container, () => $visible())
|
|
1682
|
+
* ```
|
|
1683
|
+
*/
|
|
1684
|
+
export function createShow(el: HTMLElement, condition: () => boolean, displayValue?: string): void {
|
|
1685
|
+
const originalDisplay = displayValue ?? el.style.display
|
|
1686
|
+
createRenderEffect(() => {
|
|
1687
|
+
el.style.display = condition() ? originalDisplay : 'none'
|
|
1688
|
+
})
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// ============================================================================
|
|
1692
|
+
// Portal
|
|
1693
|
+
// ============================================================================
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Create a portal that renders content into a different DOM container.
|
|
1697
|
+
*
|
|
1698
|
+
* @example
|
|
1699
|
+
* ```ts
|
|
1700
|
+
* createPortal(
|
|
1701
|
+
* document.body,
|
|
1702
|
+
* () => jsx(Modal, { children: 'Hello' }),
|
|
1703
|
+
* createElement
|
|
1704
|
+
* )
|
|
1705
|
+
* ```
|
|
1706
|
+
*/
|
|
1707
|
+
export function createPortal(
|
|
1708
|
+
container: HTMLElement,
|
|
1709
|
+
render: () => FictNode,
|
|
1710
|
+
createElementFn: CreateElementFn,
|
|
1711
|
+
): BindingHandle {
|
|
1712
|
+
// Capture the parent root BEFORE any effects run
|
|
1713
|
+
// This is needed because createRenderEffect will push/pop its own root context
|
|
1714
|
+
const parentRoot = getCurrentRoot()
|
|
1715
|
+
|
|
1716
|
+
const marker = document.createComment('fict:portal')
|
|
1717
|
+
container.appendChild(marker)
|
|
1718
|
+
|
|
1719
|
+
let currentNodes: Node[] = []
|
|
1720
|
+
let currentRoot: RootContext | null = null
|
|
1721
|
+
|
|
1722
|
+
const dispose = createRenderEffect(() => {
|
|
1723
|
+
// Clean up previous
|
|
1724
|
+
if (currentRoot) {
|
|
1725
|
+
destroyRoot(currentRoot)
|
|
1726
|
+
currentRoot = null
|
|
1727
|
+
}
|
|
1728
|
+
if (currentNodes.length > 0) {
|
|
1729
|
+
removeNodes(currentNodes)
|
|
1730
|
+
currentNodes = []
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// Create new content
|
|
1734
|
+
const root = createRootContext()
|
|
1735
|
+
const prev = pushRoot(root)
|
|
1736
|
+
let handledError = false
|
|
1737
|
+
try {
|
|
1738
|
+
const output = render()
|
|
1739
|
+
if (output != null && output !== false) {
|
|
1740
|
+
const el = createElementFn(output)
|
|
1741
|
+
const nodes = toNodeArray(el)
|
|
1742
|
+
if (marker.parentNode) {
|
|
1743
|
+
insertNodesBefore(marker.parentNode as ParentNode & Node, nodes, marker)
|
|
1744
|
+
}
|
|
1745
|
+
currentNodes = nodes
|
|
1746
|
+
}
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
if (handleSuspend(err as any, root)) {
|
|
1749
|
+
handledError = true
|
|
1750
|
+
destroyRoot(root)
|
|
1751
|
+
currentNodes = []
|
|
1752
|
+
return
|
|
1753
|
+
}
|
|
1754
|
+
if (handleError(err, { source: 'renderChild' }, root)) {
|
|
1755
|
+
handledError = true
|
|
1756
|
+
destroyRoot(root)
|
|
1757
|
+
currentNodes = []
|
|
1758
|
+
return
|
|
1759
|
+
}
|
|
1760
|
+
throw err
|
|
1761
|
+
} finally {
|
|
1762
|
+
popRoot(prev)
|
|
1763
|
+
if (!handledError) {
|
|
1764
|
+
flushOnMount(root)
|
|
1765
|
+
currentRoot = root
|
|
1766
|
+
} else {
|
|
1767
|
+
currentRoot = null
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
})
|
|
1771
|
+
|
|
1772
|
+
// The portal's dispose function must be named so we can register it for cleanup
|
|
1773
|
+
const portalDispose = () => {
|
|
1774
|
+
dispose()
|
|
1775
|
+
if (currentRoot) {
|
|
1776
|
+
destroyRoot(currentRoot)
|
|
1777
|
+
}
|
|
1778
|
+
if (currentNodes.length > 0) {
|
|
1779
|
+
removeNodes(currentNodes)
|
|
1780
|
+
}
|
|
1781
|
+
marker.parentNode?.removeChild(marker)
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// Register the portal's cleanup with the parent component's root context
|
|
1785
|
+
// This ensures the portal is cleaned up when the parent unmounts
|
|
1786
|
+
// We use parentRoot (captured before createRenderEffect) to avoid registering
|
|
1787
|
+
// with the portal's internal root which would be destroyed separately
|
|
1788
|
+
if (parentRoot) {
|
|
1789
|
+
parentRoot.destroyCallbacks.push(portalDispose)
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
return {
|
|
1793
|
+
marker,
|
|
1794
|
+
dispose: portalDispose,
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// ============================================================================
|
|
1799
|
+
// Internal helpers
|
|
1800
|
+
// ============================================================================
|
|
1801
|
+
|
|
1802
|
+
function mountBlock<T>(
|
|
1803
|
+
initialValue: T,
|
|
1804
|
+
initialIndex: number,
|
|
1805
|
+
renderItem: (item: T, index: number) => FictNode,
|
|
1806
|
+
parent: ParentNode & Node,
|
|
1807
|
+
anchor: Node,
|
|
1808
|
+
createElementFn: CreateElementFn,
|
|
1809
|
+
): ManagedBlock<T> {
|
|
1810
|
+
const start = document.createComment('fict:block:start')
|
|
1811
|
+
const end = document.createComment('fict:block:end')
|
|
1812
|
+
const valueSig = createSignal<T>(initialValue)
|
|
1813
|
+
const indexSig = createSignal<number>(initialIndex)
|
|
1814
|
+
const versionSig = createSignal(0)
|
|
1815
|
+
const valueProxy = createValueProxy(() => {
|
|
1816
|
+
versionSig()
|
|
1817
|
+
return valueSig()
|
|
1818
|
+
}) as T
|
|
1819
|
+
const renderCurrent = () => renderItem(valueProxy, indexSig())
|
|
1820
|
+
const root = createRootContext()
|
|
1821
|
+
const prev = pushRoot(root)
|
|
1822
|
+
const nodes: Node[] = [start]
|
|
1823
|
+
let handledError = false
|
|
1824
|
+
try {
|
|
1825
|
+
const output = renderCurrent()
|
|
1826
|
+
if (output != null && output !== false) {
|
|
1827
|
+
const el = createElementFn(output)
|
|
1828
|
+
const rendered = toNodeArray(el)
|
|
1829
|
+
nodes.push(...rendered)
|
|
1830
|
+
}
|
|
1831
|
+
nodes.push(end)
|
|
1832
|
+
insertNodesBefore(parent, nodes, anchor)
|
|
1833
|
+
} catch (err) {
|
|
1834
|
+
if (handleSuspend(err as any, root)) {
|
|
1835
|
+
handledError = true
|
|
1836
|
+
nodes.push(end)
|
|
1837
|
+
insertNodesBefore(parent, nodes, anchor)
|
|
1838
|
+
} else if (handleError(err, { source: 'renderChild' }, root)) {
|
|
1839
|
+
handledError = true
|
|
1840
|
+
nodes.push(end)
|
|
1841
|
+
insertNodesBefore(parent, nodes, anchor)
|
|
1842
|
+
} else {
|
|
1843
|
+
throw err
|
|
1844
|
+
}
|
|
1845
|
+
} finally {
|
|
1846
|
+
popRoot(prev)
|
|
1847
|
+
if (!handledError) {
|
|
1848
|
+
flushOnMount(root)
|
|
1849
|
+
} else {
|
|
1850
|
+
destroyRoot(root)
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
return {
|
|
1854
|
+
nodes,
|
|
1855
|
+
root,
|
|
1856
|
+
value: valueSig,
|
|
1857
|
+
index: indexSig,
|
|
1858
|
+
version: versionSig,
|
|
1859
|
+
start,
|
|
1860
|
+
end,
|
|
1861
|
+
valueProxy,
|
|
1862
|
+
renderCurrent,
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function rerenderBlock<T>(
|
|
1867
|
+
block: ManagedBlock<T>,
|
|
1868
|
+
createElementFn: CreateElementFn,
|
|
1869
|
+
): ManagedBlock<T> {
|
|
1870
|
+
const currentContent = block.nodes.slice(1, Math.max(1, block.nodes.length - 1))
|
|
1871
|
+
const currentNode = currentContent.length === 1 ? currentContent[0] : null
|
|
1872
|
+
|
|
1873
|
+
clearRoot(block.root)
|
|
1874
|
+
|
|
1875
|
+
const prev = pushRoot(block.root)
|
|
1876
|
+
let nextOutput: FictNode
|
|
1877
|
+
let handledError = false
|
|
1878
|
+
try {
|
|
1879
|
+
nextOutput = block.renderCurrent()
|
|
1880
|
+
} catch (err) {
|
|
1881
|
+
if (handleSuspend(err as any, block.root)) {
|
|
1882
|
+
handledError = true
|
|
1883
|
+
popRoot(prev)
|
|
1884
|
+
destroyRoot(block.root)
|
|
1885
|
+
block.nodes = [block.start, block.end]
|
|
1886
|
+
return block
|
|
1887
|
+
}
|
|
1888
|
+
if (handleError(err, { source: 'renderChild' }, block.root)) {
|
|
1889
|
+
handledError = true
|
|
1890
|
+
popRoot(prev)
|
|
1891
|
+
destroyRoot(block.root)
|
|
1892
|
+
block.nodes = [block.start, block.end]
|
|
1893
|
+
return block
|
|
1894
|
+
}
|
|
1895
|
+
throw err
|
|
1896
|
+
} finally {
|
|
1897
|
+
if (!handledError) {
|
|
1898
|
+
popRoot(prev)
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
if (isFragmentVNode(nextOutput) && currentContent.length > 0) {
|
|
1903
|
+
const patched = patchFragmentChildren(currentContent, nextOutput.props?.children)
|
|
1904
|
+
if (patched) {
|
|
1905
|
+
block.nodes = [block.start, ...currentContent, block.end]
|
|
1906
|
+
return block
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
if (currentNode && patchNode(currentNode, nextOutput)) {
|
|
1911
|
+
block.nodes = [block.start, currentNode, block.end]
|
|
1912
|
+
return block
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
clearContent(block)
|
|
1916
|
+
|
|
1917
|
+
if (nextOutput != null && nextOutput !== false) {
|
|
1918
|
+
const newNodes = toNodeArray(
|
|
1919
|
+
nextOutput instanceof Node ? nextOutput : (createElementFn(nextOutput) as Node),
|
|
1920
|
+
)
|
|
1921
|
+
insertNodesBefore(block.start.parentNode as ParentNode & Node, newNodes, block.end)
|
|
1922
|
+
block.nodes = [block.start, ...newNodes, block.end]
|
|
1923
|
+
} else {
|
|
1924
|
+
block.nodes = [block.start, block.end]
|
|
1925
|
+
}
|
|
1926
|
+
return block
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function patchElement(el: Element, output: FictNode): boolean {
|
|
1930
|
+
if (
|
|
1931
|
+
output === null ||
|
|
1932
|
+
output === undefined ||
|
|
1933
|
+
output === false ||
|
|
1934
|
+
typeof output === 'string' ||
|
|
1935
|
+
typeof output === 'number'
|
|
1936
|
+
) {
|
|
1937
|
+
el.textContent =
|
|
1938
|
+
output === null || output === undefined || output === false ? '' : String(output)
|
|
1939
|
+
return true
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
if (output instanceof Text) {
|
|
1943
|
+
el.textContent = output.data
|
|
1944
|
+
return true
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
if (output && typeof output === 'object' && !(output instanceof Node)) {
|
|
1948
|
+
const vnode = output as { type?: unknown; props?: Record<string, unknown> }
|
|
1949
|
+
if (typeof vnode.type === 'string' && vnode.type.toLowerCase() === el.tagName.toLowerCase()) {
|
|
1950
|
+
const children = vnode.props?.children
|
|
1951
|
+
const props = vnode.props ?? {}
|
|
1952
|
+
|
|
1953
|
+
// Update props (except children and key)
|
|
1954
|
+
for (const [key, value] of Object.entries(props)) {
|
|
1955
|
+
if (key === 'children' || key === 'key') continue
|
|
1956
|
+
if (
|
|
1957
|
+
typeof value === 'string' ||
|
|
1958
|
+
typeof value === 'number' ||
|
|
1959
|
+
typeof value === 'boolean' ||
|
|
1960
|
+
value === null ||
|
|
1961
|
+
value === undefined
|
|
1962
|
+
) {
|
|
1963
|
+
if (key === 'class' || key === 'className') {
|
|
1964
|
+
el.setAttribute('class', value === false || value === null ? '' : String(value))
|
|
1965
|
+
} else if (key === 'style' && typeof value === 'string') {
|
|
1966
|
+
;(el as HTMLElement).style.cssText = value
|
|
1967
|
+
} else if (value === false || value === null || value === undefined) {
|
|
1968
|
+
el.removeAttribute(key)
|
|
1969
|
+
} else if (value === true) {
|
|
1970
|
+
el.setAttribute(key, '')
|
|
1971
|
+
} else {
|
|
1972
|
+
el.setAttribute(key, String(value))
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Handle primitive children
|
|
1978
|
+
if (
|
|
1979
|
+
typeof children === 'string' ||
|
|
1980
|
+
typeof children === 'number' ||
|
|
1981
|
+
children === null ||
|
|
1982
|
+
children === undefined ||
|
|
1983
|
+
children === false
|
|
1984
|
+
) {
|
|
1985
|
+
el.textContent =
|
|
1986
|
+
children === null || children === undefined || children === false ? '' : String(children)
|
|
1987
|
+
return true
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Handle single nested VNode child - recursively patch
|
|
1991
|
+
if (
|
|
1992
|
+
children &&
|
|
1993
|
+
typeof children === 'object' &&
|
|
1994
|
+
!Array.isArray(children) &&
|
|
1995
|
+
!(children instanceof Node)
|
|
1996
|
+
) {
|
|
1997
|
+
const childVNode = children as { type?: unknown; props?: Record<string, unknown> }
|
|
1998
|
+
if (typeof childVNode.type === 'string') {
|
|
1999
|
+
// Find matching child element in the DOM
|
|
2000
|
+
const childEl = el.querySelector(childVNode.type)
|
|
2001
|
+
if (childEl && patchElement(childEl, children as FictNode)) {
|
|
2002
|
+
return true
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
return false
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (output instanceof Node) {
|
|
2012
|
+
if (output.nodeType === Node.ELEMENT_NODE) {
|
|
2013
|
+
const nextEl = output as Element
|
|
2014
|
+
if (nextEl.tagName.toLowerCase() === el.tagName.toLowerCase()) {
|
|
2015
|
+
el.textContent = nextEl.textContent
|
|
2016
|
+
return true
|
|
2017
|
+
}
|
|
2018
|
+
} else if (output.nodeType === Node.TEXT_NODE) {
|
|
2019
|
+
el.textContent = (output as Text).data
|
|
2020
|
+
return true
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
return false
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
function patchNode(currentNode: Node | null, nextOutput: FictNode): boolean {
|
|
2028
|
+
if (!currentNode) return false
|
|
2029
|
+
|
|
2030
|
+
if (
|
|
2031
|
+
currentNode instanceof Text &&
|
|
2032
|
+
(nextOutput === null ||
|
|
2033
|
+
nextOutput === undefined ||
|
|
2034
|
+
nextOutput === false ||
|
|
2035
|
+
typeof nextOutput === 'string' ||
|
|
2036
|
+
typeof nextOutput === 'number' ||
|
|
2037
|
+
nextOutput instanceof Text)
|
|
2038
|
+
) {
|
|
2039
|
+
const nextText =
|
|
2040
|
+
nextOutput instanceof Text
|
|
2041
|
+
? nextOutput.data
|
|
2042
|
+
: nextOutput === null || nextOutput === undefined || nextOutput === false
|
|
2043
|
+
? ''
|
|
2044
|
+
: String(nextOutput)
|
|
2045
|
+
currentNode.data = nextText
|
|
2046
|
+
return true
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
if (currentNode instanceof Element && patchElement(currentNode, nextOutput)) {
|
|
2050
|
+
return true
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
if (nextOutput instanceof Node && currentNode === nextOutput) {
|
|
2054
|
+
return true
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
return false
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function isFragmentVNode(
|
|
2061
|
+
value: unknown,
|
|
2062
|
+
): value is { type: typeof Fragment; props?: { children?: FictNode | FictNode[] } } {
|
|
2063
|
+
return (
|
|
2064
|
+
value != null &&
|
|
2065
|
+
typeof value === 'object' &&
|
|
2066
|
+
!(value instanceof Node) &&
|
|
2067
|
+
(value as { type?: unknown }).type === Fragment
|
|
2068
|
+
)
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function normalizeChildren(
|
|
2072
|
+
children: FictNode | FictNode[] | undefined,
|
|
2073
|
+
result: FictNode[] = [],
|
|
2074
|
+
): FictNode[] {
|
|
2075
|
+
if (children === undefined) {
|
|
2076
|
+
return result
|
|
2077
|
+
}
|
|
2078
|
+
if (Array.isArray(children)) {
|
|
2079
|
+
for (const child of children) {
|
|
2080
|
+
normalizeChildren(child, result)
|
|
2081
|
+
}
|
|
2082
|
+
return result
|
|
2083
|
+
}
|
|
2084
|
+
if (children === null || children === false) {
|
|
2085
|
+
return result
|
|
2086
|
+
}
|
|
2087
|
+
result.push(children)
|
|
2088
|
+
return result
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function patchFragmentChildren(
|
|
2092
|
+
nodes: Node[],
|
|
2093
|
+
children: FictNode | FictNode[] | undefined,
|
|
2094
|
+
): boolean {
|
|
2095
|
+
const normalized = normalizeChildren(children)
|
|
2096
|
+
if (normalized.length !== nodes.length) {
|
|
2097
|
+
return false
|
|
2098
|
+
}
|
|
2099
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
2100
|
+
if (!patchNode(nodes[i]!, normalized[i]!)) {
|
|
2101
|
+
return false
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
return true
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function clearContent<T>(block: ManagedBlock<T>): void {
|
|
2108
|
+
const nodes = block.nodes.slice(1, Math.max(1, block.nodes.length - 1))
|
|
2109
|
+
removeNodes(nodes)
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function removeBlockNodes<T>(block: ManagedBlock<T>): void {
|
|
2113
|
+
let cursor: Node | null = block.start
|
|
2114
|
+
const end = block.end
|
|
2115
|
+
while (cursor) {
|
|
2116
|
+
const next: Node | null = cursor.nextSibling
|
|
2117
|
+
cursor.parentNode?.removeChild(cursor)
|
|
2118
|
+
if (cursor === end) break
|
|
2119
|
+
cursor = next
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function bumpBlockVersion<T>(block: ManagedBlock<T>): void {
|
|
2124
|
+
block.version(block.version() + 1)
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// DOM utility functions are imported from './node-ops' to avoid duplication
|