@fictjs/runtime 0.0.15 → 0.2.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 (55) hide show
  1. package/dist/advanced.cjs +8 -8
  2. package/dist/advanced.d.cts +3 -3
  3. package/dist/advanced.d.ts +3 -3
  4. package/dist/advanced.js +3 -3
  5. package/dist/{chunk-GJTYOFMO.cjs → chunk-527QSKFM.cjs} +16 -16
  6. package/dist/{chunk-GJTYOFMO.cjs.map → chunk-527QSKFM.cjs.map} +1 -1
  7. package/dist/{chunk-RY4WDS6R.js → chunk-5KXEEQUO.js} +115 -20
  8. package/dist/chunk-5KXEEQUO.js.map +1 -0
  9. package/dist/{chunk-624QY53A.cjs → chunk-BSUMPMKX.cjs} +7 -7
  10. package/dist/chunk-BSUMPMKX.cjs.map +1 -0
  11. package/dist/{chunk-IUZXKAAY.js → chunk-FG3M7EBL.js} +2 -2
  12. package/dist/{chunk-PMF6MWEV.cjs → chunk-J74L7UYP.cjs} +128 -33
  13. package/dist/chunk-J74L7UYP.cjs.map +1 -0
  14. package/dist/{chunk-F3AIYQB7.js → chunk-QV5GOCR5.js} +3 -3
  15. package/dist/chunk-QV5GOCR5.js.map +1 -0
  16. package/dist/{context-B7UYnfzM.d.ts → context-4woHo7-L.d.ts} +1 -1
  17. package/dist/{context-UXySaqI_.d.cts → context-9gFXOdJl.d.cts} +1 -1
  18. package/dist/{effect-Auji1rz9.d.cts → effect-ClARNUCc.d.cts} +23 -2
  19. package/dist/{effect-Auji1rz9.d.ts → effect-ClARNUCc.d.ts} +23 -2
  20. package/dist/index.cjs +51 -54
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +4 -4
  23. package/dist/index.d.ts +4 -4
  24. package/dist/index.dev.js +96 -28
  25. package/dist/index.dev.js.map +1 -1
  26. package/dist/index.js +10 -13
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal.cjs +44 -35
  29. package/dist/internal.cjs.map +1 -1
  30. package/dist/internal.d.cts +9 -4
  31. package/dist/internal.d.ts +9 -4
  32. package/dist/internal.js +12 -3
  33. package/dist/internal.js.map +1 -1
  34. package/dist/jsx-runtime.d.cts +671 -0
  35. package/dist/jsx-runtime.d.ts +671 -0
  36. package/dist/{props-ES0Ag_Wd.d.ts → props-CBwuh35e.d.cts} +13 -6
  37. package/dist/{props-CrOMYbLv.d.cts → props-DAyeRPwH.d.ts} +13 -6
  38. package/dist/{scope-S6eAzBJZ.d.ts → scope-DvgMquEy.d.ts} +1 -1
  39. package/dist/{scope-DKYzWfTn.d.cts → scope-xmdo6lVU.d.cts} +1 -1
  40. package/package.json +1 -1
  41. package/src/binding.ts +62 -8
  42. package/src/constants.ts +2 -3
  43. package/src/dev-entry.ts +22 -0
  44. package/src/effect.ts +9 -2
  45. package/src/lifecycle.ts +24 -6
  46. package/src/list-helpers.ts +14 -3
  47. package/src/props.ts +29 -3
  48. package/src/scope.ts +1 -1
  49. package/src/signal.ts +43 -4
  50. package/src/suspense.ts +17 -13
  51. package/dist/chunk-624QY53A.cjs.map +0 -1
  52. package/dist/chunk-F3AIYQB7.js.map +0 -1
  53. package/dist/chunk-PMF6MWEV.cjs.map +0 -1
  54. package/dist/chunk-RY4WDS6R.js.map +0 -1
  55. /package/dist/{chunk-IUZXKAAY.js.map → chunk-FG3M7EBL.js.map} +0 -0
@@ -1,17 +1,20 @@
1
- import { C as Cleanup, F as FictNode, D as DOMElement } from './effect-Auji1rz9.js';
2
-
3
- type Memo<T> = () => T;
4
- declare function createMemo<T>(fn: () => T): Memo<T>;
1
+ import { C as Cleanup, F as FictNode, D as DOMElement } from './effect-ClARNUCc.cjs';
5
2
 
6
3
  type LifecycleFn = () => void | Cleanup;
4
+ interface CreateRootOptions {
5
+ inherit?: boolean;
6
+ }
7
7
  declare function onMount(fn: LifecycleFn): void;
8
8
  declare function onDestroy(fn: LifecycleFn): void;
9
9
  declare function onCleanup(fn: Cleanup): void;
