@fictjs/runtime 0.17.0 → 0.17.1

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 (64) hide show
  1. package/dist/advanced.cjs +9 -9
  2. package/dist/advanced.d.cts +3 -3
  3. package/dist/advanced.d.ts +3 -3
  4. package/dist/advanced.js +4 -4
  5. package/dist/{binding-CQUGLLBI.d.ts → binding-BfzY9rae.d.ts} +2 -2
  6. package/dist/{binding-BlABuUiG.d.cts → binding-CDR2ERoq.d.cts} +2 -2
  7. package/dist/{chunk-5FVWBK4M.cjs → chunk-2J4INHDT.cjs} +40 -40
  8. package/dist/{chunk-5FVWBK4M.cjs.map → chunk-2J4INHDT.cjs.map} +1 -1
  9. package/dist/{chunk-6DNYVH5U.cjs → chunk-CKKZDUHM.cjs} +21 -18
  10. package/dist/chunk-CKKZDUHM.cjs.map +1 -0
  11. package/dist/{chunk-UQTWIV3S.js → chunk-DHRRJJ6W.js} +8 -5
  12. package/dist/chunk-DHRRJJ6W.js.map +1 -0
  13. package/dist/{chunk-IIWHTV23.js → chunk-LFLFSJFU.js} +3 -3
  14. package/dist/{chunk-ECKYFH5Q.cjs → chunk-NBDEMBBX.cjs} +43 -81
  15. package/dist/chunk-NBDEMBBX.cjs.map +1 -0
  16. package/dist/{chunk-CFAWL76V.js → chunk-OKPQWORE.js} +43 -81
  17. package/dist/chunk-OKPQWORE.js.map +1 -0
  18. package/dist/{chunk-M42N54LG.js → chunk-OLHZBAIF.js} +3 -3
  19. package/dist/{chunk-F5SDRX4J.js → chunk-R2HYEOP7.js} +470 -172
  20. package/dist/chunk-R2HYEOP7.js.map +1 -0
  21. package/dist/{chunk-INYTG4NG.cjs → chunk-UG2IFQOY.cjs} +650 -352
  22. package/dist/chunk-UG2IFQOY.cjs.map +1 -0
  23. package/dist/{chunk-WY4LI5PB.cjs → chunk-VP2WC7X3.cjs} +8 -8
  24. package/dist/{chunk-WY4LI5PB.cjs.map → chunk-VP2WC7X3.cjs.map} +1 -1
  25. package/dist/{devtools-DWIZRe7L.d.cts → devtools-BwkkQ6DN.d.cts} +1 -1
  26. package/dist/{devtools-DNnnDGu1.d.ts → devtools-CK3SVU_w.d.ts} +1 -1
  27. package/dist/index.cjs +55 -42
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +4 -4
  30. package/dist/index.d.ts +4 -4
  31. package/dist/index.dev.js +260 -156
  32. package/dist/index.dev.js.map +1 -1
  33. package/dist/index.js +16 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal-list.cjs +4 -4
  36. package/dist/internal-list.js +3 -3
  37. package/dist/internal.cjs +5 -5
  38. package/dist/internal.d.cts +3 -3
  39. package/dist/internal.d.ts +3 -3
  40. package/dist/internal.js +4 -4
  41. package/dist/loader.cjs +18 -18
  42. package/dist/loader.js +1 -1
  43. package/dist/{props-C04ScJgm.d.ts → props-CFoQ471Y.d.ts} +1 -1
  44. package/dist/{props-CdmuXCiu.d.cts → props-D4tK8Gn0.d.cts} +1 -1
  45. package/dist/{scope-gpOMWTlf.d.ts → scope-BFzD_7hx.d.ts} +1 -1
  46. package/dist/{scope-GwC4DJ50.d.cts → scope-Ck3mTQVS.d.cts} +1 -1
  47. package/package.json +1 -1
  48. package/src/binding.ts +561 -166
  49. package/src/context.ts +8 -1
  50. package/src/dom.ts +26 -44
  51. package/src/effect.ts +9 -12
  52. package/src/error-boundary.ts +8 -0
  53. package/src/hydration.ts +25 -6
  54. package/src/lifecycle.ts +31 -79
  55. package/src/signal.ts +4 -1
  56. package/src/suspense.ts +8 -0
  57. package/dist/chunk-6DNYVH5U.cjs.map +0 -1
  58. package/dist/chunk-CFAWL76V.js.map +0 -1
  59. package/dist/chunk-ECKYFH5Q.cjs.map +0 -1
  60. package/dist/chunk-F5SDRX4J.js.map +0 -1
  61. package/dist/chunk-INYTG4NG.cjs.map +0 -1
  62. package/dist/chunk-UQTWIV3S.js.map +0 -1
  63. /package/dist/{chunk-IIWHTV23.js.map → chunk-LFLFSJFU.js.map} +0 -0
  64. /package/dist/{chunk-M42N54LG.js.map → chunk-OLHZBAIF.js.map} +0 -0
package/src/binding.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  SVGNamespace,
22
22
  } from './constants'
23
23
  import { createRenderEffect } from './effect'
24
- import { withHydrationRange, isHydratingActive } from './hydration'
24
+ import { withHydration, withHydrationRange, isHydratingActive } from './hydration'
25
25
  import { Fragment } from './jsx'
