@fictjs/runtime 0.0.9 → 0.0.11

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.9",
3
+ "version": "0.0.11",
4
4
  "description": "Fict reactive runtime",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/binding.ts CHANGED
@@ -36,9 +36,10 @@ import {
36
36
  registerRootCleanup,
37
37
  type RootContext,
38
38
  } from './lifecycle'
39
+ import { createVersionedSignalAccessor } from './list-helpers'
39
40
  import { toNodeArray, removeNodes, insertNodesBefore } from './node-ops'
41
+ import { batch } from './scheduler'
40
42
  import { computed, createSignal, untrack, type Signal } from './signal'
41
- import { createVersionedSignalAccessor } from './list-helpers'
42
43
  import type { Cleanup, FictNode } from './types'
43
44
 
44
45
  // ============================================================================
@@ -552,8 +553,18 @@ export function bindClass(
552
553
  getValue: () => string | Record<string, boolean> | null | undefined,
553
554
  ): Cleanup {
554
555
  let prev: Record<string, boolean> = {}
556
+ let prevString: string | undefined
555
557
  return createRenderEffect(() => {
556
558
  const next = getValue()
559
+ // P2-1: Short-circuit for string values to avoid DOM writes when unchanged
560
+ if (typeof next === 'string') {
561
+ if (next === prevString) return
562
+ prevString = next
563
+ el.className = next
564
+ prev = {}
565
+ return
566
+ }
567
+ prevString = undefined
557
568
  prev = applyClass(el, next, prev)
558
569
  })
559
570
  }
