@fictjs/runtime 0.3.0 → 0.5.0

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 (70) hide show
  1. package/dist/advanced.cjs +10 -8
  2. package/dist/advanced.cjs.map +1 -1
  3. package/dist/advanced.d.cts +4 -3
  4. package/dist/advanced.d.ts +4 -3
  5. package/dist/advanced.js +10 -8
  6. package/dist/advanced.js.map +1 -1
  7. package/dist/{chunk-TWELIZRY.js → chunk-5AA7HP4S.js} +5 -3
  8. package/dist/{chunk-TWELIZRY.js.map → chunk-5AA7HP4S.js.map} +1 -1
  9. package/dist/chunk-6SOPF5LZ.cjs +2363 -0
  10. package/dist/chunk-6SOPF5LZ.cjs.map +1 -0
  11. package/dist/{chunk-SO6X7G5S.js → chunk-BQG7VEBY.js} +501 -1880
  12. package/dist/chunk-BQG7VEBY.js.map +1 -0
  13. package/dist/chunk-FKDMDAUR.js +2363 -0
  14. package/dist/chunk-FKDMDAUR.js.map +1 -0
  15. package/dist/{chunk-L4DIV3RC.cjs → chunk-GHUV2FLD.cjs} +9 -7
  16. package/dist/chunk-GHUV2FLD.cjs.map +1 -0
  17. package/dist/{chunk-XLIZJMMJ.js → chunk-KKKYW54Z.js} +8 -6
  18. package/dist/{chunk-XLIZJMMJ.js.map → chunk-KKKYW54Z.js.map} +1 -1
  19. package/dist/{chunk-M2TSXZ4C.cjs → chunk-KYLNC4CD.cjs} +18 -16
  20. package/dist/chunk-KYLNC4CD.cjs.map +1 -0
  21. package/dist/chunk-TKWN42TA.cjs +2259 -0
  22. package/dist/chunk-TKWN42TA.cjs.map +1 -0
  23. package/dist/{context-B25xyQrJ.d.cts → context-CTBE00S_.d.cts} +1 -1
  24. package/dist/{context-CGdP7_Jb.d.ts → context-lkLhbkFJ.d.ts} +1 -1
  25. package/dist/{effect-D6kaLM2-.d.cts → effect-BpSNEJJz.d.cts} +7 -67
  26. package/dist/{effect-D6kaLM2-.d.ts → effect-BpSNEJJz.d.ts} +7 -67
  27. package/dist/index.cjs +40 -38
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +5 -4
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.dev.js +92 -4
  32. package/dist/index.dev.js.map +1 -1
  33. package/dist/index.js +19 -17
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal.cjs +189 -202
  36. package/dist/internal.cjs.map +1 -1
  37. package/dist/internal.d.cts +13 -23
  38. package/dist/internal.d.ts +13 -23
  39. package/dist/internal.js +195 -208
  40. package/dist/internal.js.map +1 -1
  41. package/dist/loader.cjs +280 -0
  42. package/dist/loader.cjs.map +1 -0
  43. package/dist/loader.d.cts +57 -0
  44. package/dist/loader.d.ts +57 -0
  45. package/dist/loader.js +280 -0
  46. package/dist/loader.js.map +1 -0
  47. package/dist/{props-BIfromL0.d.cts → props-XTHYD19o.d.cts} +13 -2
  48. package/dist/{props-BEgIVMRx.d.ts → props-x-HbI-jX.d.ts} +13 -2
  49. package/dist/resume-BrAkmSTY.d.cts +79 -0
  50. package/dist/resume-Dx8_l72o.d.ts +79 -0
  51. package/dist/{scope-CzNkn587.d.ts → scope-CdbGmsFf.d.ts} +1 -1
  52. package/dist/{scope-Cx_3CjIZ.d.cts → scope-DfcP9I-A.d.cts} +1 -1
  53. package/dist/signal-C4ISF17w.d.cts +66 -0
  54. package/dist/signal-C4ISF17w.d.ts +66 -0
  55. package/package.json +8 -3
  56. package/src/binding.ts +254 -5
  57. package/src/dom.ts +103 -5
  58. package/src/hooks.ts +15 -2
  59. package/src/hydration.ts +75 -0
  60. package/src/internal.ts +34 -2
  61. package/src/list-helpers.ts +113 -12
  62. package/src/loader.ts +437 -0
  63. package/src/node-ops.ts +65 -0
  64. package/src/resume.ts +517 -0
  65. package/src/store.ts +8 -0
  66. package/dist/chunk-ID3WBWNO.cjs +0 -3638
  67. package/dist/chunk-ID3WBWNO.cjs.map +0 -1
  68. package/dist/chunk-L4DIV3RC.cjs.map +0 -1
  69. package/dist/chunk-M2TSXZ4C.cjs.map +0 -1
  70. package/dist/chunk-SO6X7G5S.js.map +0 -1
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Options for creating a signal
3
+ */
4
+ interface SignalOptions<T> {
5
+ /** Custom equality check */
6
+ equals?: false | ((prev: T, next: T) => boolean);
7
+ /** Debug name */
8
+ name?: string;
9
+ /** Source location */
10
+ devToolsSource?: string;
11
+ }
12
+ /**
13
+ * Options for creating a memo
14
+ */
15
+ interface MemoOptions<T> {
16
+ /** Custom equality check */
17
+ equals?: false | ((prev: T, next: T) => boolean);
18
+ /** Debug name */
19
+ name?: string;
20
+ /** Source location */
21
+ devToolsSource?: string;
22
+ }
23
+ /**
24
+ * Signal accessor - function to get/set signal value
25
+ */
26
+ interface SignalAccessor<T> {
27
+ (): T;
28
+ (value: T): void;
29
+ }
30
+ /**
31
+ * Computed accessor - function to get computed value
32
+ */
33
+ type ComputedAccessor<T> = () => T;
34
+ /**
35
+ * Effect scope disposer - function to dispose an effect scope
36
+ */
37
+ type EffectScopeDisposer = () => void;
38
+ /**
39
+ * Create a reactive signal
40
+ * @param initialValue - The initial value
41
+ * @returns A signal accessor function
42
+ */
43
+ declare function signal<T>(initialValue: T, options?: SignalOptions<T>): SignalAccessor<T>;
44
+ /**
45
+ * Create a reactive effect scope
46
+ * @param fn - The scope function
47
+ * @returns An effect scope disposer function
48
+ */
49
+ declare function effectScope(fn: () => void): EffectScopeDisposer;
50
+ /**
51
+ * Reset all global reactive state for test isolation.
52
+ * ONLY use this in test setup/teardown - never in production code.
53
+ * This clears effect queues, resets batch depth, and clears pending flushes.
54
+ */
55
+ declare function __resetReactiveState(): void;
56
+ /**
57
+ * Create a selector signal that efficiently updates only when the selected key matches.
58
+ * Useful for large lists where only one item is selected.
59
+ *
60
+ * @param source - The source signal returning the current key
61
+ * @param equalityFn - Optional equality function
62
+ * @returns A selector function that takes a key and returns a boolean signal accessor
63
+ */
64
+ declare function createSelector<T>(source: () => T, equalityFn?: (a: T, b: T) => boolean): (key: T) => boolean;
65
+
66
+ export { type ComputedAccessor as C, type MemoOptions as M, type SignalAccessor as S, __resetReactiveState as _, type SignalOptions as a, createSelector as c, effectScope as e, signal as s };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Options for creating a signal
3
+ */
4
+ interface SignalOptions<T> {
5
+ /** Custom equality check */
6
+ equals?: false | ((prev: T, next: T) => boolean);
7
+ /** Debug name */
8
+ name?: string;
9
+ /** Source location */
10
+ devToolsSource?: string;
11
+ }
12
+ /**
13
+ * Options for creating a memo
14
+ */
15
+ interface MemoOptions<T> {
16
+ /** Custom equality check */
17
+ equals?: false | ((prev: T, next: T) => boolean);
18
+ /** Debug name */
19
+ name?: string;
20
+ /** Source location */
21
+ devToolsSource?: string;
22
+ }
23
+ /**
24
+ * Signal accessor - function to get/set signal value
25
+ */
26
+ interface SignalAccessor<T> {
27
+ (): T;
28
+ (value: T): void;
29
+ }
30
+ /**
31
+ * Computed accessor - function to get computed value
32
+ */
33
+ type ComputedAccessor<T> = () => T;
34
+ /**
35
+ * Effect scope disposer - function to dispose an effect scope
36
+ */
37
+ type EffectScopeDisposer = () => void;
38
+ /**
39
+ * Create a reactive signal
40
+ * @param initialValue - The initial value
41
+ * @returns A signal accessor function
42
+ */
43
+ declare function signal<T>(initialValue: T, options?: SignalOptions<T>): SignalAccessor<T>;
44
+ /**
45
+ * Create a reactive effect scope
46
+ * @param fn - The scope function
47
+ * @returns An effect scope disposer function
48
+ */
49
+ declare function effectScope(fn: () => void): EffectScopeDisposer;
50
+ /**
51
+ * Reset all global reactive state for test isolation.
52
+ * ONLY use this in test setup/teardown - never in production code.
53
+ * This clears effect queues, resets batch depth, and clears pending flushes.
54
+ */
55
+ declare function __resetReactiveState(): void;
56
+ /**
57
+ * Create a selector signal that efficiently updates only when the selected key matches.
58
+ * Useful for large lists where only one item is selected.
59
+ *
60
+ * @param source - The source signal returning the current key
61
+ * @param equalityFn - Optional equality function
62
+ * @returns A selector function that takes a key and returns a boolean signal accessor
63
+ */
64
+ declare function createSelector<T>(source: () => T, equalityFn?: (a: T, b: T) => boolean): (key: T) => boolean;
65
+
66
+ export { type ComputedAccessor as C, type MemoOptions as M, type SignalAccessor as S, __resetReactiveState as _, type SignalOptions as a, createSelector as c, effectScope as e, signal as s };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Fict reactive runtime",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -12,8 +12,6 @@
12
12
  "signals",
