@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.
Files changed (51) hide show
  1. package/README.md +17 -0
  2. package/dist/index.cjs +4224 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +1572 -0
  5. package/dist/index.d.ts +1572 -0
  6. package/dist/index.dev.js +4240 -0
  7. package/dist/index.dev.js.map +1 -0
  8. package/dist/index.js +4133 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/jsx-dev-runtime.cjs +44 -0
  11. package/dist/jsx-dev-runtime.cjs.map +1 -0
  12. package/dist/jsx-dev-runtime.js +14 -0
  13. package/dist/jsx-dev-runtime.js.map +1 -0
  14. package/dist/jsx-runtime.cjs +44 -0
  15. package/dist/jsx-runtime.cjs.map +1 -0
  16. package/dist/jsx-runtime.js +14 -0
  17. package/dist/jsx-runtime.js.map +1 -0
  18. package/dist/slim.cjs +3384 -0
  19. package/dist/slim.cjs.map +1 -0
  20. package/dist/slim.d.cts +475 -0
  21. package/dist/slim.d.ts +475 -0
  22. package/dist/slim.js +3335 -0
  23. package/dist/slim.js.map +1 -0
  24. package/package.json +68 -0
  25. package/src/binding.ts +2127 -0
  26. package/src/constants.ts +456 -0
  27. package/src/cycle-guard.ts +134 -0
  28. package/src/devtools.ts +17 -0
  29. package/src/dom.ts +683 -0
  30. package/src/effect.ts +83 -0
  31. package/src/error-boundary.ts +118 -0
  32. package/src/hooks.ts +72 -0
  33. package/src/index.ts +184 -0
  34. package/src/jsx-dev-runtime.ts +2 -0
  35. package/src/jsx-runtime.ts +2 -0
  36. package/src/jsx.ts +786 -0
  37. package/src/lifecycle.ts +273 -0
  38. package/src/list-helpers.ts +619 -0
  39. package/src/memo.ts +14 -0
  40. package/src/node-ops.ts +185 -0
  41. package/src/props.ts +212 -0
  42. package/src/reconcile.ts +151 -0
  43. package/src/ref.ts +25 -0
  44. package/src/scheduler.ts +12 -0
  45. package/src/signal.ts +1278 -0
  46. package/src/slim.ts +68 -0
  47. package/src/store.ts +210 -0
  48. package/src/suspense.ts +187 -0
  49. package/src/transition.ts +128 -0
  50. package/src/types.ts +172 -0
  51. 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