26
26
  import {
27
27
  createRootContext,
@@ -38,7 +38,7 @@ import {
38
38
  import { toNodeArray, removeNodes, insertNodesBefore } from './node-ops'
39
39
  import { __fictIsHydrating } from './resume'
40
40
  import { batch } from './scheduler'
41
- import { computed, untrack, isSignal, isComputed, isEffect, isEffectScope } from './signal'
41
+ import { computed, signal, untrack, isSignal, isComputed, isEffect, isEffectScope } from './signal'
42
42
  import type { Cleanup, FictNode } from './types'
43
43
 
44
44
  const isDev =
@@ -52,7 +52,9 @@ const PROP_CACHE = Symbol('fict:prop')
52
52
  const STYLE_CACHE = Symbol('fict:style')
53
53
  const CLASS_STATE_CACHE = Symbol('fict:class-state')
54
54
  const CLASS_VALUE_CACHE = Symbol('fict:class-value')
55
- const EVENT_TUPLE_LISTENER_CACHE = Symbol('fict:event-tuple-listener-cache')
55
+ const EVENT_LISTENER_CACHE = Symbol('fict:event-listener-cache')
56
+ const REF_ASSIGN_CACHE = Symbol('fict:ref-assign-cache')
57
+ const CHILDREN_BINDING_CACHE = Symbol('fict:children-binding-cache')
56
58
  const NON_REACTIVE_FN_MARKER = Symbol.for('fict:non-reactive-fn')
57
59
  const REACTIVE_FN_MARKER = Symbol.for('fict:reactive-fn')
58
60
  const NON_REACTIVE_FN_REGISTRY_KEY = Symbol.for('fict:non-reactive-fn-registry')
@@ -61,7 +63,25 @@ type NonReactiveRegistryHost = typeof globalThis & {
61
63
  [NON_REACTIVE_FN_REGISTRY_KEY]?: WeakSet<(...args: unknown[]) => unknown>
62
64
  }
63
65
 
64
- type EventTupleListenerStore = Map<string, EventListener>
66
+ interface StoredEventListener {
67
+ listener: EventListener
68
+ options: boolean | AddEventListenerOptions | undefined
69
+ }
70
+
71
+ interface ChildrenBindingState {
72
+ cleanup: Cleanup | undefined
73
+ value: (next?: FictNode | undefined) => FictNode | void
74
+ owner: RootContext | undefined
75
+ }
76
+
77
+ interface AssignedRefState {
78
+ cleanup: Cleanup | undefined
79
+ owner: RootContext | undefined
80
+ registeredCleanup: boolean
81
+ value: ((next?: unknown) => unknown | void) | undefined
82
+ }
83
+
84
+ type EventListenerStore = Map<string, StoredEventListener>
65
85
 
66
86
  const PROPERTY_BINDING_KEYS = new Set([
67
87
  'value',
@@ -105,6 +125,12 @@ export type MaybeReactive<T> = T | (() => T)
105
125
  /** Internal type for createElement function reference */
106
126
  export type CreateElementFn = (node: FictNode) => Node
107
127
 
128
+ let registeredCreateElement: CreateElementFn | undefined
129
+
130
+ export function registerCreateElement(fn: CreateElementFn): void {
131
+ registeredCreateElement = fn
132
+ }
133
+
108
134
  /** Handle returned by conditional/list bindings for cleanup */
109
135
  export interface BindingHandle {
110
136
  /** Marker node(s) used for positioning */
@@ -1215,28 +1241,19 @@ function globalEventHandler(e: Event): void {
1215
1241
  if (!node) return false
1216
1242
  const handler = node[key]
1217
1243
  if (handler && !(node as HTMLButtonElement).disabled) {
1218
- const resolveData = (value: unknown): unknown => {
1219
- if (typeof value === 'function') {
1220
- try {
1221
- const fn = value as (event?: Event) => unknown
1222
- return fn.length > 0 ? fn(e) : fn()
1223
- } catch {
1224
- return (value as () => unknown)()
1225
- }
1226
- }
1227
- return value
1228
- }
1229
-
1230
1244
  const rawData = (node as any)[dataKey] as unknown
1231
1245
  const hasData = rawData !== undefined
1232
- const resolvedNodeData = hasData ? resolveData(rawData) : undefined
1246
+ const resolvedNodeData = hasData ? resolveEventData(rawData, e) : undefined
1233
1247
  // Wrap event handler calls in batch for synchronous flush & reduced microtasks
1234
1248
  batch(() => {
1235
1249
  if (typeof handler === 'function') {
1236
1250
  callEventHandler(handler, e, node, hasData ? resolvedNodeData : undefined)
1237
1251
  } else if (Array.isArray(handler)) {
1238
- const tupleData = resolveData(handler[1])
1239
- callEventHandler(handler[0], e, node, tupleData)
1252
+ const tupleHandler = resolveEventHandlerValue(
1253
+ handler[0] as EventListenerOrEventListenerObject | null | undefined,
1254
+ )
1255
+ const tupleData = resolveEventData(handler[1], e)
1256
+ callEventHandler(tupleHandler, e, node, tupleData)
1240
1257
  }
1241
1258
  })
1242
1259
  if (e.cancelBubble) return false
@@ -1314,12 +1331,21 @@ function globalEventHandler(e: Event): void {
1314
1331
  export function addEventListener(
1315
1332
  node: Element,
1316
1333
  name: string,
1317
- handler: EventListener | [EventListener, unknown] | null | undefined,
1334
+ handler:
1335
+ | EventListenerOrEventListenerObject
1336
+ | [EventListenerOrEventListenerObject, unknown]
1337
+ | null
1338
+ | undefined,
1318
1339
  delegate?: boolean,
1340
+ options?: boolean | AddEventListenerOptions,
1319
1341
  ): void {
1320
1342
  if (delegate) {
1321
1343
  const key = `$$${name}`
1322
1344
  const dataKey = `${key}Data`
1345
+ const rootRef = getCurrentRoot()
1346
+ const delegationDocument = resolveDelegationDocument(node, rootRef)
1347
+
1348
+ delegateEvents([name], delegationDocument)
1323
1349
 
1324
1350
  if (handler == null) {
1325
1351
  ;(node as unknown as Record<string, unknown>)[key] = undefined
@@ -1327,61 +1353,130 @@ export function addEventListener(
1327
1353
  return
1328
1354
  }
1329
1355
 
1330
- // Event delegation: store handler on element
1331
- if (Array.isArray(handler)) {
1332
- ;(node as unknown as Record<string, unknown>)[key] = handler[0]
1333
- ;(node as unknown as Record<string, unknown>)[dataKey] = handler[1]
1334
- } else {
1335
- ;(node as unknown as Record<string, unknown>)[key] = handler
1336
- ;(node as unknown as Record<string, unknown>)[dataKey] = undefined
1337
- }
1356
+ ;(node as unknown as Record<string, unknown>)[key] = createEventInvoker(
1357
+ name,
1358
+ handler,
1359
+ node,
1360
+ rootRef,
1361
+ )
1362
+ ;(node as unknown as Record<string, unknown>)[dataKey] = undefined
1338
1363
  return
1339
1364
  }
1340
1365
 
1366
+ removeStoredEventListener(node, name, options)
1341
1367
  if (handler == null) return
1342
1368
 
1343
- if (Array.isArray(handler)) {
1344
- // Non-delegated with data binding
1345
- const store = getTupleEventListenerStore(node)
1346
- const existing = store.get(name)
1347
- if (existing) {
1348
- node.removeEventListener(name, existing)
1349
- }
1350
- const handlerFn = handler[0] as (data: unknown, e: Event) => void
1351
- const wrapped = (e: Event) => handlerFn.call(node, handler[1], e)
1352
- store.set(name, wrapped)
1353
- node.addEventListener(name, wrapped)
1354
- return
1355
- }
1369
+ const rootRef = getCurrentRoot()
1370
+ const wrapped = createEventInvoker(name, handler, node, rootRef)
1371
+ node.addEventListener(name, wrapped, options)
1372
+ getStoredEventListenerStore(node).set(getEventListenerStoreKey(name, options), {
1373
+ listener: wrapped,
1374
+ options,
1375
+ })
1376
+ }
1356
1377
 
1357
- // Regular event listener
1358
- node.addEventListener(name, handler as EventListener)
1378
+ function resolveDelegationDocument(node: Element, rootRef: RootContext | undefined): Document {
1379
+ const nodeDocument = node.ownerDocument ?? undefined
1380
+ if (rootRef?.ownerDocument && nodeDocument?.defaultView == null) {
1381
+ return rootRef.ownerDocument
1382
+ }
1383
+ return nodeDocument ?? rootRef?.ownerDocument ?? document
1359
1384
  }
1360
1385
 
1361
- function getTupleEventListenerStore(node: Element): EventTupleListenerStore {
1386
+ function getStoredEventListenerStore(node: Element): EventListenerStore {
1362
1387
  const host = node as unknown as {
1363
- [EVENT_TUPLE_LISTENER_CACHE]?: EventTupleListenerStore
1388
+ [EVENT_LISTENER_CACHE]?: EventListenerStore
1364
1389
  }
1365
- if (!host[EVENT_TUPLE_LISTENER_CACHE]) {
1366
- host[EVENT_TUPLE_LISTENER_CACHE] = new Map<string, EventListener>()
1390
+ if (!host[EVENT_LISTENER_CACHE]) {
1391
+ host[EVENT_LISTENER_CACHE] = new Map<string, StoredEventListener>()
1367
1392
  }
1368
- return host[EVENT_TUPLE_LISTENER_CACHE]!
1393
+ return host[EVENT_LISTENER_CACHE]!
1394
+ }
1395
+
1396
+ function getEventListenerStoreKey(
1397
+ name: string,
1398
+ options?: boolean | AddEventListenerOptions,
1399
+ ): string {
1400
+ const capture = typeof options === 'boolean' ? options : options?.capture === true
1401
+ const passive = typeof options === 'object' && options?.passive === true
1402
+ const once = typeof options === 'object' && options?.once === true
1403
+ return `${name}:${capture ? 1 : 0}:${passive ? 1 : 0}:${once ? 1 : 0}`
1369
1404
  }
1370
1405
 
1371
- function removeStoredTupleEventListener(node: Element, name: string): void {
1406
+ function removeStoredEventListener(
1407
+ node: Element,
1408
+ name: string,
1409
+ options?: boolean | AddEventListenerOptions,
1410
+ ): void {
1372
1411
  const host = node as unknown as {
1373
- [EVENT_TUPLE_LISTENER_CACHE]?: EventTupleListenerStore
1412
+ [EVENT_LISTENER_CACHE]?: EventListenerStore
1374
1413
  }
1375
- const store = host[EVENT_TUPLE_LISTENER_CACHE]
1414
+ const store = host[EVENT_LISTENER_CACHE]
1376
1415
  if (!store) return
1377
1416
 
1378
- const wrapped = store.get(name)
1379
- if (!wrapped) return
1417
+ const entry = store.get(getEventListenerStoreKey(name, options))
1418
+ if (!entry) return
1380
1419
 
1381
- node.removeEventListener(name, wrapped)
1382
- store.delete(name)
1420
+ node.removeEventListener(name, entry.listener, entry.options)
1421
+ store.delete(getEventListenerStoreKey(name, options))
1383
1422
  if (store.size === 0) {
1384
- delete host[EVENT_TUPLE_LISTENER_CACHE]
1423
+ delete host[EVENT_LISTENER_CACHE]
1424
+ }
1425
+ }
1426
+
1427
+ function resolveEventData(value: unknown, event: Event): unknown {
1428
+ if (typeof value !== 'function') return value
1429
+ if (isReactive(value)) {
1430
+ return (value as () => unknown)()
1431
+ }
1432
+ try {
1433
+ const fn = value as (event?: Event) => unknown
1434
+ return fn.length > 0 ? fn(event) : fn()
1435
+ } catch {
1436
+ return (value as () => unknown)()
1437
+ }
1438
+ }
1439
+
1440
+ function resolveEventHandlerValue(
1441
+ value: EventListenerOrEventListenerObject | null | undefined,
1442
+ ): EventListenerOrEventListenerObject | null | undefined {
1443
+ if (isStrictlyReactive(value)) {
1444
+ return (value as () => EventListenerOrEventListenerObject | null | undefined)()
1445
+ }
1446
+ return value
1447
+ }
1448
+
1449
+ function createEventInvoker(
1450
+ eventName: string,
1451
+ handler:
1452
+ | EventListenerOrEventListenerObject
1453
+ | [EventListenerOrEventListenerObject, unknown]
1454
+ | null
1455
+ | undefined,
1456
+ node: Element,
1457
+ rootRef: RootContext | undefined,
1458
+ ): EventListener {
1459
+ return (event: Event) => {
1460
+ try {
1461
+ if (Array.isArray(handler)) {
1462
+ const resolvedHandler = resolveEventHandlerValue(
1463
+ handler[0] as EventListenerOrEventListenerObject | null | undefined,
1464
+ )
1465
+ const data = resolveEventData(handler[1], event)
1466
+ callEventHandler(resolvedHandler, event, node, data)
1467
+ return
1468
+ }
1469
+
1470
+ const resolvedHandler = resolveEventHandlerValue(
1471
+ handler as EventListenerOrEventListenerObject | null | undefined,
1472
+ )
1473
+ callEventHandler(resolvedHandler, event, node)
1474
+ } catch (err) {
1475
+ if (handleError(err, { source: 'event', eventName }, rootRef)) {
1476
+ return
1477
+ }
1478
+ throw err
1479
+ }
1385
1480
  }
1386
1481
  }
1387
1482
 
@@ -1412,63 +1507,28 @@ export function bindEvent(
1412
1507
  options?: boolean | AddEventListenerOptions,
1413
1508
  ): Cleanup {
1414
1509
  if (handler == null) return () => {}
1415
- const rootRef = getCurrentRoot()
1416
1510
 
1417
1511
  // Optimization: Global Event Delegation
1418
1512
  // If the event is delegatable and no options were provided,
1419
1513
  // we attach the handler to the element property and rely on the global listener.
1420
1514
  const shouldDelegate = options == null && DelegatedEvents.has(eventName)
1421
1515
  if (shouldDelegate) {
1422
- const key = `$$${eventName}`
1423
-
1424
- // Ensure global delegation is active for this event
1425
- delegateEvents([eventName])
1426
-
1427
- // Use stricter check - don't misidentify regular callbacks as reactive
1428
- const resolveHandler = isStrictlyReactive(handler)
1429
- ? (handler as () => EventListenerOrEventListenerObject | null | undefined)
1430
- : () => handler
1431
-
1432
- // Cache a single wrapper that resolves the latest handler when invoked
1433
- // @ts-expect-error - using dynamic property for delegation
1434
- el[key] = function (this: any, ...args: any[]) {
1435
- try {
1436
- const fn = resolveHandler()
1437
- callEventHandler(fn as EventListenerOrEventListenerObject, args[0] as Event, el)
1438
- } catch (err) {
1439
- if (!handleError(err, { source: 'event', eventName }, rootRef)) {
1440
- throw err
1441
- }
1442
- }
1443
- }
1516
+ addEventListener(el, eventName, handler, true)
1444
1517
 
1445
1518
  // Cleanup: remove property (no effect needed for static or reactive)
1446
1519
  return () => {
1447
- // @ts-expect-error - using dynamic property for delegation
1448
- el[key] = undefined
1449
- }
1450
- }
1451
-
1452
- // Fallback: Native addEventListener
1453
- // Used for non-delegated events or when options are present
1454
- // Use stricter check - don't misidentify regular callbacks as reactive
1455
- const getHandler = isStrictlyReactive(handler) ? (handler as () => unknown) : () => handler
1456
-
1457
- // Create wrapped handler that resolves reactive handlers
1458
- const wrapped: EventListener = event => {
1459
- try {
1460
- const resolved = getHandler()
1461
- callEventHandler(resolved as EventListenerOrEventListenerObject, event, el)
1462
- } catch (err) {
1463
- if (handleError(err, { source: 'event', eventName }, rootRef)) {
1464
- return
1465
- }
1466
- throw err
1520
+ addEventListener(el, eventName, null, true)
1467
1521
  }
1468
1522
  }
1469
1523
 
1470
- el.addEventListener(eventName, wrapped, options)
1471
- const cleanup = () => el.removeEventListener(eventName, wrapped, options)
1524
+ addEventListener(
1525
+ el,
1526
+ eventName,
1527
+ handler as EventListenerOrEventListenerObject | null | undefined,
1528
+ false,
1529
+ options,
1530
+ )
1531
+ const cleanup = () => removeStoredEventListener(el, eventName, options)
1472
1532
  registerRootCleanup(cleanup)
1473
1533
  return cleanup
1474
1534
  }
@@ -1498,61 +1558,383 @@ export function bindEvent(
1498
1558
  * bindRef(el, () => props.ref)
1499
1559
  * ```
1500
1560
  */
1501
- export function bindRef(el: Element, ref: unknown): Cleanup {
1561
+ export function bindRef(el: Element, ref: unknown, registerCleanup = true): Cleanup {
1502
1562
  if (ref == null) return () => {}
1503
1563
 
1504
- // Handle reactive refs (getters)
1505
1564
  const getRef = isReactive(ref) ? (ref as () => unknown) : () => ref
1565
+ let currentRef: unknown
1506
1566
 
1507
- const applyRef = (refValue: unknown) => {
1567
+ const applyRefValue = (refValue: unknown, value: Element | null) => {
1508
1568
  if (refValue == null) return
1509
-
1510
1569
  if (typeof refValue === 'function') {
1511
- // Callback ref: call with element
1512
- ;(refValue as (el: Element) => void)(el)
1570
+ ;(refValue as (el: Element | null) => void)(value)
1513
1571
  } else if (typeof refValue === 'object' && 'current' in refValue) {
1514
- // Ref object: set current property
1515
- ;(refValue as { current: Element | null }).current = el
1572
+ ;(refValue as { current: Element | null }).current = value
1516
1573
  }
1517
1574
  }
1518
1575
 
1519
- // Apply ref initially
1520
- const initialRef = getRef()
1521
- applyRef(initialRef)
1576
+ const clearCurrentRef = () => {
1577
+ if (currentRef == null) return
1578
+ applyRefValue(currentRef, null)
1579
+ currentRef = undefined
1580
+ }
1522
1581
 
1523
- // For reactive refs, track changes
1582
+ const syncRef = (nextRef: unknown) => {
1583
+ if (nextRef === currentRef) return
1584
+ clearCurrentRef()
1585
+ currentRef = nextRef
1586
+ applyRefValue(currentRef, el)
1587
+ }
1588
+
1589
+ let disposeTracking: Cleanup | undefined
1524
1590
  if (isReactive(ref)) {
1525
- const cleanup = createRenderEffect(() => {
1526
- const currentRef = getRef()
1527
- applyRef(currentRef)
1591
+ disposeTracking = createRenderEffect(() => {
1592
+ syncRef(getRef())
1528
1593
  })
1529
- registerRootCleanup(cleanup)
1594
+ } else {
1595
+ syncRef(getRef())
1596
+ }
1597
+
1598
+ if (registerCleanup) {
1599
+ registerRootCleanup(clearCurrentRef)
1600
+ }
1601
+
1602
+ return () => {
1603
+ disposeTracking?.()
1604
+ clearCurrentRef()
1605
+ }
1606
+ }
1607
+
1608
+ function resolveAssignedChildrenValue(value: FictNode | undefined): FictNode {
1609
+ if (typeof value === 'function') {
1610
+ return isReactive(value) ? (value as () => FictNode)() : null
1611
+ }
1612
+ return value ?? null
1613
+ }
1614
+
1615
+ function resolveAssignedRefValue(value: unknown): unknown {
1616
+ if (isReactive(value)) {
1617
+ return (value as () => unknown)()
1618
+ }
1619
+ return value
1620
+ }
1621
+
1622
+ function createAssignedRefState(
1623
+ node: Element,
1624
+ owner: RootContext | undefined,
1625
+ initialValue: unknown,
1626
+ ): AssignedRefState {
1627
+ const valueSignal = signal<unknown>(initialValue)
1628
+ let currentRef: unknown
1629
+
1630
+ const applyRefValue = (refValue: unknown, value: Element | null) => {
1631
+ if (refValue == null) return
1632
+ if (typeof refValue === 'function') {
1633
+ ;(refValue as (el: Element | null) => void)(value)
1634
+ } else if (typeof refValue === 'object' && 'current' in refValue) {
1635
+ ;(refValue as { current: Element | null }).current = value
1636
+ }
1637
+ }
1638
+
1639
+ const clearCurrentRef = () => {
1640
+ if (currentRef == null) return
1641
+ applyRefValue(currentRef, null)
1642
+ currentRef = undefined
1643
+ }
1644
+
1645
+ const syncRef = (nextRef: unknown) => {
1646
+ if (nextRef === currentRef) return
1647
+ clearCurrentRef()
1648
+ currentRef = nextRef
1649
+ applyRefValue(currentRef, node)
1650
+ }
1530
1651
 
1531
- // On cleanup, null out the ref
1532
- const nullifyCleanup = () => {
1533
- const currentRef = getRef()
1534
- if (currentRef && typeof currentRef === 'object' && 'current' in currentRef) {
1535
- ;(currentRef as { current: Element | null }).current = null
1652
+ const disposeTracking = createRenderEffect(() => {
1653
+ syncRef(resolveAssignedRefValue(valueSignal() as unknown))
1654
+ })
1655
+
1656
+ return {
1657
+ cleanup: () => {
1658
+ disposeTracking()
1659
+ clearCurrentRef()
1660
+ },
1661
+ owner,
1662
+ registeredCleanup: false,
1663
+ value: (next?: unknown) => {
1664
+ valueSignal(next)
1665
+ syncRef(resolveAssignedRefValue(next))
1666
+ },
1667
+ }
1668
+ }
1669
+
1670
+ function bindAssignedChildren(
1671
+ node: Element,
1672
+ getValue: () => FictNode,
1673
+ createElementFn?: CreateElementFn,
1674
+ ): Cleanup {
1675
+ const hostRoot = getCurrentRoot()
1676
+ const createFn = createElementFn ?? registeredCreateElement
1677
+ let currentNodes: Node[] = []
1678
+ let currentText: Text | null = null
1679
+ let currentRoot: RootContext | null = null
1680
+ let initialHydrating = __fictIsHydrating()
1681
+
1682
+ const collectCurrentChildren = (): Node[] => Array.from(node.childNodes)
1683
+
1684
+ const clearCurrentNodes = () => {
1685
+ if (currentRoot) {
1686
+ destroyRoot(currentRoot)
1687
+ currentRoot = null
1688
+ }
1689
+ if (currentNodes.length > 0) {
1690
+ removeNodes(currentNodes)
1691
+ currentNodes = []
1692
+ }
1693
+ currentText = null
1694
+ }
1695
+
1696
+ const setTextNode = (textValue: string, shouldInsert: boolean) => {
1697
+ if (!shouldInsert) {
1698
+ clearCurrentNodes()
1699
+ if (node.childNodes.length > 0) {
1700
+ node.replaceChildren()
1536
1701
  }
1702
+ initialHydrating = false
1703
+ return
1537
1704
  }
1538
- registerRootCleanup(nullifyCleanup)
1539
1705
 
1540
- return () => {
1541
- cleanup()
1542
- nullifyCleanup()
1706
+ if (initialHydrating && isHydratingActive()) {
1707
+ const hydratedNodes = collectCurrentChildren()
1708
+ if (hydratedNodes.length === 1 && hydratedNodes[0]?.nodeType === Node.TEXT_NODE) {
1709
+ const hydratedText = hydratedNodes[0] as Text
1710
+ if (hydratedText.data !== textValue) {
1711
+ hydratedText.data = textValue
1712
+ }
1713
+ currentText = hydratedText
1714
+ currentNodes = [hydratedText]
1715
+ initialHydrating = false
1716
+ return
1717
+ }
1718
+ }
1719
+
1720
+ const textNode = currentText ?? (node.ownerDocument ?? document).createTextNode(textValue)
1721
+ if (textNode.data !== textValue) {
1722
+ textNode.data = textValue
1543
1723
  }
1724
+
1725
+ if (currentNodes.length === 1 && currentNodes[0] === textNode) {
1726
+ currentText = textNode
1727
+ return
1728
+ }
1729
+
1730
+ if (currentRoot) {
1731
+ destroyRoot(currentRoot)
1732
+ currentRoot = null
1733
+ }
1734
+ if (currentNodes.length > 0) {
1735
+ removeNodes(currentNodes)
1736
+ currentNodes = []
1737
+ }
1738
+
1739
+ node.replaceChildren(textNode)
1740
+ currentText = textNode
1741
+ currentNodes = [textNode]
1742
+ initialHydrating = false
1544
1743
  }
1545
1744
 
1546
- // For static refs, register cleanup to null out on unmount
1547
- const cleanup = () => {
1548
- const refValue = getRef()
1549
- if (refValue && typeof refValue === 'object' && 'current' in refValue) {
1550
- ;(refValue as { current: Element | null }).current = null
1745
+ const dispose = createRenderEffect(() => {
1746
+ const value = getValue()
1747
+ const isPrimitive =
1748
+ value == null ||
1749
+ value === false ||
1750
+ typeof value === 'string' ||
1751
+ typeof value === 'number' ||
1752
+ typeof value === 'boolean'
1753
+
1754
+ if (isPrimitive) {
1755
+ const textValue = value == null || value === false ? '' : String(value)
1756
+ const shouldInsert = value != null && value !== false
1757
+ setTextNode(textValue, shouldInsert)
1758
+ return
1551
1759
  }
1760
+
1761
+ clearCurrentNodes()
1762
+
1763
+ const root = createRootContext(hostRoot)
1764
+ const prev = pushRoot(root)
1765
+ let nodes: Node[]
1766
+ let currentHydratedNodes: Node[] | undefined
1767
+ let handledError = false
1768
+ try {
1769
+ const ownerDocument = node.ownerDocument ?? hostRoot?.ownerDocument ?? document
1770
+ const createValue = () => {
1771
+ if (value instanceof Node) {
1772
+ return value
1773
+ }
1774
+ if (Array.isArray(value)) {
1775
+ if (value.every(v => v instanceof Node)) {
1776
+ return value as Node[]
1777
+ }
1778
+ if (createFn) {
1779
+ const mapped: Node[] = []
1780
+ for (const item of value) {
1781
+ mapped.push(...toNodeArray(createFn(item as any), ownerDocument))
1782
+ }
1783
+ return mapped
1784
+ }
1785
+ return ownerDocument.createTextNode(String(value))
1786
+ }
1787
+ return createFn ? createFn(value) : ownerDocument.createTextNode(String(value))
1788
+ }
1789
+
1790
+ const newNode =
1791
+ initialHydrating && isHydratingActive()
1792
+ ? withHydration(node, () => createValue())
1793
+ : createValue()
1794
+
1795
+ nodes = toNodeArray(newNode, ownerDocument)
1796
+ if (root.suspended) {
1797
+ handledError = true
1798
+ destroyRoot(root)
1799
+ return
1800
+ }
1801
+
1802
+ if (initialHydrating) {
1803
+ const hydratedNodes = collectCurrentChildren()
1804
+ const reuseHydratedNodes =
1805
+ hydratedNodes.length === nodes.length &&
1806
+ nodes.every((candidate, index) => candidate === hydratedNodes[index])
1807
+ if (reuseHydratedNodes) {
1808
+ currentHydratedNodes = hydratedNodes
1809
+ } else {
1810
+ node.replaceChildren(...nodes)
1811
+ }
1812
+ } else {
1813
+ node.replaceChildren(...nodes)
1814
+ }
1815
+ } catch (err) {
1816
+ if (handleSuspend(err as any, root)) {
1817
+ handledError = true
1818
+ destroyRoot(root)
1819
+ return
1820
+ }
1821
+ if (handleError(err, { source: 'renderChild' }, root)) {
1822
+ handledError = true
1823
+ destroyRoot(root)
1824
+ return
1825
+ }
1826
+ throw err
1827
+ } finally {
1828
+ popRoot(prev)
1829
+ if (!handledError) {
1830
+ flushOnMount(root)
1831
+ }
1832
+ }
1833
+
1834
+ currentRoot = root
1835
+ currentNodes = currentHydratedNodes ?? nodes
1836
+ initialHydrating = false
1837
+ })
1838
+
1839
+ return () => {
1840
+ dispose()
1841
+ clearCurrentNodes()
1552
1842
  }
1553
- registerRootCleanup(cleanup)
1843
+ }
1554
1844
 
1555
- return cleanup
1845
+ function updateChildrenBinding(
1846
+ node: Element,
1847
+ value: FictNode | undefined,
1848
+ createElementFn?: CreateElementFn,
1849
+ ): void {
1850
+ const host = node as {
1851
+ [CHILDREN_BINDING_CACHE]?: ChildrenBindingState
1852
+ }
1853
+ const createFn = createElementFn ?? registeredCreateElement
1854
+ const owner = getCurrentRoot()
1855
+ let state = host[CHILDREN_BINDING_CACHE]
1856
+
1857
+ if (state && state.owner !== owner) {
1858
+ state.cleanup?.()
1859
+ state.cleanup = undefined
1860
+ delete host[CHILDREN_BINDING_CACHE]
1861
+ state = undefined
1862
+ }
1863
+
1864
+ if (!state) {
1865
+ const valueSignal = signal<FictNode | undefined>(value)
1866
+ const cleanup = bindAssignedChildren(
1867
+ node,
1868
+ () => resolveAssignedChildrenValue(valueSignal() as FictNode | undefined),
1869
+ createFn,
1870
+ )
1871
+ const nextState: ChildrenBindingState = {
1872
+ cleanup,
1873
+ owner,
1874
+ value: valueSignal,
1875
+ }
1876
+ state = nextState
1877
+ host[CHILDREN_BINDING_CACHE] = nextState
1878
+ registerRootCleanup(() => {
1879
+ state?.cleanup?.()
1880
+ if (state) {
1881
+ state.cleanup = undefined
1882
+ }
1883
+ if (host[CHILDREN_BINDING_CACHE] === state) {
1884
+ delete host[CHILDREN_BINDING_CACHE]
1885
+ }
1886
+ })
1887
+ return
1888
+ }
1889
+
1890
+ state.value(value)
1891
+ }
1892
+
1893
+ function updateAssignedRefBinding(node: Element, value: unknown): void {
1894
+ const host = node as {
1895
+ [REF_ASSIGN_CACHE]?: AssignedRefState
1896
+ }
1897
+ const owner = getCurrentRoot()
1898
+ let state = host[REF_ASSIGN_CACHE]
1899
+
1900
+ if (state && state.owner !== owner) {
1901
+ state.cleanup?.()
1902
+ state.cleanup = undefined
1903
+ delete host[REF_ASSIGN_CACHE]
1904
+ state = undefined
1905
+ }
1906
+
1907
+ if (!state && value == null) {
1908
+ return
1909
+ }
1910
+
1911
+ if (!state) {
1912
+ state = createAssignedRefState(node, owner, value)
1913
+ host[REF_ASSIGN_CACHE] = state
1914
+ }
1915
+
1916
+ state.value?.(value)
1917
+
1918
+ if (value == null) {
1919
+ if (!state.registeredCleanup) {
1920
+ state.cleanup?.()
1921
+ state.cleanup = undefined
1922
+ delete host[REF_ASSIGN_CACHE]
1923
+ }
1924
+ return
1925
+ }
1926
+
1927
+ if (!state.registeredCleanup && getCurrentRoot()) {
1928
+ state.registeredCleanup = true
1929
+ registerRootCleanup(() => {
1930
+ state.cleanup?.()
1931
+ state.cleanup = undefined
1932
+ state.value = undefined
1933
+ if (host[REF_ASSIGN_CACHE] === state) {
1934
+ delete host[REF_ASSIGN_CACHE]
1935
+ }
1936
+ })
1937
+ }
1556
1938
  }
1557
1939
 
1558
1940
  // ============================================================================
@@ -1590,27 +1972,15 @@ export function spread(
1590
1972
  return next
1591
1973
  }
1592
1974
 
1593
- // Handle children if not skipped
1594
- if (!skipChildren) {
1595
- createRenderEffect(() => {
1596
- const nextProps = resolveProps()
1597
- if ('children' in nextProps) {
1598
- prevProps.children = nextProps.children
1599
- }
1600
- })
1601
- }
1602
-
1603
1975
  // Handle ref
1604
- createRenderEffect(() => {
1605
- const nextProps = resolveProps()
1606
- if (typeof nextProps.ref === 'function') {
1607
- ;(nextProps.ref as (el: Element) => void)(node)
1608
- }
1609
- })
1976
+ bindRef(
1977
+ node,
1978
+ (typeof props === 'function' ? () => resolveProps().ref : resolveProps().ref) ?? null,
1979
+ )
1610
1980
 
1611
1981
  // Handle all other props
1612
1982
  createRenderEffect(() => {
1613
- assign(node, resolveProps(), isSVG, true, prevProps, true, excludedProps)
1983
+ assign(node, resolveProps(), isSVG, skipChildren, prevProps, true, excludedProps)
1614
1984
  })
1615
1985
 
1616
1986
  return prevProps
@@ -1642,7 +2012,13 @@ export function assign(
1642
2012
  for (const prop in prevProps) {
1643
2013
  if (excludedProps?.has(prop)) continue
1644
2014
  if (!(prop in props)) {
1645
- if (prop === 'children') continue
2015
+ if (prop === 'children') {
2016
+ if (!skipChildren) {
2017
+ updateChildrenBinding(node, undefined)
2018
+ prevProps.children = undefined
2019
+ }
2020
+ continue
2021
+ }
1646
2022
  prevProps[prop] = assignProp(node, prop, null, prevProps[prop], isSVG, skipRef, props)
1647
2023
  }
1648
2024
  }
@@ -1650,14 +2026,14 @@ export function assign(
1650
2026
  // Set or update props
1651
2027
  for (const prop in props) {
1652
2028
  if (excludedProps?.has(prop)) continue
2029
+ const value = props[prop]
1653
2030
  if (prop === 'children') {
1654
2031
  if (!skipChildren) {
1655
- // Handle children insertion
2032
+ updateChildrenBinding(node, value as FictNode | undefined)
1656
2033
  prevProps.children = props.children
1657
2034
  }
1658
2035
  continue
1659
2036
  }
1660
- const value = props[prop]
1661
2037
  prevProps[prop] = assignProp(node, prop, value, prevProps[prop], isSVG, skipRef, props)
1662
2038
  }
1663
2039
  }
@@ -1690,8 +2066,8 @@ function assignProp(
1690
2066
 
1691
2067
  // Ref handling
1692
2068
  if (prop === 'ref') {
1693
- if (!skipRef && typeof value === 'function') {
1694
- ;(value as (el: Element) => void)(node)
2069
+ if (!skipRef) {
2070
+ updateAssignedRefBinding(node, value)
1695
2071
  }
1696
2072
  return value
1697
2073
  }
@@ -1703,16 +2079,35 @@ function assignProp(
1703
2079
  node.setAttribute(prop, value)
1704
2080
  return value
1705
2081
  }
1706
- if (prev && typeof prev !== 'string') node.removeEventListener(eventName, prev as EventListener)
1707
- if (value) node.addEventListener(eventName, value as EventListener)
2082
+ if (prev && typeof prev !== 'string') removeStoredEventListener(node, eventName)
2083
+ addEventListener(
2084
+ node,
2085
+ eventName,
2086
+ value as
2087
+ | EventListenerOrEventListenerObject
2088
+ | [EventListenerOrEventListenerObject, unknown]
2089
+ | null
2090
+ | undefined,
2091
+ false,
2092
+ )
1708
2093
  return value
1709
2094
  }
1710
2095
 
1711
2096
  // Capture event handling: oncapture:eventname
1712
2097
  if (prop.slice(0, 10) === 'oncapture:') {
1713
2098
  const eventName = prop.slice(10)
1714
- if (prev) node.removeEventListener(eventName, prev as EventListener, true)
1715
- if (value) node.addEventListener(eventName, value as EventListener, true)
2099
+ if (prev) removeStoredEventListener(node, eventName, true)
2100
+ addEventListener(
2101
+ node,
2102
+ eventName,
2103
+ value as
2104
+ | EventListenerOrEventListenerObject
2105
+ | [EventListenerOrEventListenerObject, unknown]
2106
+ | null
2107
+ | undefined,
2108
+ false,
2109
+ true,
2110
+ )
1716
2111
  return value
1717
2112
  }
1718
2113
 
@@ -1721,20 +2116,20 @@ function assignProp(
1721
2116
  const eventName = prop.slice(2).toLowerCase()
1722
2117
  const shouldDelegate = DelegatedEvents.has(eventName)
1723
2118
  if (!shouldDelegate && prev) {
1724
- if (Array.isArray(prev)) {
1725
- removeStoredTupleEventListener(node, eventName)
1726
- } else {
1727
- node.removeEventListener(eventName, prev as EventListener)
1728
- }
2119
+ removeStoredEventListener(node, eventName)
1729
2120
  }
1730
2121
  if (shouldDelegate || value) {
1731
2122
  addEventListener(
1732
2123
  node,
1733
2124
  eventName,
1734
- value as EventListener | [EventListener, unknown] | null | undefined,
2125
+ value as
2126
+ | EventListenerOrEventListenerObject
2127
+ | [EventListenerOrEventListenerObject, unknown]
2128
+ | null
2129
+ | undefined,
1735
2130
  shouldDelegate,
2131
+ false,
1736
2132
  )
1737
- if (shouldDelegate) delegateEvents([eventName])
1738
2133
  }
1739
2134
  return value
1740
2135
  }