13
13
  "ui"
14
14
  ],
15
- "author": "Michael Lin",
16
- "license": "MIT",
17
15
  "repository": {
18
16
  "type": "git",
19
17
  "url": "https://github.com/fictjs/fict.git",
@@ -40,6 +38,11 @@
40
38
  "import": "./dist/advanced.js",
41
39
  "require": "./dist/advanced.cjs"
42
40
  },
41
+ "./loader": {
42
+ "types": "./dist/loader.d.ts",
43
+ "import": "./dist/loader.js",
44
+ "require": "./dist/loader.cjs"
45
+ },
43
46
  "./jsx-runtime": {
44
47
  "types": "./dist/jsx-runtime.d.ts",
45
48
  "import": "./dist/jsx-runtime.js",
@@ -59,6 +62,8 @@
59
62
  "jsdom": "^27.4.0",
60
63
  "tsup": "^8.5.1"
61
64
  },
65
+ "author": "unadlib",
66
+ "license": "MIT",
62
67
  "scripts": {
63
68
  "build": "tsup",
64
69
  "dev": "tsup --watch",
package/src/binding.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  SVGNamespace,
22
22
  } from './constants'
23
23
  import { createRenderEffect } from './effect'
24
+ import { withHydrationRange, isHydratingActive } from './hydration'
24
25
  import { Fragment } from './jsx'
