@fictjs/runtime 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/runtime",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Fict reactive runtime",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/binding.ts CHANGED
@@ -1124,7 +1124,9 @@ export function bindEvent(
1124
1124
  const fn = resolveHandler()
1125
1125
  callEventHandler(fn as EventListenerOrEventListenerObject, args[0] as Event, el)
1126
1126
  } catch (err) {
1127
- handleError(err, { source: 'event', eventName }, rootRef)
1127
+ if (!handleError(err, { source: 'event', eventName }, rootRef)) {
1128
+ throw err
1129
+ }
1128
1130
  }
1129
1131
  }
1130
1132
 
package/src/dev.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {}
2
+
3
+ declare global {
4
+ const __DEV__: boolean | undefined
5
+ }
package/src/dom.ts CHANGED
@@ -54,6 +54,10 @@ type NamespaceContext = 'svg' | 'mathml' | null
54
54
 
55
55
  const SVG_NS = 'http://www.w3.org/2000/svg'
56
56
  const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'
57
+ const isDev =
58
+ typeof __DEV__ !== 'undefined'
59
+ ? __DEV__
60
+ : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
57
61
 
58
62
  // ============================================================================
59
63
  // Main Render Function
@@ -431,10 +435,17 @@ function applyRef(el: Element, value: unknown): void {
431
435
  refFn(el)
432
436
 
433
437
  // Match React behavior: call ref(null) on unmount
434
- if (getCurrentRoot()) {
438
+ const root = getCurrentRoot()
439
+ if (root) {
435
440
  registerRootCleanup(() => {
436
441
  refFn(null)
437
442
  })
443
+ } else if (isDev) {
444
+ console.warn(
445
+ '[fict] Ref applied outside of a root context. ' +
446
+ 'The ref cleanup (setting to null) will not run automatically. ' +
447
+ 'Consider using createRoot() or ensure the element is created within a component.',
448
+ )
438
449
  }
439
450
  } else if (value && typeof value === 'object' && 'current' in value) {
440
451
  // Object ref
@@ -442,10 +453,17 @@ function applyRef(el: Element, value: unknown): void {
442
453
  refObj.current = el
443
454
 
444
455
  // Auto-cleanup on unmount
445
- if (getCurrentRoot()) {
456
+ const root = getCurrentRoot()
457
+ if (root) {
446
458
  registerRootCleanup(() => {
447
459
  refObj.current = null
448
460
  })
461
+ } else if (isDev) {
462
+ console.warn(
463
+ '[fict] Ref applied outside of a root context. ' +
464
+ 'The ref cleanup (setting to null) will not run automatically. ' +
465
+ 'Consider using createRoot() or ensure the element is created within a component.',
466
+ )
449
467
  }
450
468
  }
451
469
  }
@@ -72,13 +72,22 @@ export function ErrorBoundary(props: ErrorBoundaryProps): FictNode {
72
72
  if (renderingFallback) {
73
73
  throw err
74
74
  }
75
+ // nested errors. If fallback rendering also throws, we should NOT reset
76
+ // the flag until we're sure no more recursion is happening.
75
77
  renderingFallback = true
76
78
  try {
77
79
  renderValue(toView(err))
78
- } finally {
80
+ // Only reset if successful - if renderValue threw, we want to keep
81
+ // renderingFallback = true to prevent infinite recursion
79
82
  renderingFallback = false
83
+ props.onError?.(err)
84
+ } catch (fallbackErr) {
85
+ // Fallback rendering failed - keep renderingFallback = true
86
+ // to prevent further attempts, then rethrow
87
+ // If fallback fails, report both errors
88
+ props.onError?.(err)
89
+ throw fallbackErr
80
90
  }
81
- props.onError?.(err)
82
91
  return
83
92
  }
84
93
  popRoot(prev)
package/src/lifecycle.ts CHANGED
@@ -237,7 +237,10 @@ export function handleError(err: unknown, info?: ErrorInfo, startRoot?: RootCont
237
237
  }
238
238
  }
239
239
  }
240
- throw error
240
+ // The caller (e.g., runCleanupList) can decide whether to rethrow.
241
+ // This makes the API consistent: handleError always returns a boolean
242
+ // indicating whether the error was handled.
243
+ return false
241
244
  }
242
245
 
