@fictjs/runtime 0.0.11 → 0.0.13

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.
@@ -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
  // ============================================================================
@@ -32,7 +37,7 @@ export { insertNodesBefore, removeNodes, toNodeArray }
32
37
  /**
33
38
  * A keyed block represents a single item in a list with its associated DOM nodes and state
34
39
  */
35
- export interface KeyedBlock<T = unknown> {
40
+ interface KeyedBlock<T = unknown> {
36
41
  /** Unique key for this block */
37
42
  key: string | number
38
43
  /** DOM nodes belonging to this block */
@@ -52,7 +57,7 @@ export interface KeyedBlock<T = unknown> {
52
57
  /**
53
58
  * Container for managing keyed list blocks
54
59
  */
55
- export interface KeyedListContainer<T = unknown> {
60
+ interface KeyedListContainer<T = unknown> {
56
61
  /** Start marker comment node */
57
62
  startMarker: Comment
58
63
  /** End marker comment node */
@@ -97,15 +102,6 @@ type FineGrainedRenderItem<T> = (
97
102
  key: string | number,
98
103
  ) => Node[]
99
104
 
100
- /**
101
- * A block identified by start/end comment markers.
102
- */
103
- export interface MarkerBlock {
104
- start: Comment
105
- end: Comment
106
- root?: RootContext
107
- }
108
-
109
105
  // ============================================================================
110
106
  // DOM Manipulation Primitives
111
107
  // ============================================================================
@@ -124,7 +120,8 @@ export function moveNodesBefore(parent: Node, nodes: Node[], anchor: Node | null
124
120
  for (let i = nodes.length - 1; i >= 0; i--) {
125
121
  const node = nodes[i]!
126
122
  if (!node || !(node instanceof Node)) {
127
- throw new Error('Invalid node in moveNodesBefore')
123
+ const message = isDev ? 'Invalid node in moveNodesBefore' : 'FICT:E_NODE'
124
+ throw new Error(message)
128
125
  }
129
126
  // Only move if not already in correct position
130
127
  if (node.nextSibling !== anchor) {
@@ -157,49 +154,8 @@ export function moveNodesBefore(parent: Node, nodes: Node[], anchor: Node | null
157
154
  *
158
155
  * @param nodes - Array of nodes to remove
159
156
  */
160
- /**
161
- * Move an entire marker-delimited block (including markers) before the anchor.
162
- */
163
- export function moveMarkerBlock(parent: Node, block: MarkerBlock, anchor: Node | null): void {
164
- const nodes = collectBlockNodes(block)
165
- if (nodes.length === 0) return
166
- moveNodesBefore(parent, nodes, anchor)
167
- }
168
-
169
- /**
170
- * Destroy a marker-delimited block, removing nodes and destroying the associated root.
171
- */
172
- export function destroyMarkerBlock(block: MarkerBlock): void {
173
- if (block.root) {
174
- destroyRoot(block.root)
175
- }
176
- removeBlockRange(block)
177
- }
178
-
179
- function collectBlockNodes(block: MarkerBlock): Node[] {
180
- const nodes: Node[] = []
181
- let cursor: Node | null = block.start
182
- while (cursor) {
183
- nodes.push(cursor)
184
- if (cursor === block.end) {
185
- break
186
- }
187
- cursor = cursor.nextSibling
188
- }
189
- return nodes
190
- }
191
-
192
- function removeBlockRange(block: MarkerBlock): void {
193
- let cursor: Node | null = block.start
194
- while (cursor) {
195
- const next: Node | null = cursor.nextSibling
196
- cursor.parentNode?.removeChild(cursor)
197
- if (cursor === block.end) {
198
- break
199
- }
200
- cursor = next
201
- }
202
- }
157
+ // Number.MAX_SAFE_INTEGER is 2^53 - 1, but we reset earlier to avoid any precision issues
158
+ const MAX_SAFE_VERSION = 0x1fffffffffffff // 2^53 - 1
203
159
 
204
160
  export function createVersionedSignalAccessor<T>(initialValue: T): Signal<T> {
205
161
  let current = initialValue
@@ -212,7 +168,8 @@ export function createVersionedSignalAccessor<T>(initialValue: T): Signal<T> {
212
168
  return current
213
169
  }
214
170
  current = value as T
215
- version++
171
+ // This is safe because we only care about version changes, not absolute values
172
+ version = version >= MAX_SAFE_VERSION ? 1 : version + 1
216
173
  track(version)
217
174
  }
218
175
 
@@ -229,7 +186,7 @@ export function createVersionedSignalAccessor<T>(initialValue: T): Signal<T> {
229
186
  *
230
187
  * @returns Container object with markers, blocks map, and dispose function
231
188
  */
232
- export function createKeyedListContainer<T = unknown>(): KeyedListContainer<T> {
189
+ function createKeyedListContainer<T = unknown>(): KeyedListContainer<T> {
233
190
  const startMarker = document.createComment('fict:list:start')
234
191
  const endMarker = document.createComment('fict:list:end')
235
192
 
@@ -297,7 +254,7 @@ export function createKeyedListContainer<T = unknown>(): KeyedListContainer<T> {
297
254
  * @param render - Function that creates the DOM nodes and sets up bindings
298
255
  * @returns New KeyedBlock
299
256
  */
300
- export function createKeyedBlock<T>(
257
+ function createKeyedBlock<T>(
301
258
  key: string | number,
302
259
  item: T,
303
260
  index: number,
@@ -317,24 +274,36 @@ export function createKeyedBlock<T>(
317
274
  }) as Signal<number>)
318
275
  const root = createRootContext(hostRoot)
319
276
  const prevRoot = pushRoot(root)
277
+ // maintaining proper cleanup chain. The scope will be disposed when
278
+ // the root is destroyed, ensuring nested effects are properly cleaned up.
279
+ let nodes: Node[] = []
280
+ let scopeDispose: (() => void) | undefined
320
281
 
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.
282
+ // First, isolate from parent effect to prevent child effects from being
283
+ // purged when the outer effect (e.g., performDiff) re-runs
323
284
  const prevSub = setActiveSub(undefined)
324
285
 
325
- let nodes: Node[] = []
326
286
  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)
287
+ // Create an effectScope that will track all effects created during render
288
+ scopeDispose = effectScope(() => {
289
+ const rendered = render(itemSig, indexSig, key)
290
+ // If render returns real DOM nodes/arrays, preserve them to avoid
291
+ // reparenting side-effects (tests may pre-insert them).
292
+ if (
293
+ rendered instanceof Node ||
294
+ (Array.isArray(rendered) && rendered.every(n => n instanceof Node))
295
+ ) {
296
+ nodes = toNodeArray(rendered)
297
+ } else {
298
+ const element = createElement(rendered as unknown as FictNode)
299
+ nodes = toNodeArray(element)
300
+ }
301
+ })
302
+
303
+ // Register the scope cleanup with the root so effects are cleaned up
304
+ // when the block is destroyed
305
+ if (scopeDispose) {
306
+ root.cleanups.push(scopeDispose)
338
307
  }
339
308
  } finally {
340
309
  setActiveSub(prevSub)
@@ -356,13 +325,6 @@ export function createKeyedBlock<T>(
356
325
  // Utilities
357
326
  // ============================================================================
358
327
 
359
- /**
360
- * Find the first node after the start marker (for getting current anchor)
361
- */
362
- export function getFirstNodeAfter(marker: Comment): Node | null {
363
- return marker.nextSibling
364
- }
365
-
366
328
  /**
367
329
  * Check if a node is between two markers
368
330
  */
@@ -379,6 +341,107 @@ export function isNodeBetweenMarkers(
379
341
  return false
380
342
  }
381
343
 
344
+ function reorderBySwap<T>(
345
+ parent: ParentNode & Node,
346
+ first: KeyedBlock<T>,
347
+ second: KeyedBlock<T>,
348
+ ): boolean {
349
+ if (first === second) return false
350
+ const firstNodes = first.nodes
351
+ const secondNodes = second.nodes
352
+ if (firstNodes.length === 0 || secondNodes.length === 0) return false
353
+ const lastFirst = firstNodes[firstNodes.length - 1]!
354
+ const lastSecond = secondNodes[secondNodes.length - 1]!
355
+ const afterFirst = lastFirst.nextSibling
356
+ const afterSecond = lastSecond.nextSibling
357
+ moveNodesBefore(parent, firstNodes, afterSecond)
358
+ moveNodesBefore(parent, secondNodes, afterFirst)
359
+ return true
360
+ }
361
+
362
+ function getLISIndices(sequence: number[]): number[] {
363
+ const predecessors = new Array<number>(sequence.length)
364
+ const result: number[] = []
365
+
366
+ for (let i = 0; i < sequence.length; i++) {
367
+ const value = sequence[i]!
368
+ if (value < 0) {
369
+ predecessors[i] = -1
370
+ continue
371
+ }
372
+
373
+ let low = 0
374
+ let high = result.length
375
+ while (low < high) {
376
+ const mid = (low + high) >> 1
377
+ if (sequence[result[mid]!]! < value) {
378
+ low = mid + 1
379
+ } else {
380
+ high = mid
381
+ }
382
+ }
383
+
384
+ predecessors[i] = low > 0 ? result[low - 1]! : -1
385
+ if (low === result.length) {
386
+ result.push(i)
387
+ } else {
388
+ result[low] = i
389
+ }
390
+ }
391
+
392
+ const lis: number[] = new Array(result.length)
393
+ let k = result.length > 0 ? result[result.length - 1]! : -1
394
+ for (let i = result.length - 1; i >= 0; i--) {
395
+ lis[i] = k
396
+ k = predecessors[k]!
397
+ }
398
+ return lis
399
+ }
400
+
401
+ function reorderByLIS<T>(
402
+ parent: ParentNode & Node,
403
+ endMarker: Comment,
404
+ prev: KeyedBlock<T>[],
405
+ next: KeyedBlock<T>[],
406
+ ): boolean {
407
+ const positions = new Map<KeyedBlock<T>, number>()
408
+ for (let i = 0; i < prev.length; i++) {
409
+ positions.set(prev[i]!, i)
410
+ }
411
+
412
+ const sequence = new Array<number>(next.length)
413
+ for (let i = 0; i < next.length; i++) {
414
+ const position = positions.get(next[i]!)
415
+ if (position === undefined) return false
416
+ sequence[i] = position
417
+ }
418
+
419
+ const lisIndices = getLISIndices(sequence)
420
+ if (lisIndices.length === sequence.length) return true
421
+
422
+ const inLIS = new Array<boolean>(sequence.length).fill(false)
423
+ for (let i = 0; i < lisIndices.length; i++) {
424
+ inLIS[lisIndices[i]!] = true
425
+ }
426
+
427
+ let anchor: Node | null = endMarker
428
+ let moved = false
429
+ for (let i = next.length - 1; i >= 0; i--) {
430
+ const block = next[i]!
431
+ const nodes = block.nodes
432
+ if (nodes.length === 0) continue
433
+ if (inLIS[i]) {
434
+ anchor = nodes[0]!
435
+ continue
436
+ }
437
+ moveNodesBefore(parent, nodes, anchor)
438
+ anchor = nodes[0]!
439
+ moved = true
440
+ }
441
+
442
+ return moved
443
+ }
444
+
382
445
  // ============================================================================
383
446
  // High-Level List Binding (for compiler-generated code)
384
447
  // ============================================================================
@@ -443,10 +506,6 @@ function createFineGrainedKeyedList<T>(
443
506
  const prevOrderedBlocks = container.orderedBlocks
444
507
  const nextOrderedBlocks = container.nextOrderedBlocks
445
508
  const orderedIndexByKey = container.orderedIndexByKey
446
- newBlocks.clear()
447
- nextOrderedBlocks.length = 0
448
- orderedIndexByKey.clear()
449
- const createdBlocks: KeyedBlock<T>[] = []
450
509
  const newItems = getItems()
451
510
 
452
511
  if (newItems.length === 0) {
@@ -473,8 +532,45 @@ function createFineGrainedKeyedList<T>(
473
532
  }
474
533
 
475
534
  const prevCount = prevOrderedBlocks.length
535
+ if (prevCount > 0 && newItems.length === prevCount && orderedIndexByKey.size === prevCount) {
536
+ let stableOrder = true
537
+ const seen = new Set<string | number>()
538
+ for (let i = 0; i < prevCount; i++) {
539
+ const item = newItems[i]!
540
+ const key = keyFn(item, i)
541
+ if (seen.has(key) || prevOrderedBlocks[i]!.key !== key) {
542
+ stableOrder = false
543
+ break
544
+ }
545
+ seen.add(key)
546
+ }
547
+ if (stableOrder) {
548
+ for (let i = 0; i < prevCount; i++) {
549
+ const item = newItems[i]!
550
+ const block = prevOrderedBlocks[i]!
551
+ if (block.rawItem !== item) {
552
+ block.rawItem = item
553
+ block.item(item)
554
+ }
555
+ if (needsIndex && block.rawIndex !== i) {
556
+ block.rawIndex = i
557
+ block.index(i)
558
+ }
559
+ }
560
+ return
561
+ }
562
+ }
563
+
564
+ newBlocks.clear()
565
+ nextOrderedBlocks.length = 0
566
+ orderedIndexByKey.clear()
567
+ const createdBlocks: KeyedBlock<T>[] = []
476
568
  let appendCandidate = prevCount > 0 && newItems.length >= prevCount
477
569
  const appendedBlocks: KeyedBlock<T>[] = []
570
+ let mismatchCount = 0
571
+ let mismatchFirst = -1
572
+ let mismatchSecond = -1
573
+ let hasDuplicateKey = false
478
574
 
479
575
  // Phase 1: Build new blocks map (reuse or create)
480
576
  newItems.forEach((item, index) => {
@@ -502,6 +598,12 @@ function createFineGrainedKeyedList<T>(
502
598
  // If newBlocks already has this key (duplicate key case), clean up the previous block
503
599
  const existingBlock = newBlocks.get(key)
504
600
  if (existingBlock) {
601
+ if (isDev) {
602
+ console.warn(
603
+ `[fict] Duplicate key "${String(key)}" detected in list rendering. ` +
604
+ `Each item should have a unique key. The previous item with this key will be replaced.`,
605
+ )
606
+ }
505
607
  destroyRoot(existingBlock.root)
506
608
  removeNodes(existingBlock.nodes)
507
609
  }
@@ -518,6 +620,7 @@ function createFineGrainedKeyedList<T>(
518
620
  const position = orderedIndexByKey.get(key)
519
621
  if (position !== undefined) {
520
622
  appendCandidate = false
623
+ hasDuplicateKey = true
521
624
  const prior = nextOrderedBlocks[position]
522
625
  if (prior && prior !== resolvedBlock) {
523
626
  destroyRoot(prior.root)
@@ -534,8 +637,20 @@ function createFineGrainedKeyedList<T>(
534
637
  appendCandidate = false
535
638
  }
536
639
  }
537
- orderedIndexByKey.set(key, nextOrderedBlocks.length)
640
+ const nextIndex = nextOrderedBlocks.length
641
+ orderedIndexByKey.set(key, nextIndex)
538
642
  nextOrderedBlocks.push(resolvedBlock)
643
+ if (
644
+ mismatchCount < 3 &&
645
+ (nextIndex >= prevCount || prevOrderedBlocks[nextIndex] !== resolvedBlock)
646
+ ) {
647
+ if (mismatchCount === 0) {
648
+ mismatchFirst = nextIndex
649
+ } else if (mismatchCount === 1) {
650
+ mismatchSecond = nextIndex
651
+ }
652
+ mismatchCount++
653
+ }
539
654
  }
540
655
 
541
656
  if (appendCandidate && index >= prevCount) {
@@ -587,8 +702,41 @@ function createFineGrainedKeyedList<T>(
587
702
  oldBlocks.clear()
588
703
  }
589
704
 
705
+ const canReorderInPlace =
706
+ createdBlocks.length === 0 &&
707
+ oldBlocks.size === 0 &&
708
+ nextOrderedBlocks.length === prevOrderedBlocks.length
709
+
710
+ let skipReconcile = false
711
+ let updateNodeBuffer = true
712
+
713
+ if (canReorderInPlace && nextOrderedBlocks.length > 0 && !hasDuplicateKey) {
714
+ if (mismatchCount === 0) {
715
+ skipReconcile = true
716
+ updateNodeBuffer = false
717
+ } else if (
718
+ mismatchCount === 2 &&
719
+ prevOrderedBlocks[mismatchFirst] === nextOrderedBlocks[mismatchSecond] &&
720
+ prevOrderedBlocks[mismatchSecond] === nextOrderedBlocks[mismatchFirst]
721
+ ) {
722
+ if (
723
+ reorderBySwap(
724
+ parent,
725
+ prevOrderedBlocks[mismatchFirst]!,
726
+ prevOrderedBlocks[mismatchSecond]!,
727
+ )
728
+ ) {
729
+ skipReconcile = true
730
+ }
731
+ } else if (
732
+ reorderByLIS(parent, container.endMarker, prevOrderedBlocks, nextOrderedBlocks)
733
+ ) {
734
+ skipReconcile = true
735
+ }
736
+ }
737
+
590
738
  // Phase 3: Reconcile DOM with buffered node arrays
591
- if (newBlocks.size > 0 || container.currentNodes.length > 0) {
739
+ if (!skipReconcile && (newBlocks.size > 0 || container.currentNodes.length > 0)) {
592
740
  const prevNodes = container.currentNodes
593
741
  const nextNodes = container.nextNodes
594
742
  nextNodes.length = 0
@@ -608,6 +756,20 @@ function createFineGrainedKeyedList<T>(
608
756
  // Swap buffers to reuse arrays on next diff
609
757
  container.currentNodes = nextNodes
610
758
  container.nextNodes = prevNodes
759
+ } else if (skipReconcile && updateNodeBuffer) {
760
+ const prevNodes = container.currentNodes
761
+ const nextNodes = container.nextNodes
762
+ nextNodes.length = 0
763
+ nextNodes.push(container.startMarker)
764
+ for (let i = 0; i < nextOrderedBlocks.length; i++) {
765
+ const nodes = nextOrderedBlocks[i]!.nodes
766
+ for (let j = 0; j < nodes.length; j++) {
767
+ nextNodes.push(nodes[j]!)
768
+ }
769
+ }
770
+ nextNodes.push(container.endMarker)
771
+ container.currentNodes = nextNodes
772
+ container.nextNodes = prevNodes
611
773
  }
612
774
 
613
775
  // 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]