25
26
  import {
26
27
  createRootContext,
@@ -35,6 +36,7 @@ import {
35
36
  type RootContext,
36
37
  } from './lifecycle'
37
38
  import { toNodeArray, removeNodes, insertNodesBefore } from './node-ops'
39
+ import { __fictIsHydrating } from './resume'
38
40
  import { batch } from './scheduler'
39
41
  import { computed, untrack, isSignal, isComputed, isEffect, isEffectScope } from './signal'
40
42
  import type { Cleanup, FictNode } from './types'
@@ -716,6 +718,180 @@ export function insert(
716
718
  }
717
719
  }
718
720
 
721
+ /**
722
+ * Insert reactive content between two marker comments.
723
+ * Supports hydration by claiming existing nodes between markers.
724
+ */
725
+ export function insertBetween(
726
+ start: Comment,
727
+ end: Comment,
728
+ getValue: () => FictNode,
729
+ createElementFn?: CreateElementFn,
730
+ ): Cleanup {
731
+ const hostRoot = getCurrentRoot()
732
+ let currentNodes: Node[] = []
733
+ let currentText: Text | null = null
734
+ let currentRoot: RootContext | null = null
735
+ let initialHydrating = __fictIsHydrating()
736
+
737
+ const collectBetween = (): Node[] => {
738
+ const nodes: Node[] = []
739
+ let cursor = start.nextSibling
740
+ while (cursor && cursor !== end) {
741
+ nodes.push(cursor)
742
+ cursor = cursor.nextSibling
743
+ }
744
+ return nodes
745
+ }
746
+
747
+ const clearCurrentNodes = () => {
748
+ if (currentNodes.length > 0) {
749
+ removeNodes(currentNodes)
750
+ currentNodes = []
751
+ }
752
+ }
753
+
754
+ const setTextNode = (textValue: string, shouldInsert: boolean) => {
755
+ if (!currentText) {
756
+ currentText = document.createTextNode(textValue)
757
+ } else if (currentText.data !== textValue) {
758
+ currentText.data = textValue
759
+ }
760
+
761
+ if (!shouldInsert) {
762
+ clearCurrentNodes()
763
+ return
764
+ }
765
+
766
+ if (currentNodes.length === 1 && currentNodes[0] === currentText) {
767
+ return
768
+ }
769
+
770
+ clearCurrentNodes()
771
+ const parentNode = start.parentNode as (ParentNode & Node) | null
772
+ if (parentNode) {
773
+ insertNodesBefore(parentNode, [currentText], end)
774
+ currentNodes = [currentText]
775
+ }
776
+ }
777
+
778
+ const dispose = createRenderEffect(() => {
779
+ const value = getValue()
780
+ const parentNode = start.parentNode as (ParentNode & Node) | null
781
+ const isPrimitive =
782
+ value == null ||
783
+ value === false ||
784
+ typeof value === 'string' ||
785
+ typeof value === 'number' ||
786
+ typeof value === 'boolean'
787
+
788
+ if (isPrimitive) {
789
+ if (initialHydrating && isHydratingActive() && parentNode) {
790
+ const existing = collectBetween()
791
+ if (existing.length > 0) {
792
+ currentNodes = existing
793
+ const only = existing.length === 1 ? existing[0] : null
794
+ currentText = only && only.nodeType === 3 ? (only as Text) : null
795
+ }
796
+ }
797
+ if (currentRoot) {
798
+ destroyRoot(currentRoot)
799
+ currentRoot = null
800
+ }
801
+ if (!parentNode) {
802
+ clearCurrentNodes()
803
+ return
804
+ }
805
+ const textValue = value == null || value === false ? '' : String(value)
806
+ const shouldInsert = value != null && value !== false
807
+ setTextNode(textValue, shouldInsert)
808
+ initialHydrating = false
809
+ return
810
+ }
811
+
812
+ if (currentRoot) {
813
+ destroyRoot(currentRoot)
814
+ currentRoot = null
815
+ }
816
+ clearCurrentNodes()
817
+
818
+ const root = createRootContext(hostRoot)
819
+ const prev = pushRoot(root)
820
+ let nodes: Node[] = []
821
+ let handledError = false
822
+ try {
823
+ let newNode: Node | Node[] = undefined as unknown as Node | Node[]
824
+ const createValue = () => {
825
+ if (value instanceof Node) {
826
+ return value
827
+ }
828
+ if (Array.isArray(value)) {
829
+ if (value.every(v => v instanceof Node)) {
830
+ return value as Node[]
831
+ }
832
+ if (createElementFn) {
833
+ const mapped: Node[] = []
834
+ for (const item of value) {
835
+ mapped.push(...toNodeArray(createElementFn(item as any)))
836
+ }
837
+ return mapped
838
+ }
839
+ return document.createTextNode(String(value))
840
+ }
841
+ return createElementFn ? createElementFn(value) : document.createTextNode(String(value))
842
+ }
843
+
844
+ if (initialHydrating && isHydratingActive() && parentNode) {
845
+ withHydrationRange(start.nextSibling, end, parentNode.ownerDocument ?? document, () => {
846
+ newNode = createValue()
847
+ })
848
+ } else {
849
+ newNode = createValue()
850
+ }
851
+
852
+ nodes = toNodeArray(newNode)
853
+ if (root.suspended) {
854
+ handledError = true
855
+ destroyRoot(root)
856
+ return
857
+ }
858
+ if (parentNode && !initialHydrating) {
859
+ insertNodesBefore(parentNode, nodes, end)
860
+ }
861
+ } catch (err) {
862
+ if (handleSuspend(err as any, root)) {
863
+ handledError = true
864
+ destroyRoot(root)
865
+ return
866
+ }
867
+ if (handleError(err, { source: 'renderChild' }, root)) {
868
+ handledError = true
869
+ destroyRoot(root)
870
+ return
871
+ }
872
+ throw err
873
+ } finally {
874
+ popRoot(prev)
875
+ if (!handledError) {
876
+ flushOnMount(root)
877
+ }
878
+ }
879
+
880
+ currentRoot = root
881
+ currentNodes = initialHydrating ? collectBetween() : nodes
882
+ initialHydrating = false
883
+ })
884
+
885
+ return () => {
886
+ dispose()
887
+ if (currentRoot) {
888
+ destroyRoot(currentRoot)
889
+ currentRoot = null
890
+ }
891
+ clearCurrentNodes()
892
+ }
893
+ }
894
+
719
895
  /**
720
896
  * Create a reactive child binding that updates when the child value changes.
721
897
  * This is used for dynamic expressions like `{show && <Modal />}` or `{items.map(...)}`.
@@ -1299,7 +1475,11 @@ function assignProp(
1299
1475
  // Event handling: on:eventname
1300
1476
  if (prop.slice(0, 3) === 'on:') {
1301
1477
  const eventName = prop.slice(3)
1302
- if (prev) node.removeEventListener(eventName, prev as EventListener)
1478
+ if (typeof value === 'string') {
1479
+ node.setAttribute(prop, value)
1480
+ return value
1481
+ }
1482
+ if (prev && typeof prev !== 'string') node.removeEventListener(eventName, prev as EventListener)
1303
1483
  if (value) node.addEventListener(eventName, value as EventListener)
1304
1484
  return value
1305
1485
  }
@@ -1433,17 +1613,33 @@ export function createConditional(
1433
1613
  renderTrue: () => FictNode,
1434
1614
  createElementFn: CreateElementFn,
1435
1615
  renderFalse?: () => FictNode,
1616
+ startOverride?: Comment,
1617
+ endOverride?: Comment,
1436
1618
  ): BindingHandle {
1437
- const startMarker = document.createComment('fict:cond:start')
1438
- const endMarker = document.createComment('fict:cond:end')
1439
- const fragment = document.createDocumentFragment()
1440
- fragment.append(startMarker, endMarker)
1619
+ const useProvided = !!(startOverride && endOverride)
1620
+ const startMarker = useProvided ? startOverride! : document.createComment('fict:cond:start')
1621
+ const endMarker = useProvided ? endOverride! : document.createComment('fict:cond:end')
1622
+ const fragment = useProvided ? startMarker : document.createDocumentFragment()
1623
+ if (!useProvided) {
1624
+ ;(fragment as DocumentFragment).append(startMarker, endMarker)
1625
+ }
1441
1626
  const hostRoot = getCurrentRoot()
1442
1627
 
1443
1628
  let currentNodes: Node[] = []
1444
1629
  let currentRoot: RootContext | null = null
1445
1630
  let lastCondition: boolean | undefined = undefined
1446
1631
  let pendingRender = false
1632
+ let initialHydrating = __fictIsHydrating()
1633
+
1634
+ const collectBetween = (): Node[] => {
1635
+ const nodes: Node[] = []
1636
+ let cursor = startMarker.nextSibling
1637
+ while (cursor && cursor !== endMarker) {
1638
+ nodes.push(cursor)
1639
+ cursor = cursor.nextSibling
1640
+ }
1641
+ return nodes
1642
+ }
1447
1643
 
1448
1644
  // Use computed to memoize condition value - this prevents the effect from
1449
1645
  // re-running when condition dependencies change but the boolean result stays same.
@@ -1460,6 +1656,59 @@ export function createConditional(
1460
1656
  }
1461
1657
  pendingRender = false
1462
1658
 
1659
+ if (initialHydrating && isHydratingActive()) {
1660
+ initialHydrating = false
1661
+ lastCondition = cond
1662
+
1663
+ const render = cond ? renderTrue : renderFalse
1664
+ if (!render) {
1665
+ currentNodes = collectBetween()
1666
+ return
1667
+ }
1668
+
1669
+ const root = createRootContext(hostRoot)
1670
+ const prev = pushRoot(root)
1671
+ let handledError = false
1672
+ try {
1673
+ // Call render() INSIDE withHydrationRange so that template() and insertBetween
1674
+ // see the correct hydration context for the conditional content
1675
+ withHydrationRange(
1676
+ startMarker.nextSibling,
1677
+ endMarker,
1678
+ parent.ownerDocument ?? document,
1679
+ () => {
1680
+ const output = untrack(render)
1681
+ if (output == null || output === false) {
1682
+ return
1683
+ }
1684
+ createElementFn(output)
1685
+ },
1686
+ )
1687
+ currentNodes = collectBetween()
1688
+ } catch (err) {
1689
+ if (handleSuspend(err as any, root)) {
1690
+ handledError = true
1691
+ destroyRoot(root)
1692
+ return
1693
+ }
1694
+ if (handleError(err, { source: 'renderChild' }, root)) {
1695
+ handledError = true
1696
+ destroyRoot(root)
1697
+ return
1698
+ }
1699
+ throw err
1700
+ } finally {
1701
+ popRoot(prev)
1702
+ if (!handledError) {
1703
+ flushOnMount(root)
1704
+ currentRoot = root
1705
+ } else {
1706
+ currentRoot = null
1707
+ }
1708
+ }
1709
+ return
1710
+ }
1711
+
1463
1712
  if (lastCondition === cond && currentNodes.length > 0) {
1464
1713
  return
1465
1714
  }
package/src/dom.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { Properties, ChildProperties, getPropAlias, SVGElements, SVGNamespace } from './constants'
28
28
  import { getDevtoolsHook } from './devtools'
29
29
  import { __fictPushContext, __fictPopContext, __fictGetCurrentComponentId } from './hooks'
30
+ import { claimNodes, isHydratingActive, withHydration } from './hydration'
30
31
  import { Fragment } from './jsx'
31
32
  import {
32
33
  createRootContext,
@@ -42,6 +43,13 @@ import {
42
43
  onCleanup,
43
44
  } from './lifecycle'
44
45
  import { createPropsProxy, unwrapProps } from './props'
46
+ import {
47
+ __fictIsHydrating,
48
+ __fictIsResumable,
49
+ __fictRegisterScope,
50
+ __fictEnterHydration,
51
+ __fictExitHydration,
52
+ } from './resume'
45
53
  import { untrack } from './scheduler'
46
54
  import type { DOMElement, FictNode, FictVNode, RefObject } from './types'
47
55
 
@@ -76,17 +84,25 @@ let nextComponentId = 1
76
84
  export function render(view: () => FictNode, container: HTMLElement): () => void {
77
85
  const root = createRootContext()
78
86
  const prev = pushRoot(root)
79
- let dom: DOMElement
87
+ let dom: DOMElement = undefined as unknown as DOMElement
80
88
  try {
81
89
  const output = view()
82
90
  // createElement must be called within the root context
83
91
  // so that child components register their onMount callbacks correctly
84
- dom = createElement(output)
92
+ if (__fictIsHydrating()) {
93
+ withHydration(container, () => {
94
+ dom = createElement(output)
95
+ })
96
+ } else {
97
+ dom = createElement(output)
98
+ }
85
99
  } finally {
86
100
  popRoot(prev)
87
101
  }
88
102
 
89
- container.replaceChildren(dom)
103
+ if (!__fictIsHydrating()) {
104
+ container.replaceChildren(dom)
105
+ }
90
106
  container.setAttribute('data-fict-fine-grained', '1')
91
107
 
92
108
  flushOnMount(root)
@@ -99,6 +115,42 @@ export function render(view: () => FictNode, container: HTMLElement): () => void
99
115
  return teardown
100
116
  }
101
117
 
118
+ /**
119
+ * Hydrate a component into an existing DOM container.
120
+ * Unlike render(), this runs the view function INSIDE the hydration context
121
+ * so that template() can claim existing DOM nodes.
122
+ *
123
+ * @param view - A function that returns the view to hydrate
124
+ * @param container - The DOM container with existing SSR content
125
+ * @returns A teardown function to unmount the view
126
+ */
127
+ export function hydrateComponent(view: () => FictNode, container: HTMLElement): () => void {
128
+ const root = createRootContext()
129
+ const prev = pushRoot(root)
130
+
131
+ // Enable hydration flags for bindings that check __fictIsHydrating()
132
+ __fictEnterHydration()
133
+
134
+ try {
135
+ // Run the view function INSIDE withHydration so template() can claim nodes
136
+ withHydration(container, () => {
137
+ view()
138
+ })
139
+ } finally {
140
+ __fictExitHydration()
141
+ popRoot(prev)
142
+ }
143
+
144
+ container.setAttribute('data-fict-fine-grained', '1')
145
+ flushOnMount(root)
146
+
147
+ const teardown = () => {
148
+ destroyRoot(root)
149
+ }
150
+
151
+ return teardown
152
+ }
153
+
102
154
  // ============================================================================