243
246
  export function handleSuspend(
@@ -19,12 +19,17 @@ import {
19
19
  import { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'
20
20
  import reconcileArrays from './reconcile'
21
21
  import { batch } from './scheduler'
22
- import { createSignal, flush, setActiveSub, type Signal } from './signal'
22
+ import { createSignal, effectScope, flush, setActiveSub, type Signal } from './signal'
23
23
  import type { FictNode } from './types'
24
24
 
25
25
  // Re-export shared DOM helpers for compiler-generated code
26
26
  export { insertNodesBefore, removeNodes, toNodeArray }
27
27
 
28
+ const isDev =
29
+ typeof __DEV__ !== 'undefined'
30
+ ? __DEV__
31
+ : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
32
+
28
33
  // ============================================================================
29
34
  // Types
30
35
  // ============================================================================
@@ -201,6 +206,9 @@ function removeBlockRange(block: MarkerBlock): void {
201
206
  }
202
207
  }
203
208
 
209
+ // Number.MAX_SAFE_INTEGER is 2^53 - 1, but we reset earlier to avoid any precision issues
210
+ const MAX_SAFE_VERSION = 0x1fffffffffffff // 2^53 - 1
211
+
204
212
  export function createVersionedSignalAccessor<T>(initialValue: T): Signal<T> {
205
213
  let current = initialValue
206
214
  let version = 0
@@ -212,7 +220,8 @@ export function createVersionedSignalAccessor<T>(initialValue: T): Signal<T> {
212
220
  return current
213
221
  }
214
222
  current = value as T
215
- version++
223
+ // This is safe because we only care about version changes, not absolute values
224
+ version = version >= MAX_SAFE_VERSION ? 1 : version + 1
216
225
  track(version)
217
226
  }
218
227
 
@@ -317,24 +326,36 @@ export function createKeyedBlock<T>(
317
326
  }) as Signal<number>)
318
327
  const root = createRootContext(hostRoot)
319
328
  const prevRoot = pushRoot(root)
329
+ // maintaining proper cleanup chain. The scope will be disposed when
330
+ // the root is destroyed, ensuring nested effects are properly cleaned up.
331
+ let nodes: Node[] = []
332
+ let scopeDispose: (() => void) | undefined
320
333
 
321
- // Isolate child effects from the outer effect (e.g., performDiff) by clearing activeSub.
322
- // This prevents child effects from being purged when the outer effect re-runs.
334
+ // First, isolate from parent effect to prevent child effects from being
335
+ // purged when the outer effect (e.g., performDiff) re-runs
323
336
  const prevSub = setActiveSub(undefined)
324
337
 
