@fictjs/runtime 0.0.8 → 0.0.10

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.
@@ -11,6 +11,7 @@ import {
11
11
  createRootContext,
12
12
  destroyRoot,
13
13
  flushOnMount,
14
+ getCurrentRoot,
14
15
  popRoot,
15
16
  pushRoot,
16
17
  type RootContext,
@@ -18,7 +19,7 @@ import {
18
19
  import { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'
19
20
  import reconcileArrays from './reconcile'
20
21
  import { batch } from './scheduler'
21
- import { createSignal, setActiveSub, type Signal } from './signal'
22
+ import { createSignal, flush, setActiveSub, type Signal } from './signal'
22
23
  import type { FictNode } from './types'
23
24
 
24
25
  // Re-export shared DOM helpers for compiler-generated code
@@ -200,7 +201,7 @@ function removeBlockRange(block: MarkerBlock): void {
200
201
  }
201
202
  }
202
203
 
203
- function createVersionedSignalAccessor<T>(initialValue: T): Signal<T> {
204
+ export function createVersionedSignalAccessor<T>(initialValue: T): Signal<T> {
204
205
  let current = initialValue
205
206
  let version = 0
206
207
  const track = createSignal(version)
@@ -243,6 +244,16 @@ export function createKeyedListContainer<T = unknown>(): KeyedListContainer<T> {
243
244
  container.nextBlocks.clear()
244
245
 
245
246
  // Remove nodes (including markers)
247
+ // Check if markers are still in DOM before using Range
248
+ if (!startMarker.parentNode || !endMarker.parentNode) {
249
+ // Markers already removed, nothing to do
250
+ container.currentNodes = []
251
+ container.nextNodes = []
252
+ container.orderedBlocks.length = 0
253
+ container.nextOrderedBlocks.length = 0
254
+ container.orderedIndexByKey.clear()
255
+ return
256
+ }
246
257
  const range = document.createRange()
247
258
  range.setStartBefore(startMarker)
248
259
  range.setEndAfter(endMarker)
@@ -292,6 +303,7 @@ export function createKeyedBlock<T>(
292
303
  index: number,
293
304
  render: (item: Signal<T>, index: Signal<number>, key: string | number) => Node[],
294
305
  needsIndex = true,
306
+ hostRoot?: RootContext,
295
307
  ): KeyedBlock<T> {
296
308
  // Use versioned signal for all item types; avoid diffing proxy overhead for objects
297
309
  const itemSig = createVersionedSignalAccessor(item)
@@ -303,7 +315,7 @@ export function createKeyedBlock<T>(
303
315
  index = next as number
304
316
  return index
305
317
  }) as Signal<number>)
306
- const root = createRootContext()
318
+ const root = createRootContext(hostRoot)
307
319
  const prevRoot = pushRoot(root)
308
320
 
309
321
  // Isolate child effects from the outer effect (e.g., performDiff) by clearing activeSub.
@@ -327,7 +339,6 @@ export function createKeyedBlock<T>(
327
339
  } finally {
328
340
  setActiveSub(prevSub)
329
341
  popRoot(prevRoot)
330
- flushOnMount(root)
331
342
  }
332
343
 
333
344
  return {
@@ -399,18 +410,34 @@ function createFineGrainedKeyedList<T>(
399
410
  needsIndex: boolean,
400
411
  ): KeyedListBinding {
401
412
  const container = createKeyedListContainer<T>()
413
+ const hostRoot = getCurrentRoot()
402
414
  const fragment = document.createDocumentFragment()
403
415
  fragment.append(container.startMarker, container.endMarker)
404
- let pendingItems: T[] | null = null
405
416
  let disposed = false
417
+ let effectDispose: (() => void) | undefined
418
+ let connectObserver: MutationObserver | null = null
419
+ let effectStarted = false
420
+ let startScheduled = false
421
+
422
+ const getConnectedParent = (): (ParentNode & Node) | null => {
423
+ const endParent = container.endMarker.parentNode
424
+ const startParent = container.startMarker.parentNode
425
+ if (
426
+ endParent &&
427
+ startParent &&
428
+ endParent === startParent &&
429
+ (endParent as Node).nodeType !== 11
430
+ ) {
431
+ return endParent as ParentNode & Node
432
+ }
433
+ return null
434
+ }
406
435
 
407
436
  const performDiff = () => {
408
437
  if (disposed) return
409
-
438
+ const parent = getConnectedParent()
439
+ if (!parent) return
410
440
  batch(() => {
411
- const newItems = pendingItems || getItems()
412
- pendingItems = null
413
-
414
441
  const oldBlocks = container.blocks
415
442
  const newBlocks = container.nextBlocks
416
443
  const prevOrderedBlocks = container.orderedBlocks
@@ -419,27 +446,20 @@ function createFineGrainedKeyedList<T>(
419
446
  newBlocks.clear()
420
447
  nextOrderedBlocks.length = 0
421
448
  orderedIndexByKey.clear()
422
-
423
- const endParent = container.endMarker.parentNode
424
- const startParent = container.startMarker.parentNode
425
- const parent =
426
- endParent && startParent && endParent === startParent && (endParent as Node).isConnected
427
- ? (endParent as ParentNode & Node)
428
- : null
429
-
430
- // If markers aren't mounted yet, store items and retry in microtask
431
- if (!parent) {
432
- pendingItems = newItems
433
- queueMicrotask(performDiff)
434
- return
435
- }
449
+ const createdBlocks: KeyedBlock<T>[] = []
450
+ const newItems = getItems()
436
451
 
437
452
  if (newItems.length === 0) {
438
453
  if (oldBlocks.size > 0) {
454
+ // Destroy all block roots first
439
455
  for (const block of oldBlocks.values()) {
440
456
  destroyRoot(block.root)
441
- removeNodes(block.nodes)
442
457
  }
458
+ // Use Range.deleteContents for efficient bulk DOM removal
459
+ const range = document.createRange()
460
+ range.setStartAfter(container.startMarker)
461
+ range.setEndBefore(container.endMarker)
462
+ range.deleteContents()
443
463
  }
444
464
  oldBlocks.clear()
445
465
  newBlocks.clear()
@@ -459,8 +479,9 @@ function createFineGrainedKeyedList<T>(
459
479
  // Phase 1: Build new blocks map (reuse or create)
460
480
  newItems.forEach((item, index) => {
461
481
  const key = keyFn(item, index)
462
- const existed = oldBlocks.has(key)
482
+ // Micro-optimization: single Map.get instead of has+get
463
483
  let block = oldBlocks.get(key)
484
+ const existed = block !== undefined
464
485
 
465
486
  if (block) {
466
487
  if (block.rawItem !== item) {
@@ -473,45 +494,30 @@ function createFineGrainedKeyedList<T>(
473
494
  }
474
495
  }
475
496
 
476
- // If newBlocks already has this key (duplicate key case), clean up the previous block
477
- const existingBlock = newBlocks.get(key)
478
- if (existingBlock && existingBlock !== block) {
479
- destroyRoot(existingBlock.root)
480
- removeNodes(existingBlock.nodes)
481
- }
482
-
483
497
  if (block) {
498
+ // Reusing existing block from oldBlocks
484
499
  newBlocks.set(key, block)
485
500
  oldBlocks.delete(key)
486
501
  } else {
502
+ // If newBlocks already has this key (duplicate key case), clean up the previous block
487
503
  const existingBlock = newBlocks.get(key)
488
504
  if (existingBlock) {
489
505
  destroyRoot(existingBlock.root)
490
506
  removeNodes(existingBlock.nodes)
491
507
  }
492
-
493
508
  // Create new block
494
- block = createKeyedBlock(key, item, index, renderItem, needsIndex)
509
+ block = createKeyedBlock(key, item, index, renderItem, needsIndex, hostRoot)
510
+ createdBlocks.push(block)
495
511
  }
496
512
 
497
- const resolvedBlock = block!
513
+ const resolvedBlock = block
498
514
 
499
515
  newBlocks.set(key, resolvedBlock)
500
516
 
517
+ // Micro-optimization: single Map.get instead of checking position multiple times
501
518
  const position = orderedIndexByKey.get(key)
502
519
  if (position !== undefined) {
503
520
  appendCandidate = false
504
- }
505
- if (appendCandidate) {
506
- if (index < prevCount) {
507
- if (!prevOrderedBlocks[index] || prevOrderedBlocks[index]!.key !== key) {
508
- appendCandidate = false
509
- }
510
- } else if (existed) {
511
- appendCandidate = false
512
- }
513
- }
514
- if (position !== undefined) {
515
521
  const prior = nextOrderedBlocks[position]
516
522
  if (prior && prior !== resolvedBlock) {
517
523
  destroyRoot(prior.root)
@@ -519,6 +525,15 @@ function createFineGrainedKeyedList<T>(
519
525
  }
520
526
  nextOrderedBlocks[position] = resolvedBlock
521
527
  } else {
528
+ if (appendCandidate) {
529
+ if (index < prevCount) {
530
+ if (!prevOrderedBlocks[index] || prevOrderedBlocks[index]!.key !== key) {
531
+ appendCandidate = false
532
+ }
533
+ } else if (existed) {
534
+ appendCandidate = false
535
+ }
536
+ }
522
537
  orderedIndexByKey.set(key, nextOrderedBlocks.length)
523
538
  nextOrderedBlocks.push(resolvedBlock)
524
539
  }
@@ -555,6 +570,11 @@ function createFineGrainedKeyedList<T>(
555
570
  container.nextBlocks = oldBlocks
556
571
  container.orderedBlocks = nextOrderedBlocks
557
572
  container.nextOrderedBlocks = prevOrderedBlocks
573
+ for (const block of createdBlocks) {
574
+ if (newBlocks.get(block.key) === block) {
575
+ flushOnMount(block.root)
576
+ }
577
+ }
558
578
  return
559
579
  }
560
580
 
@@ -595,24 +615,95 @@ function createFineGrainedKeyedList<T>(
595
615
  container.nextBlocks = oldBlocks
596
616
  container.orderedBlocks = nextOrderedBlocks
597
617
  container.nextOrderedBlocks = prevOrderedBlocks
618
+ for (const block of createdBlocks) {
619
+ if (newBlocks.get(block.key) === block) {
620
+ flushOnMount(block.root)
621
+ }
622
+ }
598
623
  })
599
624
  }
600
625
 
601
- const effectDispose = createRenderEffect(performDiff)
626
+ const disconnectObserver = () => {
627
+ connectObserver?.disconnect()
628
+ connectObserver = null
629
+ }
630
+
631
+ const ensureEffectStarted = (): boolean => {
632
+ if (disposed || effectStarted) return effectStarted
633
+ const parent = getConnectedParent()
634
+ if (!parent) return false
635
+ const start = () => {
636
+ effectDispose = createRenderEffect(performDiff)
637
+ effectStarted = true
638
+ }
639
+ if (hostRoot) {
640
+ const prev = pushRoot(hostRoot)
641
+ try {
642
+ start()
643
+ } finally {
644
+ popRoot(prev)
645
+ }
646
+ } else {
647
+ start()
648
+ }
649
+ return true
650
+ }
651
+
652
+ const waitForConnection = () => {
653
+ if (connectObserver || typeof MutationObserver === 'undefined') return
654
+ connectObserver = new MutationObserver(() => {
655
+ if (disposed) return
656
+ if (getConnectedParent()) {
657
+ disconnectObserver()
658
+ if (ensureEffectStarted()) {
659
+ flush()
660
+ }
661
+ }
662
+ })
663
+ connectObserver.observe(document, { childList: true, subtree: true })
664
+ }
665
+
666
+ const scheduleStart = () => {
667
+ if (startScheduled || disposed || effectStarted) return
668
+ startScheduled = true
669
+ const run = () => {
670
+ startScheduled = false
671
+ if (!ensureEffectStarted()) {
672
+ waitForConnection()
673
+ }
674
+ }
675
+ if (typeof queueMicrotask === 'function') {
676
+ queueMicrotask(run)
677
+ } else {
678
+ Promise.resolve()
679
+ .then(run)
680
+ .catch(() => undefined)
681
+ }
682
+ }
683
+
684
+ scheduleStart()
602
685
 
603
686
  return {
604
- marker: fragment,
687
+ get marker() {
688
+ scheduleStart()
689
+ return fragment
690
+ },
605
691
  startMarker: container.startMarker,
606
692
  endMarker: container.endMarker,
607
693
  // Flush pending items - call after markers are inserted into DOM
608
694
  flush: () => {
609
- if (pendingItems !== null) {
610
- performDiff()
695
+ if (disposed) return
696
+ scheduleStart()
697
+ if (ensureEffectStarted()) {
698
+ flush()
699
+ } else {
700
+ waitForConnection()
611
701
  }
612
702
  },
613
703
  dispose: () => {
614
704
  disposed = true
615
705
  effectDispose?.()
706
+ disconnectObserver()
616
707
  container.dispose()
617
708
  },
618
709
  }
package/src/ref.ts CHANGED
@@ -20,6 +20,6 @@ import type { RefObject } from './types'
20
20
  * }
21
21
  * ```
22
22
  */
23
- export function createRef<T extends HTMLElement = HTMLElement>(): RefObject<T> {
23
+ export function createRef<T extends Element = HTMLElement>(): RefObject<T> {
24
24
  return { current: null }
25
25
  }
package/src/signal.ts CHANGED
@@ -1057,10 +1057,20 @@ export function endBatch(): void {
1057
1057
  */
1058
1058
  export function batch<T>(fn: () => T): T {
1059
1059
  ++batchDepth
1060
+ let _error: unknown
1061
+ let hasError = false
1060
1062
  try {
1061
1063
  return fn()
1064
+ } catch (e) {
1065
+ _error = e
1066
+ hasError = true
1067
+ throw e
1062
1068
  } finally {
1063
- if (--batchDepth === 0) flush()
1069
+ --batchDepth
1070
+ // Only flush if no error occurred to avoid interfering with error propagation
1071
+ if (!hasError && batchDepth === 0) {
1072
+ flush()
1073
+ }
1064
1074
  }
1065
1075
  }
1066
1076
  /**
@@ -1253,7 +1263,7 @@ export function createSelector<T>(
1253
1263
  let current = source()
1254
1264
  const observers = new Map<T, SignalAccessor<boolean>>()
1255
1265
 
1256
- effect(() => {
1266
+ const dispose = effect(() => {
1257
1267
  const next = source()
1258
1268
  if (equalityFn(current, next)) return
1259
1269
 
@@ -1265,6 +1275,10 @@ export function createSelector<T>(
1265
1275
 
1266
1276
  current = next
1267
1277
  })
1278
+ registerRootCleanup(() => {
1279
+ dispose()
1280
+ observers.clear()
1281
+ })
1268
1282
 
1269
1283
  return (key: T) => {
1270
1284
  let sig = observers.get(key)
package/src/store.ts CHANGED
@@ -2,6 +2,7 @@ import { signal, batch, type SignalAccessor } from './signal'
2
2
 
3
3
  const PROXY = Symbol('fict:store-proxy')
4
4
  const TARGET = Symbol('fict:store-target')
5
+ const ITERATE_KEY = Symbol('fict:iterate')
5
6
 
6
7
  // ============================================================================
7
8
  // Store (Deep Proxy)
@@ -57,22 +58,43 @@ function wrap<T>(value: T): T {
57
58
  // Recursively wrap objects
58
59
  return wrap(value)
59
60
  },
61
+ has(target, prop) {
62
+ const result = Reflect.has(target, prop)
63
+ track(target, prop)
64
+ return result
65
+ },
66
+ ownKeys(target) {
67
+ track(target, ITERATE_KEY)
68
+ return Reflect.ownKeys(target)
69
+ },
70
+ getOwnPropertyDescriptor(target, prop) {
71
+ track(target, prop)
72
+ return Reflect.getOwnPropertyDescriptor(target, prop)
73
+ },
60
74
  set(target, prop, value, receiver) {
61
75
  if (prop === PROXY || prop === TARGET) return false
62
76
 
77
+ const hadKey = Object.prototype.hasOwnProperty.call(target, prop)
63
78
  const oldValue = Reflect.get(target, prop, receiver)
64
79
  if (oldValue === value) return true
65
80
 
66
81
  const result = Reflect.set(target, prop, value, receiver)
67
82
  if (result) {
68
83
  trigger(target, prop)
84
+ if (!hadKey) {
85
+ trigger(target, ITERATE_KEY)
86
+ }
69
87
  }
70
88
  return result
71
89
  },
72
90
  deleteProperty(target, prop) {
91
+ const hadKey = Object.prototype.hasOwnProperty.call(target, prop)
73
92
  const result = Reflect.deleteProperty(target, prop)
74
93
  if (result) {
75
94
  trigger(target, prop)
95
+ if (hadKey) {
96
+ trigger(target, ITERATE_KEY)
97
+ }
76
98
  }
77
99
  return result
78
100
  },
@@ -99,7 +121,9 @@ function track(target: object, prop: string | symbol) {
99
121
 
100
122
  let s = signals.get(prop)
101
123
  if (!s) {
102
- s = signal(getLastValue(target, prop))
124
+ const initial =
125
+ prop === ITERATE_KEY ? (Reflect.ownKeys(target).length as number) : getLastValue(target, prop)
126
+ s = signal(initial)
103
127
  signals.set(prop, s)
104
128
  }
105
129
  s() // subscribe
@@ -110,7 +134,11 @@ function trigger(target: object, prop: string | symbol) {
110
134
  if (signals) {
111
135
  const s = signals.get(prop)
112
136
  if (s) {
113
- s(getLastValue(target, prop)) // notify with new value
137
+ if (prop === ITERATE_KEY) {
138
+ s(Reflect.ownKeys(target).length)
139
+ } else {
140
+ s(getLastValue(target, prop)) // notify with new value
141
+ }
114
142
  }
115
143
  }
116
144
  }
package/src/types.ts CHANGED
@@ -128,15 +128,15 @@ export interface DOMEventHandlers {
128
128
  // ============================================================================
129
129
 
130
130
  /** Ref callback type */
131
- export type RefCallback<T extends HTMLElement = HTMLElement> = (element: T) => void
131
+ export type RefCallback<T extends Element = HTMLElement> = (element: T) => void
132
132
 
133
133
  /** Ref object type (for future use with createRef) */
134
- export interface RefObject<T extends HTMLElement = HTMLElement> {
134
+ export interface RefObject<T extends Element = HTMLElement> {
135
135
  current: T | null
136
136
  }
137
137
 
138
138
  /** Ref type that can be either callback or object */
139
- export type Ref<T extends HTMLElement = HTMLElement> = RefCallback<T> | RefObject<T>
139
+ export type Ref<T extends Element = HTMLElement> = RefCallback<T> | RefObject<T>
140
140
 
141
141
  // ============================================================================
142
142
  // Style Types