103
155
  // Element Creation
104
156
  // ============================================================================
@@ -140,6 +192,15 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
140
192
  return document.createTextNode('')
141
193
  }
142
194
 
195
+ // Reactive getter function - resolve to actual node
196
+ if (isReactive(node)) {
197
+ const resolved = (node as () => FictNode)()
198
+ if (resolved === node) {
199
+ return document.createTextNode('')
200
+ }
201
+ return createElementWithContext(resolved, namespace)
202
+ }
203
+
143
204
  if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
144
205
  // Handle BindingHandle (list/conditional bindings, etc)
145
206
  if ('marker' in node) {
@@ -241,6 +302,32 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
241
302
  })
242
303
  onCleanup(() => hook.componentUnmount?.(componentId))
243
304
  }
305
+ if (__fictIsResumable() && !__fictIsHydrating()) {
306
+ const content = createElementWithContext(rendered as FictNode, namespace)
307
+ const host =
308
+ namespace === 'svg'
309
+ ? document.createElementNS(SVG_NS, 'fict-host')
310
+ : namespace === 'mathml'
311
+ ? document.createElementNS(MATHML_NS, 'fict-host')
312
+ : document.createElement('fict-host')
313
+ host.setAttribute('data-fict-host', '')
314
+ if (namespace === null && (host as HTMLElement).style) {
315
+ ;(host as HTMLElement).style.display = 'contents'
316
+ }
317
+ const meta = (vnode.type as unknown as { __fictMeta?: { id?: string; resume?: string } })
318
+ .__fictMeta
319
+ const typeKey = (meta?.id ?? vnode.type.name) || 'Anonymous'
320
+ __fictRegisterScope(ctx, host, typeKey, rawProps)
321
+ if (meta?.resume) {
322
+ host.setAttribute('data-fict-h', meta.resume)
323
+ }
324
+ if (content instanceof DocumentFragment) {
325
+ host.append(...Array.from(content.childNodes))
326
+ } else {
327
+ host.appendChild(content)
328
+ }
329
+ return host as DOMElement
330
+ }
244
331
 
