@fictjs/runtime 0.0.12 → 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/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 {
@@ -128,7 +120,7 @@ function resolveNamespace(tagName: string, namespace: NamespaceContext): Namespa
128
120
  if (tagName === 'math') return 'mathml'
129
121
  if (namespace === 'mathml') return 'mathml'
130
122
  if (namespace === 'svg') return 'svg'
131
- if (SVGElements.has(tagName)) return 'svg'
123
+ if (isDev && SVGElements.has(tagName)) return 'svg'
132
124
  return null
133
125
  }
134
126
 
@@ -143,9 +135,8 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
143
135
  return document.createTextNode('')
144
136
  }
145
137
 
146
- // Primitive proxy produced by keyed list binding
147
138
  if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
148
- // Handle BindingHandle (createList, createConditional, etc)
139
+ // Handle BindingHandle (list/conditional bindings, etc)
149
140
  if ('marker' in node) {
150
141
  const handle = node as { marker: unknown; dispose?: () => void; flush?: () => void }
151
142
  // Register dispose cleanup if available
@@ -164,14 +155,6 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
164
155
  }
165
156
  return createElement(handle.marker as FictNode)
166
157
  }
167
-
168
- const nodeRecord = node as unknown as Record<PropertyKey, unknown>
169
- if (nodeRecord[PRIMITIVE_PROXY]) {
170
- const primitiveGetter = nodeRecord[Symbol.toPrimitive]
171
- const value =
172
- typeof primitiveGetter === 'function' ? primitiveGetter.call(node, 'default') : node
173
- return document.createTextNode(value == null || value === false ? '' : String(value))
174
- }
175
158
  }
176
159
 
177
160
  // Array - create fragment
@@ -559,7 +542,13 @@ function applyProps(el: Element, props: Record<string, unknown>, isSVG = false):
559
542
  }
560
543
 
561
544
  // Child properties (innerHTML, textContent, etc.)
562
- if (ChildProperties.has(key)) {
545
+ if (
546
+ (isDev && ChildProperties.has(key)) ||
547
+ key === 'innerHTML' ||
548
+ key === 'textContent' ||
549
+ key === 'innerText' ||
550
+ key === 'children'
551
+ ) {
563
552
  createAttributeBinding(el, key, value as MaybeReactive<unknown>, setProperty)
564
553
  continue
565
554
  }
@@ -583,13 +572,18 @@ function applyProps(el: Element, props: Record<string, unknown>, isSVG = false):
583
572
  }
584
573
 
585
574
  // Check for property alias (element-specific mappings)
586
- 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
587
581
 
588
582
  // Handle properties and element-specific attributes
