@fictjs/runtime 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,10 @@
1
1
  import { getDevtoolsHook } from './devtools'
2
2
 
3
+ const isDev =
4
+ typeof __DEV__ !== 'undefined'
5
+ ? __DEV__
6
+ : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
7
+
3
8
  export interface CycleProtectionOptions {
4
9
  maxFlushCyclesPerMicrotask?: number
5
10
  maxEffectRunsPerFlush?: number
@@ -15,120 +20,142 @@ interface CycleWindowEntry {
15
20
  budget: number
16
21
  }
17
22
 
18
- const defaultOptions = {
19
- maxFlushCyclesPerMicrotask: 10_000,
20
- maxEffectRunsPerFlush: 20_000,
21
- windowSize: 5,
22
- highUsageRatio: 0.8,
23
- maxRootReentrantDepth: 10,
24
- enableWindowWarning: true,
25
- devMode: false,
26
- }
23
+ let setCycleProtectionOptions: (opts: CycleProtectionOptions) => void = () => {}
24
+ let resetCycleProtectionStateForTests: () => void = () => {}
25
+ let beginFlushGuard: () => void = () => {}
26
+ let beforeEffectRunGuard: () => boolean = () => true
27
+ let endFlushGuard: () => void = () => {}
28
+ let enterRootGuard: (root: object) => boolean = () => true
29
+ let exitRootGuard: (root: object) => void = () => {}
27
30
 
28
- let options: Required<CycleProtectionOptions> = {
29
- ...defaultOptions,
30
- } as Required<CycleProtectionOptions>
31
+ if (isDev) {
32
+ const defaultOptions = {
33
+ maxFlushCyclesPerMicrotask: 10_000,
34
+ maxEffectRunsPerFlush: 20_000,
35
+ windowSize: 5,
36
+ highUsageRatio: 0.8,
37
+ maxRootReentrantDepth: 10,
38
+ enableWindowWarning: true,
39
+ devMode: false,
40
+ }
31
41
 
32
- let effectRunsThisFlush = 0
33
- let windowUsage: CycleWindowEntry[] = []
34
- let rootDepth = new WeakMap<object, number>()
35
- let flushWarned = false
36
- let rootWarned = false
37
- let windowWarned = false
42
+ let options: Required<CycleProtectionOptions> = {
43
+ ...defaultOptions,
44
+ } as Required<CycleProtectionOptions>
38
45
 
39
- export function setCycleProtectionOptions(opts: CycleProtectionOptions): void {
40
- options = { ...options, ...opts }
41
- }
46
+ let effectRunsThisFlush = 0
47
+ let windowUsage: CycleWindowEntry[] = []
48
+ let rootDepth = new WeakMap<object, number>()
49
+ let flushWarned = false
50
+ let rootWarned = false
51
+ let windowWarned = false
42
52
 
43
- export function resetCycleProtectionStateForTests(): void {
44
- options = { ...defaultOptions } as Required<CycleProtectionOptions>
45
- effectRunsThisFlush = 0
46
- windowUsage = []
47
- rootDepth = new WeakMap<object, number>()
48
- flushWarned = false
49
- rootWarned = false
50
- windowWarned = false
51
- }
53
+ setCycleProtectionOptions = opts => {
54
+ options = { ...options, ...opts }
55
+ }
52
56
 
53
- export function beginFlushGuard(): void {
54
- effectRunsThisFlush = 0
55
- flushWarned = false
56
- windowWarned = false
57
- }
57
+ resetCycleProtectionStateForTests = () => {
58
+ options = { ...defaultOptions } as Required<CycleProtectionOptions>
59
+ effectRunsThisFlush = 0
60
+ windowUsage = []
61
+ rootDepth = new WeakMap<object, number>()
62
+ flushWarned = false
63
+ rootWarned = false
64
+ windowWarned = false
65
+ }
58
66
 
59
- export function beforeEffectRunGuard(): boolean {
60
- const next = ++effectRunsThisFlush
61
- if (next > options.maxFlushCyclesPerMicrotask || next > options.maxEffectRunsPerFlush) {
62
- const message = `[fict] cycle protection triggered: flush-budget-exceeded`
63
- if (options.devMode) {
64
- throw new Error(message)
65
- }
66
- if (!flushWarned) {
67
- flushWarned = true
68
- console.warn(message, { effectRuns: next })
67
+ beginFlushGuard = () => {
68
+ effectRunsThisFlush = 0
69
+ flushWarned = false
70
+ windowWarned = false
71
+ }
72
+
73
+ beforeEffectRunGuard = () => {
74
+ const next = ++effectRunsThisFlush
75
+ if (next > options.maxFlushCyclesPerMicrotask || next > options.maxEffectRunsPerFlush) {
76
+ const message = `[fict] cycle protection triggered: flush-budget-exceeded`
77
+ if (options.devMode) {
78
+ throw new Error(message)
79
+ }
80
+ if (!flushWarned) {
81
+ flushWarned = true
82
+ console.warn(message, { effectRuns: next })
83
+ }
84
+ return false
69
85
  }
70
- return false
86
+ return true
71
87
  }
72
- return true
73
- }
74
88
 
75
- export function endFlushGuard(): void {
76
- recordWindowUsage(effectRunsThisFlush, options.maxFlushCyclesPerMicrotask)
77
- effectRunsThisFlush = 0
78
- }
89
+ endFlushGuard = () => {
90
+ recordWindowUsage(effectRunsThisFlush, options.maxFlushCyclesPerMicrotask)
91
+ effectRunsThisFlush = 0
92
+ }
79
93
 
80
- export function enterRootGuard(root: object): boolean {
81
- const depth = (rootDepth.get(root) ?? 0) + 1
82
- if (depth > options.maxRootReentrantDepth) {
83
- const message = `[fict] cycle protection triggered: root-reentry`
84
- if (options.devMode) {
85
- throw new Error(message)
94
+ enterRootGuard = root => {
95
+ const depth = (rootDepth.get(root) ?? 0) + 1
96
+ if (depth > options.maxRootReentrantDepth) {
97
+ const message = `[fict] cycle protection triggered: root-reentry`
98
+ if (options.devMode) {
99
+ throw new Error(message)
100
+ }
101
+ if (!rootWarned) {
102
+ rootWarned = true
103
+ console.warn(message, { depth })
104
+ }
105
+ return false
86
106
  }
87
- if (!rootWarned) {
88
- rootWarned = true
89
- console.warn(message, { depth })
90
- }
91
- return false
107
+ rootDepth.set(root, depth)
108
+ return true
92
109
  }
93
- rootDepth.set(root, depth)
94
- return true
95
- }
96
110
 
97
- export function exitRootGuard(root: object): void {
98
- const depth = rootDepth.get(root)
99
- if (depth === undefined) return
100
- if (depth <= 1) {
101
- rootDepth.delete(root)
102
- } else {
103
- rootDepth.set(root, depth - 1)
111
+ exitRootGuard = root => {
112
+ const depth = rootDepth.get(root)
113
+ if (depth === undefined) return
114
+ if (depth <= 1) {
115
+ rootDepth.delete(root)
116
+ } else {
117
+ rootDepth.set(root, depth - 1)
118
+ }
104
119
  }
105
- }
106
120
 
107
- function recordWindowUsage(used: number, budget: number): void {
108
- if (!options.enableWindowWarning) return
109
- const entry = { used, budget }
110
- windowUsage.push(entry)
111
- if (windowUsage.length > options.windowSize) {
112
- windowUsage.shift()
121
+ const recordWindowUsage = (used: number, budget: number): void => {
122
+ if (!options.enableWindowWarning) return
123
+ const entry = { used, budget }
124
+ windowUsage.push(entry)
125
+ if (windowUsage.length > options.windowSize) {
126
+ windowUsage.shift()
127
+ }
128
+ if (windowWarned) return
129
+ if (
130
+ windowUsage.length >= options.windowSize &&
131
+ windowUsage.every(
132
+ item => item.budget > 0 && item.used / item.budget >= options.highUsageRatio,
133
+ )
134
+ ) {
135
+ windowWarned = true
136
+ reportCycle('high-usage-window', {
137
+ windowSize: options.windowSize,
138
+ ratio: options.highUsageRatio,
139
+ })
140
+ }
113
141
  }
114
- if (windowWarned) return
115
- if (
116
- windowUsage.length >= options.windowSize &&
117
- windowUsage.every(item => item.budget > 0 && item.used / item.budget >= options.highUsageRatio)
118
- ) {
119
- windowWarned = true
120
- reportCycle('high-usage-window', {
121
- windowSize: options.windowSize,
122
- ratio: options.highUsageRatio,
123
- })
142
+
143
+ const reportCycle = (
144
+ reason: string,
145
+ detail: Record<string, unknown> | undefined = undefined,
146
+ ): void => {
147
+ const hook = getDevtoolsHook()
148
+ hook?.cycleDetected?.(detail ? { reason, detail } : { reason })
149
+ console.warn(`[fict] cycle protection triggered: ${reason}`, detail ?? '')
124
150
  }
125
151
  }
126
152
 
127
- function reportCycle(
128
- reason: string,
129
- detail: Record<string, unknown> | undefined = undefined,
130
- ): void {
131
- const hook = getDevtoolsHook()
132
- hook?.cycleDetected?.(detail ? { reason, detail } : { reason })
133
- console.warn(`[fict] cycle protection triggered: ${reason}`, detail ?? '')
153
+ export {
154
+ setCycleProtectionOptions,
155
+ resetCycleProtectionStateForTests,
156
+ beginFlushGuard,
157
+ beforeEffectRunGuard,
158
+ endFlushGuard,
159
+ enterRootGuard,
160
+ exitRootGuard,
134
161
  }
package/src/dev.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {}
2
+
3
+ declare global {
4
+ const __DEV__: boolean | undefined
5
+ }
package/src/dom.ts CHANGED
@@ -20,19 +20,11 @@ import {
20
20
  createChildBinding,
21
21
  bindEvent,
22
22
  isReactive,
23
- PRIMITIVE_PROXY,
24
23
  type MaybeReactive,
25
24
  type AttributeSetter,
26
25
  type BindingHandle,
27
26
  } from './binding'
28
- import {
29
- Properties,
30
- ChildProperties,
31
- Aliases,
32
- getPropAlias,
33
- SVGElements,
34
- SVGNamespace,
35
- } from './constants'
27
+ import { Properties, ChildProperties, getPropAlias, SVGElements, SVGNamespace } from './constants'
36
28
  import { __fictPushContext, __fictPopContext } from './hooks'
37
29
  import { Fragment } from './jsx'
38
30
  import {
@@ -54,6 +46,10 @@ type NamespaceContext = 'svg' | 'mathml' | null
54
46
 
55
47
  const SVG_NS = 'http://www.w3.org/2000/svg'
56
48
  const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'
49
+ const isDev =
50
+ typeof __DEV__ !== 'undefined'
51
+ ? __DEV__
52
+ : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
57
53
 
58
54
  // ============================================================================
59
55
  // Main Render Function
@@ -124,7 +120,7 @@ function resolveNamespace(tagName: string, namespace: NamespaceContext): Namespa
124
120
  if (tagName === 'math') return 'mathml'
125
121
  if (namespace === 'mathml') return 'mathml'
126
122
  if (namespace === 'svg') return 'svg'
127
- if (SVGElements.has(tagName)) return 'svg'
123
+ if (isDev && SVGElements.has(tagName)) return 'svg'
128
124
  return null
129
125
  }
130
126
 
@@ -139,9 +135,8 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
139
135
  return document.createTextNode('')
140
136
  }
141
137
 
142
- // Primitive proxy produced by keyed list binding
143
138
  if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
144
- // Handle BindingHandle (createList, createConditional, etc)
139
+ // Handle BindingHandle (list/conditional bindings, etc)
145
140
  if ('marker' in node) {
146
141
  const handle = node as { marker: unknown; dispose?: () => void; flush?: () => void }
147
142
  // Register dispose cleanup if available
@@ -160,14 +155,6 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
160
155
  }
161
156
  return createElement(handle.marker as FictNode)
162
157
  }
163
-
164
- const nodeRecord = node as unknown as Record<PropertyKey, unknown>
165
- if (nodeRecord[PRIMITIVE_PROXY]) {
166
- const primitiveGetter = nodeRecord[Symbol.toPrimitive]
167
- const value =
168
- typeof primitiveGetter === 'function' ? primitiveGetter.call(node, 'default') : node
169
- return document.createTextNode(value == null || value === false ? '' : String(value))
170
- }
171
158
  }
172
159
 
173
160
  // Array - create fragment
@@ -431,10 +418,17 @@ function applyRef(el: Element, value: unknown): void {
431
418
  refFn(el)
432
419
 
433
420
  // Match React behavior: call ref(null) on unmount
434
- if (getCurrentRoot()) {
421
+ const root = getCurrentRoot()
422
+ if (root) {
435
423
  registerRootCleanup(() => {
436
424
  refFn(null)
437
425
  })
426
+ } else if (isDev) {
427
+ console.warn(
428
+ '[fict] Ref applied outside of a root context. ' +
429
+ 'The ref cleanup (setting to null) will not run automatically. ' +
430
+ 'Consider using createRoot() or ensure the element is created within a component.',
431
+ )
438
432
  }
439
433
  } else if (value && typeof value === 'object' && 'current' in value) {
440
434
  // Object ref
@@ -442,10 +436,17 @@ function applyRef(el: Element, value: unknown): void {
442
436
  refObj.current = el
443
437
 
444
438
  // Auto-cleanup on unmount
445
- if (getCurrentRoot()) {
439
+ const root = getCurrentRoot()
440
+ if (root) {
446
441
  registerRootCleanup(() => {
447
442
  refObj.current = null
448
443
  })
444
+ } else if (isDev) {
445
+ console.warn(
446
+ '[fict] Ref applied outside of a root context. ' +
447
+ 'The ref cleanup (setting to null) will not run automatically. ' +
448
+ 'Consider using createRoot() or ensure the element is created within a component.',
449
+ )
449
450
  }
450
451
  }
451
452
  }
@@ -541,7 +542,13 @@ function applyProps(el: Element, props: Record<string, unknown>, isSVG = false):
541
542
  }
542
543
 
543
544
  // Child properties (innerHTML, textContent, etc.)
544
- if (ChildProperties.has(key)) {
545
+ if (
546
+ (isDev && ChildProperties.has(key)) ||
547
+ key === 'innerHTML' ||
548
+ key === 'textContent' ||
549
+ key === 'innerText' ||
550
+ key === 'children'
551
+ ) {
545
552
  createAttributeBinding(el, key, value as MaybeReactive<unknown>, setProperty)
546
553
  continue
547
554
  }
@@ -565,13 +572,18 @@ function applyProps(el: Element, props: Record<string, unknown>, isSVG = false):
565
572
  }
566
573
 
567
574
  // Check for property alias (element-specific mappings)
568
- const propAlias = !isSVG ? getPropAlias(key, tagName) : undefined
575
+ const propAlias = !isSVG && isDev ? getPropAlias(key, tagName) : undefined
576
+ const isProperty = !isSVG
577
+ ? isDev
578
+ ? Properties.has(key)
579
+ : key in (el as unknown as Record<string, unknown>)
580
+ : false
569
581
 
570
582
  // Handle properties and element-specific attributes
571
- if (propAlias || (!isSVG && Properties.has(key)) || (isCE && !isSVG)) {
583
+ if (propAlias || isProperty || (isCE && !isSVG)) {
572
584
  const propName = propAlias || key
573
585
  // Custom elements use toPropertyName conversion
574
- if (isCE && !Properties.has(key)) {
586
+ if (isCE && !isProperty && !propAlias) {
575
587
  createAttributeBinding(
576
588
  el,
577
589
  toPropertyName(propName),
@@ -598,7 +610,7 @@ function applyProps(el: Element, props: Record<string, unknown>, isSVG = false):
598
610
 
599
611
  // Regular attributes (potentially reactive)
600
612
  // Apply alias mapping (className -> class, htmlFor -> for)
601
- const attrName = Aliases[key] || key
613
+ const attrName = key === 'htmlFor' ? 'for' : key
602
614
  createAttributeBinding(el, attrName, value as MaybeReactive<unknown>, setAttribute)
603
615
  }
604
616
  }
package/src/effect.ts CHANGED
@@ -8,6 +8,10 @@ import {
8
8
  import { effectWithCleanup } from './signal'
9
9
  import type { Cleanup } from './types'
10
10
 
11
+ /**
12
+ * Effect callback run synchronously; async callbacks are not tracked after the first await.
13
+ * TypeScript will reject `async () => {}` here—split async work or read signals before awaiting.
14
+ */
11
15
  export type Effect = () => void | Cleanup
12
16
 
13
17
  export function createEffect(fn: Effect): () => void {
@@ -72,13 +72,22 @@ export function ErrorBoundary(props: ErrorBoundaryProps): FictNode {
72
72
  if (renderingFallback) {
73
73
  throw err
74
74
  }
75
+ // nested errors. If fallback rendering also throws, we should NOT reset
76
+ // the flag until we're sure no more recursion is happening.
75
77
  renderingFallback = true
76
78
  try {
77
79
  renderValue(toView(err))
78
- } finally {
80
+ // Only reset if successful - if renderValue threw, we want to keep
81
+ // renderingFallback = true to prevent infinite recursion
79
82
  renderingFallback = false
83
+ props.onError?.(err)
84
+ } catch (fallbackErr) {
85
+ // Fallback rendering failed - keep renderingFallback = true
86
+ // to prevent further attempts, then rethrow
87
+ // If fallback fails, report both errors
88
+ props.onError?.(err)
89
+ throw fallbackErr
80
90
  }
81
- props.onError?.(err)
82
91
  return
83
92
  }
84
93
  popRoot(prev)
package/src/hooks.ts CHANGED
@@ -2,6 +2,11 @@ import { createEffect } from './effect'
2
2
  import { createMemo } from './memo'
3
3
  import { createSignal, type SignalAccessor, type ComputedAccessor } from './signal'
4
4
 
5
+ const isDev =
6
+ typeof __DEV__ !== 'undefined'
7
+ ? __DEV__
8
+ : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
9
+
5
10
  interface HookContext {
6
11
  slots: unknown[]
7
12
  cursor: number
@@ -12,7 +17,10 @@ const ctxStack: HookContext[] = []
12
17
 
13
18
  function assertRenderContext(ctx: HookContext, hookName: string): void {
14
19
  if (!ctx.rendering) {
15
- throw new Error(`${hookName} can only be used during render execution`)
20
+ const message = isDev
21
+ ? `${hookName} can only be used during render execution`
22
+ : 'FICT:E_HOOK_RENDER'
23
+ throw new Error(message)
16
24
  }
17
25
  }
18
26
 
package/src/index.ts CHANGED
@@ -101,12 +101,10 @@ export {
101
101
  isReactive,
102
102
  // Advanced bindings
103
103
  createConditional,
104
- createList,
105
104
  createPortal,
106
105
  createShow,
107
106
  // Utility functions
108
107
  unwrap,
109
- unwrapPrimitive,
110
108
  } from './binding'
111
109
 
112
110
  // Constants for DOM handling
@@ -129,13 +127,7 @@ export { default as reconcileArrays } from './reconcile'
129
127
  // Types
130
128
  // ============================================================================
131
129
 
132
- export type {
133
- MaybeReactive,
134
- BindingHandle,
135
- KeyFn,
136
- CreateElementFn,
137
- AttributeSetter,
138
- } from './binding'
130
+ export type { MaybeReactive, BindingHandle, CreateElementFn, AttributeSetter } from './binding'
139
131
 
140
132
  export type {
141
133
  FictNode,
@@ -167,21 +159,11 @@ export {
167
159
  moveNodesBefore,
168
160
  removeNodes,
169
161
  insertNodesBefore,
170
- moveMarkerBlock,
171
- destroyMarkerBlock,
172
- // Keyed list container
173
- createKeyedListContainer,
174
- // Block creation
175
- createKeyedBlock,
176
162
  // High-level list binding (for compiler-generated code)
177
163
  createKeyedList,
178
164
  // Utilities
179
165
  toNodeArray,
180
- getFirstNodeAfter,
181
166
  isNodeBetweenMarkers,
182
167
  // Types
183
- type KeyedBlock,
184
- type KeyedListContainer,
185
168
  type KeyedListBinding,
186
- type MarkerBlock,
187
169
  } from './list-helpers'
package/src/lifecycle.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { enterRootGuard, exitRootGuard } from './cycle-guard'
2
2
  import type { Cleanup, ErrorInfo, SuspenseToken } from './types'
3
3
 
4
+ const isDev =
5
+ typeof __DEV__ !== 'undefined'
6
+ ? __DEV__
7
+ : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
8
+
4
9
  type LifecycleFn = () => void | Cleanup
5
10
 
6
11
  export interface RootContext {
@@ -169,7 +174,10 @@ function runLifecycle(fn: LifecycleFn): void {
169
174
 
170
175
  export function registerErrorHandler(fn: ErrorHandler): void {
171
176
  if (!currentRoot) {
172
- throw new Error('registerErrorHandler must be called within a root')
177
+ const message = isDev
178
+ ? 'registerErrorHandler must be called within a root'
179
+ : 'FICT:E_ROOT_HANDLER'
180
+ throw new Error(message)
173
181
  }
174
182
  if (!currentRoot.errorHandlers) {
175
183
  currentRoot.errorHandlers = []
@@ -185,7 +193,10 @@ export function registerErrorHandler(fn: ErrorHandler): void {
185
193
 
186
194
  export function registerSuspenseHandler(fn: SuspenseHandler): void {
187
195
  if (!currentRoot) {
188
- throw new Error('registerSuspenseHandler must be called within a root')
196
+ const message = isDev
197
+ ? 'registerSuspenseHandler must be called within a root'
198
+ : 'FICT:E_ROOT_SUSPENSE'
199
+ throw new Error(message)
189
200
  }
190
201
  if (!currentRoot.suspenseHandlers) {
191
202
  currentRoot.suspenseHandlers = []
@@ -237,7 +248,10 @@ export function handleError(err: unknown, info?: ErrorInfo, startRoot?: RootCont
237
248
  }
238
249
  }
239
250
  }
240
- throw error
251
+ // The caller (e.g., runCleanupList) can decide whether to rethrow.
252
+ // This makes the API consistent: handleError always returns a boolean
253
+ // indicating whether the error was handled.
254
+ return false
241
255
  }
242
256
 
243
257
  export function handleSuspend(