245
332
  return createElementWithContext(rendered as FictNode, namespace)
246
333
  } catch (err) {
@@ -359,8 +446,19 @@ export function template(
359
446
 
360
447
  // Create the cloning function
361
448
  const fn = isImportNode
362
- ? () => untrack(() => document.importNode(node || (node = create()), true))
363
- : () => (node || (node = create())).cloneNode(true)
449
+ ? () =>
450
+ untrack(() => {
451
+ const base = node || (node = create())
452
+ return isHydratingActive()
453
+ ? claimNodes(base, () => document.importNode(base, true))
454
+ : document.importNode(base, true)
455
+ })
456
+ : () => {
457
+ const base = node || (node = create())
458
+ return isHydratingActive()
459
+ ? claimNodes(base, () => base.cloneNode(true))
460
+ : base.cloneNode(true)
461
+ }
364
462
 
365
463
  // Add cloneNode property for compatibility
366
464
  ;(fn as { cloneNode?: typeof fn }).cloneNode = fn
package/src/hooks.ts CHANGED
@@ -13,15 +13,19 @@ const isDev =
13
13
  ? __DEV__
14
14
  : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
15
15
 
16
- interface HookContext {
16
+ export interface HookContext {
17
17
  slots: unknown[]
18
18
  cursor: number
19
19
  rendering?: boolean
20
20
  componentId?: number
21
21
  parentId?: number
22
+ scopeId?: string
23
+ scopeType?: string
24
+ slotMap?: Record<string, number>
22
25
  }
23
26
 
24
27
  const ctxStack: HookContext[] = []
28
+ let preparedContext: HookContext | null = null
25
29
 
26
30
  function assertRenderContext(ctx: HookContext, hookName: string): void {
27
31
  if (!ctx.rendering) {
@@ -54,11 +58,16 @@ export function __fictUseContext(): HookContext {
54
58
  }
55
59
 
56
60
  export function __fictPushContext(): HookContext {
57
- const ctx: HookContext = { slots: [], cursor: 0 }
61
+ const ctx: HookContext = preparedContext ?? { slots: [], cursor: 0 }
62
+ preparedContext = null
58
63
  ctxStack.push(ctx)
59
64
  return ctx
60
65
  }
61
66
 
67
+ export function __fictPrepareContext(ctx: HookContext): void {
68
+ preparedContext = ctx
69
+ }
70
+
62
71
  export function __fictGetCurrentComponentId(): number | undefined {
63
72
  return ctxStack[ctxStack.length - 1]?.componentId
64
73
  }
@@ -86,6 +95,10 @@ export function __fictUseSignal<T>(
86
95
  if (!ctx.slots[index]) {
87
96
  ctx.slots[index] = createSignal(initial, options)
88
97
  }
98
+ if (options?.name) {
99
+ if (!ctx.slotMap) ctx.slotMap = {}
100
+ ctx.slotMap[options.name] = index
101
+ }
89
102
  return ctx.slots[index] as SignalAccessor<T>
90
103
  }
91
104