10
- declare function createRoot<T>(fn: () => T): {
10
+ declare function createRoot<T>(fn: () => T, options?: CreateRootOptions): {
11
11
  dispose: () => void;
12
12
  value: T;
13
13
  };
14
14
 
15
+ type Memo<T> = () => T;
16
+ declare function createMemo<T>(fn: () => T): Memo<T>;
17
+
15
18
  declare const Fragment: unique symbol;
16
19
  declare namespace JSX {
17
20
  type Element = FictNode;
@@ -741,9 +744,13 @@ declare function mergeProps<T extends Record<string, unknown>>(...sources: (Merg
741
744
  type PropGetter<T> = (() => T) & {
742
745
  __fictProp: true;
743
746
  };
747
+ interface PropOptions {
748
+ unwrap?: boolean;
749
+ }
744
750
  /**
745
751
  * Memoize a prop getter to cache expensive computations.
746
752
  * Use when prop expressions involve heavy calculations or you need lazy, reactive props.
753
+ * Set { unwrap: false } to keep nested prop getters as values.
747
754
  *
748
755
  * @example
749
756
  * ```tsx
@@ -755,6 +762,6 @@ type PropGetter<T> = (() => T) & {
755
762
  * <Child data={memoizedData} />
756
763
  * ```
757
764
  */
758
- declare function prop<T>(getter: () => T): PropGetter<T>;
765
+ declare function prop<T>(getter: () => T, options?: PropOptions): PropGetter<T>;
759
766
 
760
767
  export { Fragment as F, JSX as J, type Memo as M, __fictProp as _, onDestroy as a, onCleanup as b, createMemo as c, createRoot as d, createElement as e, __fictPropsRest as f, createPropsProxy as g, mergeProps as m, onMount as o, prop as p, render as r, template as t };
@@ -1,17 +1,20 @@
1
- import { C as Cleanup, F as FictNode, D as DOMElement } from './effect-Auji1rz9.cjs';
2
-
3
- type Memo<T> = () => T;
4
- declare function createMemo<T>(fn: () => T): Memo<T>;
1
+ import { C as Cleanup, F as FictNode, D as DOMElement } from './effect-ClARNUCc.js';
5
2
 
6
3
  type LifecycleFn = () => void | Cleanup;
4
+ interface CreateRootOptions {
5
+ inherit?: boolean;
6
+ }
7
7
  declare function onMount(fn: LifecycleFn): void;
8
8
  declare function onDestroy(fn: LifecycleFn): void;
9
9
  declare function onCleanup(fn: Cleanup): void;
10
- declare function createRoot<T>(fn: () => T): {
10
+ declare function createRoot<T>(fn: () => T, options?: CreateRootOptions): {
11
11
  dispose: () => void;
12
12
  value: T;
13
13
  };
14
14
 
15
+ type Memo<T> = () => T;
16
+ declare function createMemo<T>(fn: () => T): Memo<T>;
17
+
15
18
  declare const Fragment: unique symbol;
16
19
  declare namespace JSX {
17
20
  type Element = FictNode;
@@ -741,9 +744,13 @@ declare function mergeProps<T extends Record<string, unknown>>(...sources: (Merg
741
744
  type PropGetter<T> = (() => T) & {
742
745
  __fictProp: true;
743
746
  };
747
+ interface PropOptions {
748
+ unwrap?: boolean;
749
+ }
744
750
  /**
745
751
  * Memoize a prop getter to cache expensive computations.
746
752
  * Use when prop expressions involve heavy calculations or you need lazy, reactive props.
753
+ * Set { unwrap: false } to keep nested prop getters as values.
747
754
  *
748
755
  * @example
749
756
  * ```tsx
@@ -755,6 +762,6 @@ type PropGetter<T> = (() => T) & {
755
762
  * <Child data={memoizedData} />
756
763
  * ```
757
764
  */
758
- declare function prop<T>(getter: () => T): PropGetter<T>;
765
+ declare function prop<T>(getter: () => T, options?: PropOptions): PropGetter<T>;
759
766
 
760
767
  export { Fragment as F, JSX as J, type Memo as M, __fictProp as _, onDestroy as a, onCleanup as b, createMemo as c, createRoot as d, createElement as e, __fictPropsRest as f, createPropsProxy as g, mergeProps as m, onMount as o, prop as p, render as r, template as t };
@@ -1,4 +1,4 @@
1
- import { O as MaybeReactive } from './effect-Auji1rz9.js';
1
+ import { O as MaybeReactive } from './effect-ClARNUCc.js';
2
2
 
3
3
  /**
4
4
  * Signal accessor - function to get/set signal value
@@ -1,4 +1,4 @@
1
- import { O as MaybeReactive } from './effect-Auji1rz9.cjs';
1
+ import { O as MaybeReactive } from './effect-ClARNUCc.cjs';
2
2
 
3
3
  /**
4
4
  * Signal accessor - function to get/set signal value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/runtime",
3
- "version": "0.0.15",
3
+ "version": "0.2.0",
4
4
  "description": "Fict reactive runtime",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/binding.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  } from './lifecycle'
37
37
  import { toNodeArray, removeNodes, insertNodesBefore } from './node-ops'
38
38
  import { batch } from './scheduler'
39
- import { computed, untrack } from './signal'
39
+ import { computed, untrack, isSignal, isComputed, isEffect, isEffectScope } from './signal'
40
40
  import type { Cleanup, FictNode } from './types'
41
41
 
42
42
  const isDev =
@@ -70,11 +70,43 @@ export interface BindingHandle {
70
70
  // ============================================================================
71
71
 
72
72
  /**
73
- * Check if a value is reactive (a getter function)
74
- * Note: Event handlers (functions that take arguments) are NOT reactive values
73
+ * Check if a value is reactive (a getter function that returns a value).
74
+ *
75
+ * A value is considered reactive if:
76
+ * 1. It's a signal or computed value created by the runtime (marked with Symbol)
77
+ * 2. It's a zero-argument function (getter pattern used by the compiler)
78
+ *
79
+ * NOT considered reactive:
80
+ * - Event handlers (functions that take arguments)
81
+ * - Effect disposers (zero-arg but for cleanup, not value access)
82
+ * - Effect scopes (zero-arg but for scope management)
83
+ *
84
+ * @param value - The value to check
85
+ * @returns true if the value is a reactive getter
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * const [count, setCount] = createSignal(0)
90
+ * isReactive(count) // true - signal accessor
91
+ * isReactive(() => 42) // true - getter pattern
92
+ * isReactive((x) => x) // false - takes argument
93
+ * isReactive('hello') // false - not a function
94
+ * isReactive(effectDisposer) // false - effect cleanup function
95
+ * ```
75
96
  */
76
97
  export function isReactive(value: unknown): value is () => unknown {
77
- return typeof value === 'function' && value.length === 0
98
+ if (typeof value !== 'function') return false
99
+
100
+ // Check for runtime-created signals/computed (most reliable)
101
+ if (isSignal(value) || isComputed(value)) return true
102
+
103
+ // Exclude effect disposers and effect scopes - they are zero-arg
104
+ // functions but not reactive getters
105
+ if (isEffect(value) || isEffectScope(value)) return false
106
+
107
+ // Fall back to length check for compiler-generated getters
108
+ // Zero-argument functions are treated as reactive getters
109
+ return value.length === 0
78
110
  }
79
111
 
80
112
  /**
@@ -590,6 +622,7 @@ export function insert(
590
622
  const root = createRootContext(hostRoot)
591
623
  const prev = pushRoot(root)
592
624
  let nodes: Node[] = []
625
+ let handledError = false
593
626
  try {
594
627
  let newNode: Node | Node[]
595
628
 
@@ -614,14 +647,34 @@ export function insert(
614
647
  }
615
648
 
616
649
  nodes = toNodeArray(newNode)
650
+ if (root.suspended) {
651
+ handledError = true
652
+ destroyRoot(root)
653
+ return
654
+ }
617
655
  if (parentNode) {
618
656
  insertNodesBefore(parentNode, nodes, marker)
619
657
  }
658
+ } catch (err) {
659
+ if (handleSuspend(err as any, root)) {
660
+ handledError = true
661
+ destroyRoot(root)
662
+ return
663
+ }
664
+ if (handleError(err, { source: 'renderChild' }, root)) {
665
+ handledError = true
666
+ destroyRoot(root)
667
+ return
668
+ }
669
+ throw err
620
670
  } finally {
621
671
  popRoot(prev)
622
- flushOnMount(root)
672
+ if (!handledError) {
673
+ flushOnMount(root)
674
+ }
623
675
  }
624
676
 
677
+ // If we reach here, no error was handled (handledError blocks return early)
625
678
  currentRoot = root
626
679
  currentNodes = nodes
627
680
  })
@@ -952,9 +1005,10 @@ export function bindEvent(
952
1005
  const rootRef = getCurrentRoot()
953
1006
 
954
1007
  // Optimization: Global Event Delegation
955
- // If the event is delegatable and no special options (capture, passive) are used,
1008
+ // If the event is delegatable and no options were provided,
956
1009
  // we attach the handler to the element property and rely on the global listener.
957
- if (isDev && DelegatedEvents.has(eventName) && !options) {
1010
+ const shouldDelegate = options == null && DelegatedEvents.has(eventName)
1011
+ if (shouldDelegate) {
958
1012
  const key = `$$${eventName}`
959
1013
 
960
1014
  // Ensure global delegation is active for this event
@@ -1235,7 +1289,7 @@ function assignProp(
1235
1289
  // Standard event handling: onClick, onInput, etc.
1236
1290
  if (prop.slice(0, 2) === 'on') {
1237
1291
  const eventName = prop.slice(2).toLowerCase()
1238
- const shouldDelegate = isDev && DelegatedEvents.has(eventName)
1292
+ const shouldDelegate = DelegatedEvents.has(eventName)
1239
1293
  if (!shouldDelegate && prev) {
1240
1294
  const handler = Array.isArray(prev) ? prev[0] : prev
1241
1295
  node.removeEventListener(eventName, handler as EventListener)
package/src/constants.ts CHANGED
@@ -263,10 +263,9 @@ export const $$EVENTS = '_$FICT_DELEGATE'
263
263
  /**
264
264
  * Events that should use event delegation for performance
265
265
  * These events bubble and are commonly used across many elements
266
+ * Note: This must match the compiler's DelegatedEvents set
266
267
  */
267
- const delegatedEvents = isDev ? DelegatedEventNames : []
268
-
269
- export const DelegatedEvents = new Set<string>(delegatedEvents)
268
+ export const DelegatedEvents = new Set<string>(DelegatedEventNames)
270
269
 
271
270
  // ============================================================================
272
271
  // SVG Support
@@ -0,0 +1,22 @@
1
+ // Unified runtime entry for dev/test aliasing.
2
+ // Re-export internal compiler APIs plus public runtime exports so all code
3
+ // shares a single reactive instance.
4
+ export * from './internal'
5
+ export {
6
+ batch,
7
+ createContext,
8
+ createRef,
9
+ createRoot,
10
+ ErrorBoundary,
11
+ hasContext,
12
+ onCleanup,
13
+ onMount,
14
+ render,
15
+ startTransition,
16
+ Suspense,
17
+ createSuspenseToken,
18
+ untrack,
19
+ useContext,
20
+ useDeferredValue,
21
+ useTransition,
22
+ } from './index'
package/src/effect.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  getCurrentRoot,
3
3
  handleError,
4
+ handleSuspend,
4
5
  registerRootCleanup,
5
6
  runCleanupList,
6
7
  withEffectCleanups,
@@ -34,6 +35,9 @@ export function createEffect(fn: Effect): () => void {
34
35
  bucket.push(maybeCleanup)
35
36
  }
36
37
  } catch (err) {
38
+ if (handleSuspend(err as any, rootForError)) {
39
+ return
40
+ }
37
41
  if (handleError(err, { source: 'effect' }, rootForError)) {
38
42
  return
39
43
  }
@@ -43,7 +47,7 @@ export function createEffect(fn: Effect): () => void {
43
47
  cleanups = bucket
44
48
  }
45
49
 
46
- const disposeEffect = effectWithCleanup(run, doCleanup)
50
+ const disposeEffect = effectWithCleanup(run, doCleanup, rootForError)
47
51
  const teardown = () => {
48
52
  runCleanupList(cleanups)
49
53
  disposeEffect()
@@ -76,6 +80,9 @@ export function createRenderEffect(fn: Effect): () => void {
76
80
  cleanup = maybeCleanup
77
81
  }
78
82
  } catch (err) {
83
+ if (handleSuspend(err as any, rootForError)) {
84
+ return
85
+ }
79
86
  const handled = handleError(err, { source: 'effect' }, rootForError)
80
87
  if (handled) {
81
88
  return
@@ -84,7 +91,7 @@ export function createRenderEffect(fn: Effect): () => void {
84
91
  }
85
92
  }
86
93
 
87
- const disposeEffect = effectWithCleanup(run, doCleanup)
94
+ const disposeEffect = effectWithCleanup(run, doCleanup, rootForError)
88
95
  const teardown = () => {
89
96
  if (cleanup) {
90
97
  cleanup()
package/src/lifecycle.ts CHANGED
@@ -15,6 +15,11 @@ export interface RootContext {
15
15
  destroyCallbacks: Cleanup[]
16
16
  errorHandlers?: ErrorHandler[]
17
17
  suspenseHandlers?: SuspenseHandler[]
18
+ suspended?: boolean
19
+ }
20
+
21
+ export interface CreateRootOptions {
22
+ inherit?: boolean
18
23
  }
19
24
 
20
25
  type ErrorHandler = (err: unknown, info?: ErrorInfo) => boolean | void
@@ -25,8 +30,8 @@ let currentEffectCleanups: Cleanup[] | undefined
25
30
  const globalErrorHandlers = new WeakMap<RootContext, ErrorHandler[]>()
26
31
  const globalSuspenseHandlers = new WeakMap<RootContext, SuspenseHandler[]>()
27
32
 
28
- export function createRootContext(parent: RootContext | undefined = currentRoot): RootContext {
29
- return { parent, cleanups: [], destroyCallbacks: [] }
33
+ export function createRootContext(parent?: RootContext): RootContext {
34
+ return { parent, cleanups: [], destroyCallbacks: [], suspended: false }
30
35
  }
31
36
 
32
37
  export function pushRoot(root: RootContext): RootContext | undefined {
@@ -111,8 +116,12 @@ export function destroyRoot(root: RootContext): void {
111
116
  }
112
117
  }
113
118
 
114
- export function createRoot<T>(fn: () => T): { dispose: () => void; value: T } {
115
- const root = createRootContext()
119
+ export function createRoot<T>(
120
+ fn: () => T,
121
+ options?: CreateRootOptions,
122
+ ): { dispose: () => void; value: T } {
123
+ const parent = options?.inherit ? currentRoot : undefined
124
+ const root = createRootContext(parent)
116
125
  const prev = pushRoot(root)
117
126
  let value: T
118
127
  try {
@@ -259,13 +268,18 @@ export function handleSuspend(
259
268
  startRoot?: RootContext,
260
269
  ): boolean {
261
270
  let root: RootContext | undefined = startRoot ?? currentRoot
271
+ const originRoot = root // Preserve reference to set suspended flag on success
262
272
  while (root) {
263
273
  const handlers = root.suspenseHandlers
264
274
  if (handlers && handlers.length) {
265
275
  for (let i = handlers.length - 1; i >= 0; i--) {
266
276
  const handler = handlers[i]!
267
277
  const handled = handler(token)
268
- if (handled !== false) return true
278
+ if (handled !== false) {
279
+ // Only set suspended = true when a handler actually handles the token
280
+ if (originRoot) originRoot.suspended = true
281
+ return true
282
+ }
269
283
  }
270
284
  }
271
285
  root = root.parent
@@ -280,7 +294,11 @@ export function handleSuspend(
280
294
  for (let i = globalForRoot.length - 1; i >= 0; i--) {
281
295
  const handler = globalForRoot[i]!
282
296
  const handled = handler(token)
283
- if (handled !== false) return true
297
+ if (handled !== false) {
298
+ // Only set suspended = true when a handler actually handles the token
299
+ if (originRoot) originRoot.suspended = true
300
+ return true
301
+ }
284
302
  }
285
303
  }
286
304
  return false
@@ -135,8 +135,17 @@ export function moveNodesBefore(parent: Node, nodes: Node[], anchor: Node | null
135
135
  try {
136
136
  const clone = parent.ownerDocument.importNode(node, true)
137
137
  parent.insertBefore(clone, anchor)
138
- // Note: Cloning during move breaks references in KeyedBlock.nodes
139
- // This is a worst-case fallback for tests.
138
+ // Update the nodes array with the clone to maintain correct references.
139
+ // This ensures future operations (like removal or reordering) work correctly.
140
+ nodes[i] = clone
141
+ if (isDev) {
142
+ console.warn(
143
+ `[fict] Node cloning fallback triggered during list reordering. ` +
144
+ `This may indicate cross-document node insertion. ` +
145
+ `The node reference has been updated to the clone.`,
146
+ )
147
+ }
148
+ anchor = clone
140
149
  continue
141
150
  } catch {
142
151
  // Clone fallback failed
@@ -491,7 +500,9 @@ function createFineGrainedKeyedList<T>(
491
500
  endParent === startParent &&
492
501
  (endParent as Node).nodeType !== 11
493
502
  ) {
494
- return endParent as ParentNode & Node
503
+ const parentNode = endParent as ParentNode & Node
504
+ if ('isConnected' in parentNode && !parentNode.isConnected) return null
505
+ return parentNode
495
506
  }
496
507
  return null
497
508
  }
package/src/props.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createMemo } from './memo'
2
2
 
3
+ const PROP_GETTER_MARKER = Symbol.for('fict:prop-getter')
3
4
  const propGetters = new WeakSet<(...args: unknown[]) => unknown>()
4
5
  const rawToProxy = new WeakMap<object, object>()
5
6
  const proxyToRaw = new WeakMap<object, object>()
@@ -12,12 +13,21 @@ const proxyToRaw = new WeakMap<object, object>()
12
13
  export function __fictProp<T>(getter: () => T): () => T {
13
14
  if (typeof getter === 'function' && getter.length === 0) {
14
15
  propGetters.add(getter)
16
+ if (Object.isExtensible(getter)) {
17
+ try {
18
+ ;(getter as (() => T) & { [PROP_GETTER_MARKER]?: boolean })[PROP_GETTER_MARKER] = true
19
+ } catch {
20
+ // Ignore marker failures on non-standard function objects.
21
+ }
22
+ }
15
23
  }
16
24
  return getter
17
25
  }
18
26
 
19
27
  function isPropGetter(value: unknown): value is () => unknown {
20
- return typeof value === 'function' && propGetters.has(value as (...args: unknown[]) => unknown)
28
+ if (typeof value !== 'function') return false
29
+ const fn = value as (() => unknown) & { [PROP_GETTER_MARKER]?: boolean }
30
+ return propGetters.has(fn as (...args: unknown[]) => unknown) || fn[PROP_GETTER_MARKER] === true
21
31
  }
22
32
 
23
33
  export function createPropsProxy<T extends Record<string, unknown>>(props: T): T {
@@ -189,9 +199,14 @@ export function mergeProps<T extends Record<string, unknown>>(
189
199
  }
190
200
 
191
201
  export type PropGetter<T> = (() => T) & { __fictProp: true }
202
+
203
+ export interface PropOptions {
204
+ unwrap?: boolean
205
+ }
192
206
  /**
193
207
  * Memoize a prop getter to cache expensive computations.
194
208
  * Use when prop expressions involve heavy calculations or you need lazy, reactive props.
209
+ * Set { unwrap: false } to keep nested prop getters as values.
195
210
  *
196
211
  * @example
197
212
  * ```tsx
@@ -203,10 +218,21 @@ export type PropGetter<T> = (() => T) & { __fictProp: true }
203
218
  * <Child data={memoizedData} />
204
219
  * ```
205
220
  */
206
- export function prop<T>(getter: () => T): PropGetter<T> {
221
+ export function prop<T>(getter: () => T, options?: PropOptions): PropGetter<T> {
207
222
  if (isPropGetter(getter)) {
208
223
  return getter as PropGetter<T>
209
224
  }
225
+ // Capture getter to avoid type narrowing from isPropGetter guard
226
+ const fn: () => T = getter
227
+ const unwrap = options?.unwrap !== false
210
228
  // Wrap in prop so component props proxy auto-unwraps when passed down.
211
- return __fictProp(createMemo(getter)) as PropGetter<T>
229
+ return __fictProp(
230
+ createMemo(() => {
231
+ const value = fn()
232
+ if (unwrap && isPropGetter(value)) {
233
+ return (value as () => T)()
234
+ }
235
+ return value
236
+ }),
237
+ ) as PropGetter<T>
212
238
  }
package/src/scope.ts CHANGED
@@ -25,7 +25,7 @@ export function createScope(): ReactiveScope {
25
25
 
26
26
  const run = <T>(fn: () => T): T => {
27
27
  stop()
28
- const { dispose: rootDispose, value } = createRoot(fn)
28
+ const { dispose: rootDispose, value } = createRoot(fn, { inherit: true })
29
29
  dispose = rootDispose
30
30
  return value
31
31
  }
package/src/signal.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { beginFlushGuard, beforeEffectRunGuard, endFlushGuard } from './cycle-guard'
2
2
  import { getDevtoolsHook } from './devtools'
3
- import { registerRootCleanup } from './lifecycle'
3
+ import {
4
+ getCurrentRoot,
5
+ handleError,
6
+ handleSuspend,
7
+ registerRootCleanup,
8
+ type RootContext,
9
+ } from './lifecycle'
4
10
 
5
11
  const isDev =
6
12
  typeof __DEV__ !== 'undefined'
@@ -103,6 +109,8 @@ export interface EffectNode extends BaseNode {
103
109
  depsTail: Link | undefined
104
110
  /** Optional cleanup runner to be called before checkDirty */
105
111
  runCleanup?: () => void
112
+ /** Root context for error/suspense handling */
113
+ root?: RootContext
106
114
  /** Devtools ID */
107
115
  __id?: number | undefined
108
116
  }
@@ -808,7 +816,25 @@ function runEffect(e: EffectNode): void {
808
816
  inCleanup = false
809
817
  }
810
818
  }
811
- if (checkDirty(e.deps, e)) {
819
+ let isDirty = false
820
+ try {
821
+ isDirty = checkDirty(e.deps, e)
822
+ } catch (err) {
823
+ if (handleSuspend(err as any, e.root)) {
824
+ if (e.flags !== 0) {
825
+ e.flags = Watching
826
+ }
827
+ return
828
+ }
829
+ if (handleError(err, { source: 'effect' }, e.root)) {
830
+ if (e.flags !== 0) {
831
+ e.flags = Watching
832
+ }
833
+ return
834
+ }
835
+ throw err
836
+ }
837
+ if (isDirty) {
812
838
  ++cycle
813
839
  effectRunDevtools(e)
814
840
  e.depsTail = undefined
@@ -1031,7 +1057,7 @@ function computedOper<T>(this: ComputedNode<T>): T {
1031
1057
  * @returns An effect disposer function
1032
1058
  */
1033
1059
  export function effect(fn: () => void): EffectDisposer {
1034
- const e = {
1060
+ const e: EffectNode = {
1035
1061
  fn,
1036
1062
  subs: undefined,
1037
1063
  subsTail: undefined,
@@ -1040,6 +1066,10 @@ export function effect(fn: () => void): EffectDisposer {
1040
1066
  flags: WatchingRunning,
1041
1067
  __id: undefined as number | undefined,
1042
1068
  }
1069
+ const root = getCurrentRoot()
1070
+ if (root) {
1071
+ e.root = root
1072
+ }
1043
1073
 
1044
1074
  registerEffectDevtools(e)
1045
1075
 
@@ -1066,9 +1096,14 @@ export function effect(fn: () => void): EffectDisposer {
1066
1096
  * cleanup functions to access the previous values of signals.
1067
1097
  * @param fn - The effect function
1068
1098
  * @param cleanupRunner - Function to run cleanups before signal value commit
1099
+ * @param root - Root context for error/suspense handling (defaults to current root)
1069
1100
  * @returns An effect disposer function
1070
1101
  */
1071
- export function effectWithCleanup(fn: () => void, cleanupRunner: () => void): EffectDisposer {
1102
+ export function effectWithCleanup(
1103
+ fn: () => void,
1104
+ cleanupRunner: () => void,
1105
+ root?: RootContext,
1106
+ ): EffectDisposer {
1072
1107
  const e: EffectNode = {
1073
1108
  fn,
1074
1109
  subs: undefined,
@@ -1079,6 +1114,10 @@ export function effectWithCleanup(fn: () => void, cleanupRunner: () => void): Ef
1079
1114
  runCleanup: cleanupRunner,
1080
1115
  __id: undefined as number | undefined,
1081
1116
  }
1117
+ const resolvedRoot = root ?? getCurrentRoot()
1118
+ if (resolvedRoot) {
1119
+ e.root = resolvedRoot
1120
+ }
1082
1121
 
1083
1122
  registerEffectDevtools(e)
1084
1123
 
package/src/suspense.ts CHANGED
@@ -60,11 +60,6 @@ export function Suspense(props: SuspenseProps): FictNode {
60
60
  ? (props.fallback as (e?: unknown) => FictNode)(err)
61
61
  : props.fallback
62
62
 
63
- const switchView = (view: FictNode | null) => {
64
- currentView(view)
65
- renderView(view)
66
- }
67
-
68
63
  const renderView = (view: FictNode | null) => {
69
64
  if (cleanup) {
70
65
  cleanup()
@@ -88,8 +83,9 @@ export function Suspense(props: SuspenseProps): FictNode {
88
83
  // Suspended view: child threw a suspense token and was handled upstream.
89
84
  // Avoid replacing existing fallback content; tear down this attempt.
90
85
  const suspendedAttempt =
91
- nodes.length > 0 &&
92
- nodes.every(node => node instanceof Comment && (node as Comment).data === 'fict:suspend')
86
+ root.suspended ||
87
+ (nodes.length > 0 &&
88
+ nodes.every(node => node instanceof Comment && (node as Comment).data === 'fict:suspend'))
93
89
  if (suspendedAttempt) {
94
90
  popRoot(prev)
95
91
  destroyRoot(root)
@@ -134,7 +130,10 @@ export function Suspense(props: SuspenseProps): FictNode {
134
130
  registerSuspenseHandler(token => {
135
131
  const tokenEpoch = epoch
136
132
  pending(pending() + 1)
137
- switchView(toFallback())
133
+ // Directly render fallback instead of using switchView to avoid
134
+ // triggering the effect which would cause duplicate renders
135
+ currentView(toFallback())
136
+ renderView(toFallback())
138
137
 
139
138
  const thenable = (token as SuspenseToken).then
140
139
  ? (token as SuspenseToken)
@@ -157,7 +156,9 @@ export function Suspense(props: SuspenseProps): FictNode {
157
156
  const newPending = Math.max(0, pending() - 1)
158
157
  pending(newPending)
159
158
  if (newPending === 0) {
160
- switchView(props.children ?? null)
159
+ // Directly render children instead of using switchView
160
+ currentView(props.children ?? null)
161
+ renderView(props.children ?? null)
161
162
  onResolveMaybe()
162
163
  }
163
164
  },
@@ -180,9 +181,10 @@ export function Suspense(props: SuspenseProps): FictNode {
180
181
  return false
181
182
  })
182
183
 
183
- createEffect(() => {
184
- renderView(currentView())
185
- })
184
+ // Initial render - render children directly
185
+ // Note: This will be called synchronously during component creation.
186
+ // If children suspend, the handler above will be called and switch to fallback.
187
+ renderView(props.children ?? null)
186
188
 
187
189
  if (props.resetKeys !== undefined) {
188
190
  const isGetter =
@@ -195,7 +197,9 @@ export function Suspense(props: SuspenseProps): FictNode {
195
197
  prev = next
196
198
  epoch++
197
199
  pending(0)
198
- switchView(props.children ?? null)
200
+ // Directly render children instead of using switchView
201
+ currentView(props.children ?? null)
202
+ renderView(props.children ?? null)
199
203
  }
200
204
  })
201
205
  }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["/home/runner/work/fict/fict/packages/runtime/dist/chunk-624QY53A.cjs","../src/scope.ts"],"names":[],"mappings":"AAAA;AACE;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACA;ACOO,SAAS,WAAA,CAAA,EAA6B;AAC3C,EAAA,IAAI,QAAA,EAA+B,IAAA;AAEnC,EAAA,MAAM,KAAA,EAAO,CAAA,EAAA,GAAM;AACjB,IAAA,GAAA,CAAI,OAAA,EAAS;AACX,MAAA,OAAA,CAAQ,CAAA;AACR,MAAA,QAAA,EAAU,IAAA;AAAA,IACZ;AAAA,EACF,CAAA;AAEA,EAAA,MAAM,IAAA,EAAM,CAAI,EAAA,EAAA,GAAmB;AACjC,IAAA,IAAA,CAAK,CAAA;AACL,IAAA,MAAM,EAAE,OAAA,EAAS,WAAA,EAAa,MAAM,EAAA,EAAI,0CAAA,EAAa,CAAA;AACrD,IAAA,QAAA,EAAU,WAAA;AACV,IAAA,OAAO,KAAA;AAAA,EACT,CAAA;AAEA,EAAA,mDAAA,IAAwB,CAAA;AACxB,EAAA,OAAO,EAAE,GAAA,EAAK,KAAK,CAAA;AACrB;AAMO,SAAS,UAAA,CAAW,IAAA,EAA8B,EAAA,EAAsB;AAC7E,EAAA,MAAM,MAAA,EAAQ,WAAA,CAAY,CAAA;AAC1B,EAAA,MAAM,SAAA,EAAW,CAAA,EAAA,GAAO,0CAAA,IAAe,EAAA,EAAK,IAAA,CAAuB,EAAA,EAAI,CAAC,CAAC,IAAA;AAEzE,EAAA,4CAAA,CAAa,EAAA,GAAM;AACjB,IAAA,MAAM,QAAA,EAAU,QAAA,CAAS,CAAA;AACzB,IAAA,GAAA,CAAI,OAAA,EAAS;AACX,MAAA,KAAA,CAAM,GAAA,CAAI,EAAE,CAAA;AAAA,IACd,EAAA,KAAO;AACL,MAAA,KAAA,CAAM,IAAA,CAAK,CAAA;AAAA,IACb;AAAA,EACF,CAAC,CAAA;AAED,EAAA,yCAAA,KAAU,CAAM,IAAI,CAAA;AACtB;ADfA;AACA;AACE;AACA;AACF,mEAAC","file":"/home/runner/work/fict/fict/packages/runtime/dist/chunk-624QY53A.cjs","sourcesContent":[null,"import { isReactive, type MaybeReactive } from './binding'\nimport { createEffect } from './effect'\nimport { createRoot, onCleanup, registerRootCleanup } from './lifecycle'\n\nexport { effectScope } from './signal'\n\nexport interface ReactiveScope {\n run<T>(fn: () => T): T\n stop(): void\n}\n\n/**\n * Create an explicit reactive scope that can contain effects/memos and be stopped manually.\n * The scope registers with the current root for cleanup.\n */\nexport function createScope(): ReactiveScope {\n let dispose: (() => void) | null = null\n\n const stop = () => {\n if (dispose) {\n dispose()\n dispose = null\n }\n }\n\n const run = <T>(fn: () => T): T => {\n stop()\n const { dispose: rootDispose, value } = createRoot(fn)\n dispose = rootDispose\n return value\n }\n\n registerRootCleanup(stop)\n return { run, stop }\n}\n\n/**\n * Run a block of reactive code inside a managed scope that follows a boolean flag.\n * When the flag turns false, the scope is disposed and all contained effects/memos are cleaned up.\n */\nexport function runInScope(flag: MaybeReactive<boolean>, fn: () => void): void {\n const scope = createScope()\n const evaluate = () => (isReactive(flag) ? (flag as () => boolean)() : !!flag)\n\n createEffect(() => {\n const enabled = evaluate()\n if (enabled) {\n scope.run(fn)\n } else {\n scope.stop()\n }\n })\n\n onCleanup(scope.stop)\n}\n"]}