325
- let nodes: Node[] = []
326
338
  try {
327
- const rendered = render(itemSig, indexSig, key)
328
- // If render returns real DOM nodes/arrays, preserve them to avoid
329
- // reparenting side-effects (tests may pre-insert them).
330
- if (
331
- rendered instanceof Node ||
332
- (Array.isArray(rendered) && rendered.every(n => n instanceof Node))
333
- ) {
334
- nodes = toNodeArray(rendered)
335
- } else {
336
- const element = createElement(rendered as unknown as FictNode)
337
- nodes = toNodeArray(element)
339
+ // Create an effectScope that will track all effects created during render
340
+ scopeDispose = effectScope(() => {
341
+ const rendered = render(itemSig, indexSig, key)
342
+ // If render returns real DOM nodes/arrays, preserve them to avoid
343
+ // reparenting side-effects (tests may pre-insert them).
344
+ if (
345
+ rendered instanceof Node ||
346
+ (Array.isArray(rendered) && rendered.every(n => n instanceof Node))
347
+ ) {
348
+ nodes = toNodeArray(rendered)
349
+ } else {
350
+ const element = createElement(rendered as unknown as FictNode)
351
+ nodes = toNodeArray(element)
352
+ }
353
+ })
354
+
355
+ // Register the scope cleanup with the root so effects are cleaned up
356
+ // when the block is destroyed
357
+ if (scopeDispose) {
358
+ root.cleanups.push(scopeDispose)
338
359
  }
339
360
  } finally {
340
361
  setActiveSub(prevSub)
@@ -379,6 +400,107 @@ export function isNodeBetweenMarkers(
379
400
  return false
380
401
  }
381
402
 
403
+ function reorderBySwap<T>(
404
+ parent: ParentNode & Node,
405
+ first: KeyedBlock<T>,
406
+ second: KeyedBlock<T>,
407
+ ): boolean {
408
+ if (first === second) return false
409
+ const firstNodes = first.nodes
410
+ const secondNodes = second.nodes
411
+ if (firstNodes.length === 0 || secondNodes.length === 0) return false
412
+ const lastFirst = firstNodes[firstNodes.length - 1]!
413
+ const lastSecond = secondNodes[secondNodes.length - 1]!
414
+ const afterFirst = lastFirst.nextSibling
415
+ const afterSecond = lastSecond.nextSibling
416
+ moveNodesBefore(parent, firstNodes, afterSecond)
417
+ moveNodesBefore(parent, secondNodes, afterFirst)
418
+ return true
419
+ }
420
+
421
+ function getLISIndices(sequence: number[]): number[] {
422
+ const predecessors = new Array<number>(sequence.length)
423
+ const result: number[] = []
424
+
425
+ for (let i = 0; i < sequence.length; i++) {
426
+ const value = sequence[i]!
427
+ if (value < 0) {
428
+ predecessors[i] = -1
429
+ continue
430
+ }
431
+
432
+ let low = 0
433
+ let high = result.length
434
+ while (low < high) {
435
+ const mid = (low + high) >> 1
436
+ if (sequence[result[mid]!]! < value) {
437
+ low = mid + 1
438
+ } else {
439
+ high = mid
440
+ }
441
+ }
442
+
443
+ predecessors[i] = low > 0 ? result[low - 1]! : -1
444
+ if (low === result.length) {
445
+ result.push(i)
446
+ } else {
447
+ result[low] = i
448
+ }
449
+ }
450
+
451
+ const lis: number[] = new Array(result.length)
452
+ let k = result.length > 0 ? result[result.length - 1]! : -1
453
+ for (let i = result.length - 1; i >= 0; i--) {
454
+ lis[i] = k
455
+ k = predecessors[k]!
456
+ }
457
+ return lis
458
+ }
459
+
460
+ function reorderByLIS<T>(
461
+ parent: ParentNode & Node,
462
+ endMarker: Comment,
463
+ prev: KeyedBlock<T>[],
464
+ next: KeyedBlock<T>[],
465
+ ): boolean {
466
+ const positions = new Map<KeyedBlock<T>, number>()
467
+ for (let i = 0; i < prev.length; i++) {
468
+ positions.set(prev[i]!, i)
469
+ }
470
+
471
+ const sequence = new Array<number>(next.length)
472
+ for (let i = 0; i < next.length; i++) {
473
+ const position = positions.get(next[i]!)
474
+ if (position === undefined) return false
475
+ sequence[i] = position
476
+ }
477
+
478
+ const lisIndices = getLISIndices(sequence)
479
+ if (lisIndices.length === sequence.length) return true
480
+
481
+ const inLIS = new Array<boolean>(sequence.length).fill(false)
482
+ for (let i = 0; i < lisIndices.length; i++) {
483
+ inLIS[lisIndices[i]!] = true
484
+ }
485
+
486
+ let anchor: Node | null = endMarker
487
+ let moved = false
488
+ for (let i = next.length - 1; i >= 0; i--) {
489
+ const block = next[i]!
490
+ const nodes = block.nodes
491
+ if (nodes.length === 0) continue
492
+ if (inLIS[i]) {
493
+ anchor = nodes[0]!
494
+ continue
495
+ }
496
+ moveNodesBefore(parent, nodes, anchor)
497
+ anchor = nodes[0]!
498
+ moved = true
499
+ }
500
+
501
+ return moved
502
+ }
503
+
382
504
  // ============================================================================
383
505
  // High-Level List Binding (for compiler-generated code)
384
506
  // ============================================================================
@@ -443,10 +565,6 @@ function createFineGrainedKeyedList<T>(
443
565
  const prevOrderedBlocks = container.orderedBlocks
444
566
  const nextOrderedBlocks = container.nextOrderedBlocks
445
567
  const orderedIndexByKey = container.orderedIndexByKey
446
- newBlocks.clear()
447
- nextOrderedBlocks.length = 0
448
- orderedIndexByKey.clear()
449
- const createdBlocks: KeyedBlock<T>[] = []
450
568
  const newItems = getItems()
451
569
 
452
570
  if (newItems.length === 0) {
@@ -473,8 +591,45 @@ function createFineGrainedKeyedList<T>(
473
591
  }
474
592
 
475
593
  const prevCount = prevOrderedBlocks.length
594
+ if (prevCount > 0 && newItems.length === prevCount && orderedIndexByKey.size === prevCount) {
595
+ let stableOrder = true
596
+ const seen = new Set<string | number>()
597
+ for (let i = 0; i < prevCount; i++) {
598
+ const item = newItems[i]!
599
+ const key = keyFn(item, i)
600
+ if (seen.has(key) || prevOrderedBlocks[i]!.key !== key) {
601
+ stableOrder = false
602
+ break
603
+ }
604
+ seen.add(key)
605
+ }
606
+ if (stableOrder) {
607
+ for (let i = 0; i < prevCount; i++) {
608
+ const item = newItems[i]!
609
+ const block = prevOrderedBlocks[i]!
610
+ if (block.rawItem !== item) {
611
+ block.rawItem = item
612
+ block.item(item)
613
+ }
614
+ if (needsIndex && block.rawIndex !== i) {
615
+ block.rawIndex = i
616
+ block.index(i)
617
+ }
618
+ }
619
+ return
620
+ }
621
+ }
622
+
623
+ newBlocks.clear()
624
+ nextOrderedBlocks.length = 0
625
+ orderedIndexByKey.clear()
626
+ const createdBlocks: KeyedBlock<T>[] = []
476
627
  let appendCandidate = prevCount > 0 && newItems.length >= prevCount
477
628
  const appendedBlocks: KeyedBlock<T>[] = []
629
+ let mismatchCount = 0
630
+ let mismatchFirst = -1
631
+ let mismatchSecond = -1
632
+ let hasDuplicateKey = false
478
633
 
479
634
  // Phase 1: Build new blocks map (reuse or create)
480
635
  newItems.forEach((item, index) => {
@@ -502,6 +657,12 @@ function createFineGrainedKeyedList<T>(
502
657
  // If newBlocks already has this key (duplicate key case), clean up the previous block
503
658
  const existingBlock = newBlocks.get(key)
504
659
  if (existingBlock) {
660
+ if (isDev) {
661
+ console.warn(
662
+ `[fict] Duplicate key "${String(key)}" detected in list rendering. ` +
663
+ `Each item should have a unique key. The previous item with this key will be replaced.`,
664
+ )
665
+ }
505
666
  destroyRoot(existingBlock.root)
506
667
  removeNodes(existingBlock.nodes)
507
668
  }
@@ -518,6 +679,7 @@ function createFineGrainedKeyedList<T>(
518
679
  const position = orderedIndexByKey.get(key)
519
680
  if (position !== undefined) {
520
681
  appendCandidate = false
682
+ hasDuplicateKey = true
521
683
  const prior = nextOrderedBlocks[position]
522
684
  if (prior && prior !== resolvedBlock) {
523
685
  destroyRoot(prior.root)
@@ -534,8 +696,20 @@ function createFineGrainedKeyedList<T>(
534
696
  appendCandidate = false
535
697
  }
536
698
  }
537
- orderedIndexByKey.set(key, nextOrderedBlocks.length)
699
+ const nextIndex = nextOrderedBlocks.length
700
+ orderedIndexByKey.set(key, nextIndex)
538
701
  nextOrderedBlocks.push(resolvedBlock)
702
+ if (
703
+ mismatchCount < 3 &&
704
+ (nextIndex >= prevCount || prevOrderedBlocks[nextIndex] !== resolvedBlock)
705
+ ) {
706
+ if (mismatchCount === 0) {
707
+ mismatchFirst = nextIndex
708
+ } else if (mismatchCount === 1) {
709
+ mismatchSecond = nextIndex
710
+ }
711
+ mismatchCount++
712
+ }
539
713
  }
540
714
 
541
715
  if (appendCandidate && index >= prevCount) {
@@ -587,8 +761,41 @@ function createFineGrainedKeyedList<T>(
587
761
  oldBlocks.clear()
588
762
  }
589
763
 
764
+ const canReorderInPlace =
765
+ createdBlocks.length === 0 &&
766
+ oldBlocks.size === 0 &&
767
+ nextOrderedBlocks.length === prevOrderedBlocks.length
768
+
769
+ let skipReconcile = false
770
+ let updateNodeBuffer = true
771
+
772
+ if (canReorderInPlace && nextOrderedBlocks.length > 0 && !hasDuplicateKey) {
773
+ if (mismatchCount === 0) {
774
+ skipReconcile = true
775
+ updateNodeBuffer = false
776
+ } else if (
777
+ mismatchCount === 2 &&
778
+ prevOrderedBlocks[mismatchFirst] === nextOrderedBlocks[mismatchSecond] &&
779
+ prevOrderedBlocks[mismatchSecond] === nextOrderedBlocks[mismatchFirst]
780
+ ) {
781
+ if (
782
+ reorderBySwap(
783
+ parent,
784
+ prevOrderedBlocks[mismatchFirst]!,
785
+ prevOrderedBlocks[mismatchSecond]!,
786
+ )
787
+ ) {
788
+ skipReconcile = true
789
+ }
790
+ } else if (
791
+ reorderByLIS(parent, container.endMarker, prevOrderedBlocks, nextOrderedBlocks)
792
+ ) {
793
+ skipReconcile = true
794
+ }
795
+ }
796
+
590
797
  // Phase 3: Reconcile DOM with buffered node arrays
591
- if (newBlocks.size > 0 || container.currentNodes.length > 0) {
798
+ if (!skipReconcile && (newBlocks.size > 0 || container.currentNodes.length > 0)) {
592
799
  const prevNodes = container.currentNodes
593
800
  const nextNodes = container.nextNodes
594
801
  nextNodes.length = 0
@@ -608,6 +815,20 @@ function createFineGrainedKeyedList<T>(
608
815
  // Swap buffers to reuse arrays on next diff
609
816
  container.currentNodes = nextNodes
610
817
  container.nextNodes = prevNodes
818
+ } else if (skipReconcile && updateNodeBuffer) {
819
+ const prevNodes = container.currentNodes
820
+ const nextNodes = container.nextNodes
821
+ nextNodes.length = 0
822
+ nextNodes.push(container.startMarker)
823
+ for (let i = 0; i < nextOrderedBlocks.length; i++) {
824
+ const nodes = nextOrderedBlocks[i]!.nodes
825
+ for (let j = 0; j < nodes.length; j++) {
826
+ nextNodes.push(nodes[j]!)
827
+ }
828
+ }
829
+ nextNodes.push(container.endMarker)
830
+ container.currentNodes = nextNodes
831
+ container.nextNodes = prevNodes
611
832
  }
612
833
 
613
834
  // Swap block maps for reuse
package/src/props.ts CHANGED
@@ -124,10 +124,7 @@ export function mergeProps<T extends Record<string, unknown>>(
124
124
 
125
125
  return new Proxy({} as Record<string, unknown>, {
126
126
  get(_, prop) {
127
- // Symbol properties (like Symbol.iterator) should return undefined
128
- if (typeof prop === 'symbol') {
129
- return undefined
130
- }
127
+ // Only return undefined if no source has this Symbol property
131
128
  // Search sources in reverse order (last wins)
132
129
  for (let i = validSources.length - 1; i >= 0; i--) {
133
130
  const src = validSources[i]!
@@ -136,6 +133,7 @@ export function mergeProps<T extends Record<string, unknown>>(
136
133
 
137
134
  const value = (raw as Record<string | symbol, unknown>)[prop]
138
135
  // Preserve prop getters - let child component's createPropsProxy unwrap lazily
136
+ // Note: For Symbol properties, we still wrap in getter if source is dynamic
139
137
  if (typeof src === 'function' && !isPropGetter(value)) {
140
138
  return __fictProp(() => {
141
139
  const latest = resolveSource(src)
package/src/reconcile.ts CHANGED
@@ -21,6 +21,10 @@
21
21
  * @param a - The old array of nodes (currently in DOM)
22
22
  * @param b - The new array of nodes (target state)
23
23
  *
24
+ * **Note:** This function may mutate the input array `a` during the swap
25
+ * optimization (step 5a). If you need to preserve the original array,
26
+ * pass a shallow copy: `reconcileArrays(parent, [...oldNodes], newNodes)`.
27
+ *
24
28
  * @example
25
29
  * ```ts
26
30
  * const oldNodes = [node1, node2, node3]