589
- if (propAlias || (!isSVG && Properties.has(key)) || (isCE && !isSVG)) {
583
+ if (propAlias || isProperty || (isCE && !isSVG)) {
590
584
  const propName = propAlias || key
591
585
  // Custom elements use toPropertyName conversion
592
- if (isCE && !Properties.has(key)) {
586
+ if (isCE && !isProperty && !propAlias) {
593
587
  createAttributeBinding(
594
588
  el,
595
589
  toPropertyName(propName),
@@ -616,7 +610,7 @@ function applyProps(el: Element, props: Record<string, unknown>, isSVG = false):
616
610
 
617
611
  // Regular attributes (potentially reactive)
618
612
  // Apply alias mapping (className -> class, htmlFor -> for)
619
- const attrName = Aliases[key] || key
613
+ const attrName = key === 'htmlFor' ? 'for' : key
620
614
  createAttributeBinding(el, attrName, value as MaybeReactive<unknown>, setAttribute)
621
615
  }
622
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 {
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 = []
@@ -37,7 +37,7 @@ const isDev =
37
37
  /**
38
38
  * A keyed block represents a single item in a list with its associated DOM nodes and state
39
39
  */
40
- export interface KeyedBlock<T = unknown> {
40
+ interface KeyedBlock<T = unknown> {
41
41
  /** Unique key for this block */
42
42
  key: string | number
43
43
  /** DOM nodes belonging to this block */
@@ -57,7 +57,7 @@ export interface KeyedBlock<T = unknown> {
57
57
  /**
58
58
  * Container for managing keyed list blocks
59
59
  */
60
- export interface KeyedListContainer<T = unknown> {
60
+ interface KeyedListContainer<T = unknown> {
61
61
  /** Start marker comment node */
62
62
  startMarker: Comment
63
63
  /** End marker comment node */
@@ -102,15 +102,6 @@ type FineGrainedRenderItem<T> = (
102
102
  key: string | number,
103
103
  ) => Node[]
104
104
 
105
- /**
106
- * A block identified by start/end comment markers.
107
- */
108
- export interface MarkerBlock {
109
- start: Comment
110
- end: Comment
111
- root?: RootContext
112
- }
113
-
114
105
  // ============================================================================
115
106
  // DOM Manipulation Primitives
116
107
  // ============================================================================
@@ -129,7 +120,8 @@ export function moveNodesBefore(parent: Node, nodes: Node[], anchor: Node | null
129
120
  for (let i = nodes.length - 1; i >= 0; i--) {
130
121
  const node = nodes[i]!
131
122
  if (!node || !(node instanceof Node)) {
132
- throw new Error('Invalid node in moveNodesBefore')
123
+ const message = isDev ? 'Invalid node in moveNodesBefore' : 'FICT:E_NODE'
124
+ throw new Error(message)
133
125
  }
134
126
  // Only move if not already in correct position
135
127
  if (node.nextSibling !== anchor) {
@@ -162,50 +154,6 @@ export function moveNodesBefore(parent: Node, nodes: Node[], anchor: Node | null
162
154
  *
163
155
  * @param nodes - Array of nodes to remove
164
156
  */
165
- /**
166
- * Move an entire marker-delimited block (including markers) before the anchor.
167
- */
168
- export function moveMarkerBlock(parent: Node, block: MarkerBlock, anchor: Node | null): void {
169
- const nodes = collectBlockNodes(block)
170
- if (nodes.length === 0) return
171
- moveNodesBefore(parent, nodes, anchor)
172
- }
173
-
174
- /**
175
- * Destroy a marker-delimited block, removing nodes and destroying the associated root.
176
- */
177
- export function destroyMarkerBlock(block: MarkerBlock): void {
178
- if (block.root) {
179
- destroyRoot(block.root)
180
- }
181
- removeBlockRange(block)
182
- }
183
-
184
- function collectBlockNodes(block: MarkerBlock): Node[] {
185
- const nodes: Node[] = []
186
- let cursor: Node | null = block.start
187
- while (cursor) {
188
- nodes.push(cursor)
189
- if (cursor === block.end) {
190
- break
191
- }
192
- cursor = cursor.nextSibling
193
- }
194
- return nodes
195
- }
196
-
197
- function removeBlockRange(block: MarkerBlock): void {
198
- let cursor: Node | null = block.start
199
- while (cursor) {
200
- const next: Node | null = cursor.nextSibling
201
- cursor.parentNode?.removeChild(cursor)
202
- if (cursor === block.end) {
203
- break
204
- }
205
- cursor = next
206
- }
207
- }
208
-
209
157
  // Number.MAX_SAFE_INTEGER is 2^53 - 1, but we reset earlier to avoid any precision issues
210
158
  const MAX_SAFE_VERSION = 0x1fffffffffffff // 2^53 - 1
211
159
 
@@ -238,7 +186,7 @@ export function createVersionedSignalAccessor<T>(initialValue: T): Signal<T> {
238
186
  *
239
187
  * @returns Container object with markers, blocks map, and dispose function
240
188
  */
241
- export function createKeyedListContainer<T = unknown>(): KeyedListContainer<T> {
189
+ function createKeyedListContainer<T = unknown>(): KeyedListContainer<T> {
242
190
  const startMarker = document.createComment('fict:list:start')
243
191
  const endMarker = document.createComment('fict:list:end')
244
192
 
@@ -306,7 +254,7 @@ export function createKeyedListContainer<T = unknown>(): KeyedListContainer<T> {
306
254
  * @param render - Function that creates the DOM nodes and sets up bindings
307
255
  * @returns New KeyedBlock
308
256
  */
309
- export function createKeyedBlock<T>(
257
+ function createKeyedBlock<T>(
310
258
  key: string | number,
311
259
  item: T,
312
260
  index: number,
@@ -377,13 +325,6 @@ export function createKeyedBlock<T>(
377
325
  // Utilities
378
326
  // ============================================================================
379
327
 
380
- /**
381
- * Find the first node after the start marker (for getting current anchor)
382
- */
383
- export function getFirstNodeAfter(marker: Comment): Node | null {
384
- return marker.nextSibling
385
- }
386
-
387
328
  /**
388
329
  * Check if a node is between two markers
389
330
  */