@fictjs/runtime 0.1.0 → 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 (46) 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-Q4EN6BXV.cjs → chunk-527QSKFM.cjs} +16 -16
  6. package/dist/{chunk-Q4EN6BXV.cjs.map → chunk-527QSKFM.cjs.map} +1 -1
  7. package/dist/{chunk-BWZFJXUI.js → chunk-5KXEEQUO.js} +84 -10
  8. package/dist/chunk-5KXEEQUO.js.map +1 -0
  9. package/dist/{chunk-YQ4IB7NC.cjs → chunk-BSUMPMKX.cjs} +7 -7
  10. package/dist/{chunk-YQ4IB7NC.cjs.map → chunk-BSUMPMKX.cjs.map} +1 -1
  11. package/dist/{chunk-V62XZLDU.js → chunk-FG3M7EBL.js} +2 -2
  12. package/dist/{chunk-7WAGAQLT.cjs → chunk-J74L7UYP.cjs} +84 -10
  13. package/dist/chunk-J74L7UYP.cjs.map +1 -0
  14. package/dist/{chunk-CF3OHML2.js → chunk-QV5GOCR5.js} +2 -2
  15. package/dist/{context-B7UYnfzM.d.ts → context-4woHo7-L.d.ts} +1 -1
  16. package/dist/{context-UXySaqI_.d.cts → context-9gFXOdJl.d.cts} +1 -1
  17. package/dist/{effect-Auji1rz9.d.cts → effect-ClARNUCc.d.cts} +23 -2
  18. package/dist/{effect-Auji1rz9.d.ts → effect-ClARNUCc.d.ts} +23 -2
  19. package/dist/index.cjs +51 -54
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +4 -4
  22. package/dist/index.d.ts +4 -4
  23. package/dist/index.dev.js +66 -19
  24. package/dist/index.dev.js.map +1 -1
  25. package/dist/index.js +10 -13
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal.cjs +34 -34
  28. package/dist/internal.d.cts +4 -4
  29. package/dist/internal.d.ts +4 -4
  30. package/dist/internal.js +2 -2
  31. package/dist/jsx-runtime.d.cts +671 -0
  32. package/dist/jsx-runtime.d.ts +671 -0
  33. package/dist/{props-BfmSLuyp.d.cts → props-CBwuh35e.d.cts} +4 -4
  34. package/dist/{props-BBi8Tkks.d.ts → props-DAyeRPwH.d.ts} +4 -4
  35. package/dist/{scope-S6eAzBJZ.d.ts → scope-DvgMquEy.d.ts} +1 -1
  36. package/dist/{scope-DKYzWfTn.d.cts → scope-xmdo6lVU.d.cts} +1 -1
  37. package/package.json +1 -1
  38. package/src/binding.ts +58 -5
  39. package/src/effect.ts +9 -2
  40. package/src/lifecycle.ts +13 -3
  41. package/src/signal.ts +43 -4
  42. package/src/suspense.ts +17 -13
  43. package/dist/chunk-7WAGAQLT.cjs.map +0 -1
  44. package/dist/chunk-BWZFJXUI.js.map +0 -1
  45. /package/dist/{chunk-V62XZLDU.js.map → chunk-FG3M7EBL.js.map} +0 -0
  46. /package/dist/{chunk-CF3OHML2.js.map → chunk-QV5GOCR5.js.map} +0 -0
@@ -1,7 +1,4 @@
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.js';
5
2
 
6
3
  type LifecycleFn = () => void | Cleanup;
7
4
  interface CreateRootOptions {
@@ -15,6 +12,9 @@ declare function createRoot<T>(fn: () => T, options?: CreateRootOptions): {
15
12
  value: T;
16
13
  };
17
14
 
15
+ type Memo<T> = () => T;
16
+ declare function createMemo<T>(fn: () => T): Memo<T>;
17
+
18
18
  declare const Fragment: unique symbol;
19
19
  declare namespace JSX {
20
20
  type Element = FictNode;
@@ -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.1.0",
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
  })
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,7 @@ export interface RootContext {
15
15
  destroyCallbacks: Cleanup[]
16
16
  errorHandlers?: ErrorHandler[]
17
17
  suspenseHandlers?: SuspenseHandler[]
18
+ suspended?: boolean
18
19
  }
19
20
 
20
21
  export interface CreateRootOptions {
@@ -30,7 +31,7 @@ const globalErrorHandlers = new WeakMap<RootContext, ErrorHandler[]>()
30
31
  const globalSuspenseHandlers = new WeakMap<RootContext, SuspenseHandler[]>()
31
32
 
32
33
  export function createRootContext(parent?: RootContext): RootContext {
33
- return { parent, cleanups: [], destroyCallbacks: [] }
34
+ return { parent, cleanups: [], destroyCallbacks: [], suspended: false }
34
35
  }
35
36
 
36
37
  export function pushRoot(root: RootContext): RootContext | undefined {
@@ -267,13 +268,18 @@ export function handleSuspend(
267
268
  startRoot?: RootContext,
268
269
  ): boolean {
269
270
  let root: RootContext | undefined = startRoot ?? currentRoot
271
+ const originRoot = root // Preserve reference to set suspended flag on success
270
272
  while (root) {
271
273
  const handlers = root.suspenseHandlers
272
274
  if (handlers && handlers.length) {
273
275
  for (let i = handlers.length - 1; i >= 0; i--) {
274
276
  const handler = handlers[i]!
275
277
  const handled = handler(token)
276
- 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
+ }
277
283
  }
278
284
  }
279
285
  root = root.parent
@@ -288,7 +294,11 @@ export function handleSuspend(
288
294
  for (let i = globalForRoot.length - 1; i >= 0; i--) {
289
295
  const handler = globalForRoot[i]!
290
296
  const handled = handler(token)
291
- 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
+ }
292
302
  }
293
303
  }
294
304
  return false
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
  }