@@ -914,11 +925,21 @@ export function clearDelegatedEvents(doc: Document = window.document): void {
914
925
  * Walks up the DOM tree to find and call handlers stored as $$eventName properties.
915
926
  */
916
927
  function globalEventHandler(e: Event): void {
917
- let node = e.target as Element | null
928
+ const asNode = (value: unknown): Node | null =>
929
+ value && typeof (value as Node).nodeType === 'number' ? (value as Node) : null
930
+ const asElement = (value: unknown): Element | null => {
931
+ const n = asNode(value)
932
+ if (!n) return null
933
+ if (n.nodeType === 1) return n as Element
934
+ return (n as ChildNode).parentElement
935
+ }
936
+
937
+ let node = asElement(e.target)
918
938
  const key = `$$${e.type}` as const
919
939
  const dataKey = `${key}Data` as `$$${string}Data`
920
940
  const oriTarget = e.target
921
941
  const oriCurrentTarget = e.currentTarget
942
+ let lastHandled: Element | null = null
922
943
 
923
944
  // Retarget helper for shadow DOM and portals
924
945
  const retarget = (value: EventTarget) =>
@@ -947,12 +968,15 @@ function globalEventHandler(e: Event): void {
947
968
  const rawData = (node as any)[dataKey] as unknown
948
969
  const hasData = rawData !== undefined
949
970
  const resolvedNodeData = hasData ? resolveData(rawData) : undefined
950
- if (typeof handler === 'function') {
951
- callEventHandler(handler, e, node, hasData ? resolvedNodeData : undefined)
952
- } else if (Array.isArray(handler)) {
953
- const tupleData = resolveData(handler[1])
954
- callEventHandler(handler[0], e, node, tupleData)
955
- }
971
+ // P2-3: Wrap event handler calls in batch for synchronous flush & reduced microtasks
972
+ batch(() => {
973
+ if (typeof handler === 'function') {
974
+ callEventHandler(handler, e, node, hasData ? resolvedNodeData : undefined)
975
+ } else if (Array.isArray(handler)) {
976
+ const tupleData = resolveData(handler[1])
977
+ callEventHandler(handler[0], e, node, tupleData)
978
+ }
979
+ })
956
980
  if (e.cancelBubble) return false
957
981
  }
958
982
  // Handle shadow DOM host retargeting
@@ -961,7 +985,10 @@ function globalEventHandler(e: Event): void {
961
985
  shadowHost &&
962
986
  typeof shadowHost !== 'string' &&
963
987
  !(shadowHost as Element)._$host &&
964
- node.contains(e.target as Node)
988
+ (() => {
989
+ const targetNode = asNode(e.target)
990
+ return targetNode ? node.contains(targetNode) : false
991
+ })()
965
992
  ) {
966
993
  retarget(shadowHost as EventTarget)
967
994
  }
@@ -971,9 +998,7 @@ function globalEventHandler(e: Event): void {
971
998
  // Walk up tree helper
972
999
  const walkUpTree = (): void => {
973
1000
  while (handleNode() && node) {
974
- node = (node._$host ||
975
- node.parentNode ||
976
- (node as unknown as ShadowRoot).host) as Element | null
1001
+ node = asElement(node._$host || node.parentNode || (node as unknown as ShadowRoot).host)
977
1002
  }
978
1003
  }
979
1004
 
@@ -990,8 +1015,11 @@ function globalEventHandler(e: Event): void {
990
1015
  const path = e.composedPath()
991
1016
  retarget(path[0] as EventTarget)
992
1017
  for (let i = 0; i < path.length - 2; i++) {
993
- node = path[i] as Element
1018
+ const nextNode = asElement(path[i] as EventTarget)
1019
+ if (!nextNode || nextNode === lastHandled) continue
1020
+ node = nextNode
994
1021
  if (!handleNode()) break
1022
+ lastHandled = node
995
1023
  // Handle portal event bubbling
996
1024
  if (node._$host) {
997
1025
  node = node._$host
package/src/dom.ts CHANGED
@@ -143,11 +143,21 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
143
143
  if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
144
144
  // Handle BindingHandle (createList, createConditional, etc)
145
145
  if ('marker' in node) {
146
- const handle = node as { marker: unknown; dispose?: () => void }
146
+ const handle = node as { marker: unknown; dispose?: () => void; flush?: () => void }
147
147
  // Register dispose cleanup if available
148
148
  if (typeof handle.dispose === 'function') {
149
149
  registerRootCleanup(handle.dispose)
150
150
  }
151
+ if (typeof handle.flush === 'function') {
152
+ const runFlush = () => handle.flush && handle.flush()
153
+ if (typeof queueMicrotask === 'function') {
154
+ queueMicrotask(runFlush)
155
+ } else {
156
+ Promise.resolve()
157
+ .then(runFlush)
158
+ .catch(() => undefined)
159
+ }
160
+ }
151
161
  return createElement(handle.marker as FictNode)
152
162
  }
153
163
 
@@ -210,20 +220,20 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
210
220
  })
211
221
 
212
222
  const props = createPropsProxy(baseProps)
223
+ // Create a fresh hook context for this component instance.
224
+ // This preserves slot state across re-renders driven by __fictRender.
225
+ __fictPushContext()
213
226
  try {
214
- // Create a fresh hook context for this component instance.
215
- // This preserves slot state across re-renders driven by __fictRender.
216
- __fictPushContext()
217
227
  const rendered = vnode.type(props)
218
- __fictPopContext()
219
228
  return createElementWithContext(rendered as FictNode, namespace)
220
229
  } catch (err) {
221
- __fictPopContext()
222
230
  if (handleSuspend(err as any)) {
223
231
  return document.createComment('fict:suspend')
224
232
  }
225
233
  handleError(err, { source: 'render', componentName: vnode.type.name })
226
234
  throw err
235
+ } finally {
236
+ __fictPopContext()
227
237
  }
228
238
  }
229
239
 
package/src/effect.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  runCleanupList,
6
6
  withEffectCleanups,
7
7
  } from './lifecycle'
8
- import { effect } from './signal'
8
+ import { effectWithCleanup } from './signal'
9
9
  import type { Cleanup } from './types'
10
10
 
11
11
  export type Effect = () => void | Cleanup
@@ -14,8 +14,14 @@ export function createEffect(fn: Effect): () => void {
14
14
  let cleanups: Cleanup[] = []
15
15
  const rootForError = getCurrentRoot()
16
16
 
17
- const run = () => {
17
+ // Cleanup runner - called by runEffect BEFORE signal values are committed
18
+ const doCleanup = () => {
18
19
  runCleanupList(cleanups)
20
+ cleanups = []
21
+ }
22
+
23
+ const run = () => {
24
+ // Note: cleanups are now run by signal.ts runEffect before this function is called
19
25
  const bucket: Cleanup[] = []
20
26
  withEffectCleanups(bucket, () => {
21
27
  try {
@@ -33,7 +39,7 @@ export function createEffect(fn: Effect): () => void {
33
39
  cleanups = bucket
34
40
  }
35
41
 
36
- const disposeEffect = effect(run)
42
+ const disposeEffect = effectWithCleanup(run, doCleanup)
37
43
  const teardown = () => {
38
44
  runCleanupList(cleanups)
39
45
  disposeEffect()
@@ -50,25 +56,31 @@ export function createRenderEffect(fn: Effect): () => void {
50
56
  let cleanup: Cleanup | undefined
51
57
  const rootForError = getCurrentRoot()
52
58
 
53
- const run = () => {
59
+ // Cleanup runner - called by runEffect BEFORE signal values are committed
60
+ const doCleanup = () => {
54
61
  if (cleanup) {
55
62
  cleanup()
56
63
  cleanup = undefined
57
64
  }
65
+ }
66
+
67
+ const run = () => {
68
+ // Note: cleanups are now run by signal.ts runEffect before this function is called
58
69
  try {
59
70
  const maybeCleanup = fn()
60
71
  if (typeof maybeCleanup === 'function') {
61
72
  cleanup = maybeCleanup
62
73
  }
63
74
  } catch (err) {
64
- if (handleError(err, { source: 'effect' }, rootForError)) {
75
+ const handled = handleError(err, { source: 'effect' }, rootForError)
76
+ if (handled) {
65
77
  return
66
78
  }
67
79
  throw err
68
80
  }
69
81
  }
70
82
 
71
- const disposeEffect = effect(run)
83
+ const disposeEffect = effectWithCleanup(run, doCleanup)
72
84
  const teardown = () => {
73
85
  if (cleanup) {
74
86
  cleanup()
package/src/hooks.ts CHANGED
@@ -5,18 +5,26 @@ import { createSignal, type SignalAccessor, type ComputedAccessor } from './sign
5
5
  interface HookContext {
6
6
  slots: unknown[]
7
7
  cursor: number
8
+ rendering?: boolean
8
9
  }
9
10
 
10
11
  const ctxStack: HookContext[] = []
11
12
 
13
+ function assertRenderContext(ctx: HookContext, hookName: string): void {
14
+ if (!ctx.rendering) {
15
+ throw new Error(`${hookName} can only be used during render execution`)
16
+ }
17
+ }
18
+
12
19
  export function __fictUseContext(): HookContext {
13
20
  if (ctxStack.length === 0) {
14
- const ctx: HookContext = { slots: [], cursor: 0 }
21
+ const ctx: HookContext = { slots: [], cursor: 0, rendering: true }
15
22
  ctxStack.push(ctx)
16
23
  return ctx
17
24
  }
18
25
  const ctx = ctxStack[ctxStack.length - 1]!
19
26
  ctx.cursor = 0
27
+ ctx.rendering = true
20
28
  return ctx
21
29
  }
22
30
 
@@ -35,6 +43,7 @@ export function __fictResetContext(): void {
35
43
  }
36
44
 
37
45
  export function __fictUseSignal<T>(ctx: HookContext, initial: T, slot?: number): SignalAccessor<T> {
46
+ assertRenderContext(ctx, '__fictUseSignal')
38
47
  const index = slot ?? ctx.cursor++
39
48
  if (!ctx.slots[index]) {
40
49
  ctx.slots[index] = createSignal(initial)
@@ -47,6 +56,7 @@ export function __fictUseMemo<T>(
47
56
  fn: () => T,
48
57
  slot?: number,
49
58
  ): ComputedAccessor<T> {
59
+ assertRenderContext(ctx, '__fictUseMemo')
50
60
  const index = slot ?? ctx.cursor++
51
61
  if (!ctx.slots[index]) {
52
62
  ctx.slots[index] = createMemo(fn)
@@ -55,6 +65,7 @@ export function __fictUseMemo<T>(
55
65
  }
56
66
 
57
67
  export function __fictUseEffect(ctx: HookContext, fn: () => void, slot?: number): void {
68
+ assertRenderContext(ctx, '__fictUseEffect')
58
69
  const index = slot ?? ctx.cursor++
59
70
  if (!ctx.slots[index]) {
60
71
  ctx.slots[index] = createEffect(fn)
@@ -64,9 +75,11 @@ export function __fictUseEffect(ctx: HookContext, fn: () => void, slot?: number)
64
75
  export function __fictRender<T>(ctx: HookContext, fn: () => T): T {
65
76
  ctxStack.push(ctx)
66
77
  ctx.cursor = 0
78
+ ctx.rendering = true
67
79
  try {
68
80
  return fn()
69
81
  } finally {
82
+ ctx.rendering = false
70
83
  ctxStack.pop()
71
84
  }
72
85
  }
@@ -19,7 +19,7 @@ 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, setActiveSub, type Signal } from './signal'
22
+ import { createSignal, 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
@@ -339,7 +339,6 @@ export function createKeyedBlock<T>(
339
339
  } finally {
340
340
  setActiveSub(prevSub)
341
341
  popRoot(prevRoot)
342
- flushOnMount(root)
343
342
  }
344
343
 
345
344
  return {
@@ -414,16 +413,31 @@ function createFineGrainedKeyedList<T>(
414
413
  const hostRoot = getCurrentRoot()
415
414
  const fragment = document.createDocumentFragment()
416
415
  fragment.append(container.startMarker, container.endMarker)
417
- let pendingItems: T[] | null = null
418
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
+ }
419
435
 
420
436
  const performDiff = () => {
421
437
  if (disposed) return
422
-
438
+ const parent = getConnectedParent()
439
+ if (!parent) return
423
440
  batch(() => {
424
- const newItems = pendingItems || getItems()
425
- pendingItems = null
426
-
427
441
  const oldBlocks = container.blocks
428
442
  const newBlocks = container.nextBlocks
429
443
  const prevOrderedBlocks = container.orderedBlocks
@@ -432,27 +446,20 @@ function createFineGrainedKeyedList<T>(
432
446
  newBlocks.clear()
433
447
  nextOrderedBlocks.length = 0
434
448
  orderedIndexByKey.clear()
435
-
436
- const endParent = container.endMarker.parentNode
437
- const startParent = container.startMarker.parentNode
438
- const parent =
439
- endParent && startParent && endParent === startParent && (endParent as Node).isConnected
440
- ? (endParent as ParentNode & Node)
441
- : null
442
-
443
- // If markers aren't mounted yet, store items and retry in microtask
444
- if (!parent) {
445
- pendingItems = newItems
446
- queueMicrotask(performDiff)
447
- return
448
- }
449
+ const createdBlocks: KeyedBlock<T>[] = []
450
+ const newItems = getItems()
449
451
 
450
452
  if (newItems.length === 0) {
451
453
  if (oldBlocks.size > 0) {
454
+ // Destroy all block roots first
452
455
  for (const block of oldBlocks.values()) {
453
456
  destroyRoot(block.root)
454
- removeNodes(block.nodes)
455
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()
456
463
  }
457
464
  oldBlocks.clear()
458
465
  newBlocks.clear()
@@ -472,8 +479,9 @@ function createFineGrainedKeyedList<T>(
472
479
  // Phase 1: Build new blocks map (reuse or create)
473
480
  newItems.forEach((item, index) => {
474
481
  const key = keyFn(item, index)
475
- const existed = oldBlocks.has(key)
482
+ // Micro-optimization: single Map.get instead of has+get
476
483
  let block = oldBlocks.get(key)
484
+ const existed = block !== undefined
477
485
 
478
486
  if (block) {
479
487
  if (block.rawItem !== item) {
@@ -486,45 +494,30 @@ function createFineGrainedKeyedList<T>(
486
494
  }
487
495
  }
488
496
 
489
- // If newBlocks already has this key (duplicate key case), clean up the previous block
490
- const existingBlock = newBlocks.get(key)
491
- if (existingBlock && existingBlock !== block) {
492
- destroyRoot(existingBlock.root)
493
- removeNodes(existingBlock.nodes)
494
- }
495
-
496
497
  if (block) {
498
+ // Reusing existing block from oldBlocks
497
499
  newBlocks.set(key, block)
498
500
  oldBlocks.delete(key)
499
501
  } else {
502
+ // If newBlocks already has this key (duplicate key case), clean up the previous block
500
503
  const existingBlock = newBlocks.get(key)
501
504
  if (existingBlock) {
502
505
  destroyRoot(existingBlock.root)
503
506
  removeNodes(existingBlock.nodes)
504
507
  }
505
-
506
508
  // Create new block
507
509
  block = createKeyedBlock(key, item, index, renderItem, needsIndex, hostRoot)
510
+ createdBlocks.push(block)
508
511
  }
509
512
 
510
- const resolvedBlock = block!
513
+ const resolvedBlock = block
511
514
 
512
515
  newBlocks.set(key, resolvedBlock)
513
516
 
517
+ // Micro-optimization: single Map.get instead of checking position multiple times
514
518
  const position = orderedIndexByKey.get(key)
515
519
  if (position !== undefined) {
516
520
  appendCandidate = false
517
- }
518
- if (appendCandidate) {
519
- if (index < prevCount) {
520
- if (!prevOrderedBlocks[index] || prevOrderedBlocks[index]!.key !== key) {
521
- appendCandidate = false
522
- }
523
- } else if (existed) {
524
- appendCandidate = false
525
- }
526
- }
527
- if (position !== undefined) {
528
521
  const prior = nextOrderedBlocks[position]
529
522
  if (prior && prior !== resolvedBlock) {
530
523
  destroyRoot(prior.root)
@@ -532,6 +525,15 @@ function createFineGrainedKeyedList<T>(
532
525
  }
533
526
  nextOrderedBlocks[position] = resolvedBlock
534
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
+ }
535
537
  orderedIndexByKey.set(key, nextOrderedBlocks.length)
536
538
  nextOrderedBlocks.push(resolvedBlock)
537
539
  }
@@ -568,6 +570,11 @@ function createFineGrainedKeyedList<T>(
568
570
  container.nextBlocks = oldBlocks
569
571
  container.orderedBlocks = nextOrderedBlocks
570
572
  container.nextOrderedBlocks = prevOrderedBlocks
573
+ for (const block of createdBlocks) {
574
+ if (newBlocks.get(block.key) === block) {
575
+ flushOnMount(block.root)
576
+ }
577
+ }
571
578
  return
572
579
  }
573
580
 
@@ -608,24 +615,95 @@ function createFineGrainedKeyedList<T>(
608
615
  container.nextBlocks = oldBlocks
609
616
  container.orderedBlocks = nextOrderedBlocks
610
617
  container.nextOrderedBlocks = prevOrderedBlocks
618
+ for (const block of createdBlocks) {
619
+ if (newBlocks.get(block.key) === block) {
620
+ flushOnMount(block.root)
621
+ }
622
+ }
611
623
  })
612
624
  }
613
625
 
614
- 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()
615
685
 
616
686
  return {
617
- marker: fragment,
687
+ get marker() {
688
+ scheduleStart()
689
+ return fragment
690
+ },
618
691
  startMarker: container.startMarker,
619
692
  endMarker: container.endMarker,
620
693
  // Flush pending items - call after markers are inserted into DOM
621
694
  flush: () => {
622
- if (pendingItems !== null) {
623
- performDiff()
695
+ if (disposed) return
696
+ scheduleStart()
697
+ if (ensureEffectStarted()) {
698
+ flush()
699
+ } else {
700
+ waitForConnection()
624
701
  }
625
702
  },
626
703
  dispose: () => {
627
704
  disposed = true
628
705
  effectDispose?.()
706
+ disconnectObserver()
629
707
  container.dispose()
630
708
  },
631
709
  }