@fictjs/runtime 0.5.2 → 0.7.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 (63) hide show
  1. package/dist/advanced.cjs +13 -9
  2. package/dist/advanced.cjs.map +1 -1
  3. package/dist/advanced.d.cts +4 -4
  4. package/dist/advanced.d.ts +4 -4
  5. package/dist/advanced.js +8 -4
  6. package/dist/advanced.js.map +1 -1
  7. package/dist/{chunk-D2IWOO4X.js → chunk-4LCHQ7U4.js} +250 -99
  8. package/dist/chunk-4LCHQ7U4.js.map +1 -0
  9. package/dist/{chunk-LRFMCJY3.js → chunk-7YQK3XKY.js} +120 -27
  10. package/dist/chunk-7YQK3XKY.js.map +1 -0
  11. package/dist/{chunk-QB2UD62G.cjs → chunk-CEV6TO5U.cjs} +8 -8
  12. package/dist/{chunk-QB2UD62G.cjs.map → chunk-CEV6TO5U.cjs.map} +1 -1
  13. package/dist/{chunk-ZR435MDC.cjs → chunk-FSCBL7RI.cjs} +120 -27
  14. package/dist/chunk-FSCBL7RI.cjs.map +1 -0
  15. package/dist/{chunk-KNGHYGK4.cjs → chunk-HHDHQGJY.cjs} +17 -17
  16. package/dist/{chunk-KNGHYGK4.cjs.map → chunk-HHDHQGJY.cjs.map} +1 -1
  17. package/dist/{chunk-Z6M3HKLG.cjs → chunk-PRF4QG73.cjs} +400 -249
  18. package/dist/chunk-PRF4QG73.cjs.map +1 -0
  19. package/dist/{chunk-4NUHM77Z.js → chunk-TLDT76RV.js} +3 -3
  20. package/dist/{chunk-SLFAEVKJ.js → chunk-WRU3IZOA.js} +3 -3
  21. package/dist/{context-CTBE00S_.d.cts → context-BFbHf9nC.d.cts} +1 -1
  22. package/dist/{context-lkLhbkFJ.d.ts → context-C4vBQbb4.d.ts} +1 -1
  23. package/dist/{effect-BpSNEJJz.d.cts → effect-DAzpH7Mm.d.cts} +33 -1
  24. package/dist/{effect-BpSNEJJz.d.ts → effect-DAzpH7Mm.d.ts} +33 -1
  25. package/dist/index.cjs +42 -42
  26. package/dist/index.d.cts +5 -5
  27. package/dist/index.d.ts +5 -5
  28. package/dist/index.dev.js +206 -46
  29. package/dist/index.dev.js.map +1 -1
  30. package/dist/index.js +3 -3
  31. package/dist/internal.cjs +55 -41
  32. package/dist/internal.cjs.map +1 -1
  33. package/dist/internal.d.cts +3 -3
  34. package/dist/internal.d.ts +3 -3
  35. package/dist/internal.js +17 -3
  36. package/dist/internal.js.map +1 -1
  37. package/dist/loader.cjs +9 -9
  38. package/dist/loader.js +1 -1
  39. package/dist/{props-XTHYD19o.d.cts → props-84UJeWO8.d.cts} +1 -1
  40. package/dist/{props-x-HbI-jX.d.ts → props-BRhFK50f.d.ts} +1 -1
  41. package/dist/{scope-CdbGmsFf.d.ts → scope-D3DpsfoG.d.ts} +1 -1
  42. package/dist/{scope-DfcP9I-A.d.cts → scope-DlCBL1Ft.d.cts} +1 -1
  43. package/package.json +1 -1
  44. package/src/advanced.ts +1 -1
  45. package/src/binding.ts +229 -101
  46. package/src/constants.ts +1 -1
  47. package/src/cycle-guard.ts +4 -3
  48. package/src/dom.ts +15 -4
  49. package/src/hooks.ts +1 -1
  50. package/src/internal.ts +7 -0
  51. package/src/lifecycle.ts +1 -1
  52. package/src/props.ts +60 -1
  53. package/src/signal.ts +60 -10
  54. package/src/store.ts +131 -18
  55. package/src/transition.ts +46 -9
  56. package/dist/chunk-D2IWOO4X.js.map +0 -1
  57. package/dist/chunk-LRFMCJY3.js.map +0 -1
  58. package/dist/chunk-Z6M3HKLG.cjs.map +0 -1
  59. package/dist/chunk-ZR435MDC.cjs.map +0 -1
  60. package/dist/jsx-dev-runtime.d.cts +0 -671
  61. package/dist/jsx-dev-runtime.d.ts +0 -671
  62. /package/dist/{chunk-4NUHM77Z.js.map → chunk-TLDT76RV.js.map} +0 -0
  63. /package/dist/{chunk-SLFAEVKJ.js.map → chunk-WRU3IZOA.js.map} +0 -0
package/src/binding.ts CHANGED
@@ -44,7 +44,53 @@ import type { Cleanup, FictNode } from './types'
44
44
  const isDev =
45
45
  typeof __DEV__ !== 'undefined'
46
46
  ? __DEV__
47
- : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
47
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
48
+
49
+ const TEXT_CACHE = Symbol('fict:text')
50
+ const ATTR_CACHE = Symbol('fict:attr')
51
+ const PROP_CACHE = Symbol('fict:prop')
52
+ const STYLE_CACHE = Symbol('fict:style')
53
+ const CLASS_STATE_CACHE = Symbol('fict:class-state')
54
+ const CLASS_VALUE_CACHE = Symbol('fict:class-value')
55
+ const NON_REACTIVE_FN_MARKER = Symbol.for('fict:non-reactive-fn')
56
+ const REACTIVE_FN_MARKER = Symbol.for('fict:reactive-fn')
57
+ const NON_REACTIVE_FN_REGISTRY_KEY = Symbol.for('fict:non-reactive-fn-registry')
58
+
59
+ type NonReactiveRegistryHost = typeof globalThis & {
60
+ [NON_REACTIVE_FN_REGISTRY_KEY]?: WeakSet<(...args: unknown[]) => unknown>
61
+ }
62
+
63
+ const PROPERTY_BINDING_KEYS = new Set([
64
+ 'value',
65
+ 'checked',
66
+ 'selected',
67
+ 'disabled',
68
+ 'readOnly',
69
+ 'multiple',
70
+ 'muted',
71
+ ])
72
+
73
+ const STYLE_PROP_CACHE = new Map<string, string>()
74
+ const hasOwn = Object.prototype.hasOwnProperty
75
+
76
+ function getNonReactiveFnRegistry(): WeakSet<(...args: unknown[]) => unknown> {
77
+ const host = globalThis as NonReactiveRegistryHost
78
+ let registry = host[NON_REACTIVE_FN_REGISTRY_KEY]
79
+ if (!registry) {
80
+ registry = new WeakSet<(...args: unknown[]) => unknown>()
81
+ host[NON_REACTIVE_FN_REGISTRY_KEY] = registry
82
+ }
83
+ return registry
84
+ }
85
+
86
+ function isExplicitReactiveFn(value: unknown): boolean {
87
+ if (typeof value !== 'function') return false
88
+ return (
89
+ (value as ((...args: unknown[]) => unknown) & { [REACTIVE_FN_MARKER]?: boolean })[
90
+ REACTIVE_FN_MARKER
91
+ ] === true
92
+ )
93
+ }
48
94
 
49
95
  // ============================================================================
50
96
  // Type Definitions
@@ -99,9 +145,15 @@ export interface BindingHandle {
99
145
  export function isReactive(value: unknown): value is () => unknown {
100
146
  if (typeof value !== 'function') return false
101
147
 
148
+ // Explicit non-reactive marker always wins.
149
+ if (isNonReactiveFn(value)) return false
150
+
102
151
  // Check for runtime-created signals/computed (most reliable)
103
152
  if (isSignal(value) || isComputed(value)) return true
104
153
 
154
+ // Explicit marker for user-authored reactive getters.
155
+ if (isExplicitReactiveFn(value)) return true
156
+
105
157
  // Exclude effect disposers and effect scopes - they are zero-arg
106
158
  // functions but not reactive getters
107
159
  if (isEffect(value) || isEffectScope(value)) return false
@@ -112,20 +164,22 @@ export function isReactive(value: unknown): value is () => unknown {
112
164
  }
113
165
 
114
166
  /**
115
- * fix: Stricter reactive check that only considers explicitly marked values.
116
- * Used for event handlers where we don't want to misidentify regular callbacks
117
- * (like `onClick={() => doSomething()}`) as reactive getters.
167
+ * Stricter reactive check that only considers explicitly marked values.
168
+ * Used in DOM/event paths where regular callbacks must not be treated as
169
+ * reactive getters.
118
170
  *
119
171
  * Only returns true for:
120
172
  * - Signal accessors (created by createSignal)
121
173
  * - Computed accessors (created by createMemo)
122
174
  * - Prop getters (marked by __fictProp)
175
+ * - Explicitly marked getters (reactive(...))
123
176
  */
124
- function isStrictlyReactive(value: unknown): value is () => unknown {
177
+ export function isStrictlyReactive(value: unknown): value is () => unknown {
125
178
  if (typeof value !== 'function') return false
126
- // Only check for explicitly marked reactive values
127
- // Do NOT use length === 0 as fallback - many callbacks have 0 params
128
- return isSignal(value) || isComputed(value) || isPropGetterFn(value)
179
+ // Do NOT use length === 0 as fallback - many callbacks have 0 params.
180
+ return (
181
+ isSignal(value) || isComputed(value) || isPropGetterFn(value) || isExplicitReactiveFn(value)
182
+ )
129
183
  }
130
184
 
131
185
  // Import-like check for prop getter marker without circular dependency
@@ -135,6 +189,48 @@ function isPropGetterFn(value: unknown): boolean {
135
189
  return (value as any)[PROP_GETTER_MARKER] === true
136
190
  }
137
191
 
192
+ function isNonReactiveFn(value: unknown): boolean {
193
+ if (typeof value !== 'function') return false
194
+ if (getNonReactiveFnRegistry().has(value as (...args: unknown[]) => unknown)) return true
195
+ return (
196
+ (value as ((...args: unknown[]) => unknown) & { [NON_REACTIVE_FN_MARKER]?: boolean })[
197
+ NON_REACTIVE_FN_MARKER
198
+ ] === true
199
+ )
200
+ }
201
+
202
+ /**
203
+ * Mark a function as non-reactive so runtime bindings won't treat it as a getter.
204
+ * Useful for callback props / function-as-child patterns that must remain callbacks.
205
+ */
206
+ export function nonReactive<T extends (...args: unknown[]) => unknown>(fn: T): T {
207
+ getNonReactiveFnRegistry().add(fn as (...args: unknown[]) => unknown)
208
+ if (Object.isExtensible(fn)) {
209
+ try {
210
+ ;(fn as T & { [NON_REACTIVE_FN_MARKER]?: boolean })[NON_REACTIVE_FN_MARKER] = true
211
+ } catch {
212
+ // Ignore marker failures on non-standard function objects.
213
+ }
214
+ }
215
+ return fn
216
+ }
217
+
218
+ /**
219
+ * Mark a zero-arg function as an explicit reactive getter.
220
+ * Useful when authoring runtime code manually and you need function values
221
+ * to participate in reactive binding without relying on heuristics.
222
+ */
223
+ export function reactive<T>(fn: () => T): () => T {
224
+ if (Object.isExtensible(fn)) {
225
+ try {
226
+ ;(fn as (() => T) & { [REACTIVE_FN_MARKER]?: boolean })[REACTIVE_FN_MARKER] = true
227
+ } catch {
228
+ // Ignore marker failures on non-standard function objects.
229
+ }
230
+ }
231
+ return fn
232
+ }
233
+
138
234
  /**
139
235
  * Unwrap a potentially reactive value to get the actual value
140
236
  */
@@ -202,15 +298,11 @@ export function createTextBinding(value: MaybeReactive<unknown>): Text {
202
298
  if (isReactive(value)) {
203
299
  // Reactive: create effect to update text when value changes
204
300
  createRenderEffect(() => {
205
- const v = (value as () => unknown)()
206
- const fmt = formatTextValue(v)
207
- if (text.data !== fmt) {
208
- text.data = fmt
209
- }
301
+ setText(text, (value as () => unknown)())
210
302
  })
211
303
  } else {
212
304
  // Static: set once
213
- text.data = formatTextValue(value)
305
+ setText(text, value)
214
306
  }
215
307
 
216
308
  return text
@@ -221,12 +313,22 @@ export function createTextBinding(value: MaybeReactive<unknown>): Text {
221
313
  * This is a convenience function for binding to existing DOM nodes.
222
314
  */
223
315
  export function bindText(textNode: Text, getValue: () => unknown): Cleanup {
224
- return createRenderEffect(() => {
225
- const value = formatTextValue(getValue())
226
- if (textNode.data !== value) {
227
- textNode.data = value
228
- }
229
- })
316
+ return createRenderEffect(() => setText(textNode, getValue()))
317
+ }
318
+
319
+ /**
320
+ * Patch text node content with per-node value caching.
321
+ * This is the low-level primitive used by compiled render effects.
322
+ */
323
+ export function setText(textNode: Text, value: unknown): void {
324
+ const next = formatTextValue(value)
325
+ const cache = textNode as unknown as Record<PropertyKey, unknown>
326
+ const prev = cache[TEXT_CACHE]
327
+ if (prev === next && textNode.data === next) return
328
+ cache[TEXT_CACHE] = next
329
+ if (textNode.data !== next) {
330
+ textNode.data = next
331
+ }
230
332
  }
231
333
 
232
334
  /**
@@ -279,52 +381,53 @@ export function createAttributeBinding(
279
381
  * Bind a reactive value to an element's attribute.
280
382
  */
281
383
  export function bindAttribute(el: Element, key: string, getValue: () => unknown): Cleanup {
282
- let prevValue: unknown = undefined
283
- return createRenderEffect(() => {
284
- const value = getValue()
285
- if (value === prevValue) return
286
- prevValue = value
384
+ return createRenderEffect(() => setAttr(el, key, getValue()))
385
+ }
287
386
 
288
- if (value === undefined || value === null || value === false) {
289
- el.removeAttribute(key)
290
- } else if (value === true) {
291
- el.setAttribute(key, '')
292
- } else {
293
- el.setAttribute(key, String(value))
294
- }
295
- })
387
+ /**
388
+ * Patch attribute value with per-node, per-attribute cache.
389
+ */
390
+ export function setAttr(el: Element, key: string, value: unknown): void {
391
+ const cacheTarget = el as unknown as Record<PropertyKey, unknown>
392
+ const attrCache =
393
+ (cacheTarget[ATTR_CACHE] as Record<string, unknown> | undefined) ??
394
+ (cacheTarget[ATTR_CACHE] = Object.create(null))
395
+ if (attrCache[key] === value) return
396
+ attrCache[key] = value
397
+
398
+ if (value === undefined || value === null || value === false) {
399
+ el.removeAttribute(key)
400
+ } else if (value === true) {
401
+ el.setAttribute(key, '')
402
+ } else {
403
+ el.setAttribute(key, String(value))
404
+ }
296
405
  }
297
406
 
298
407
  /**
299
408
  * Bind a reactive value to an element's property.
300
409
  */
301
410
  export function bindProperty(el: Element, key: string, getValue: () => unknown): Cleanup {
302
- // Keep behavior aligned with the legacy createElement+applyProps path in `dom.ts`,
303
- // where certain keys must behave like DOM properties and nullish clears should
304
- // reset to sensible defaults (e.g. value -> '', checked -> false).
305
- const PROPERTY_BINDING_KEYS = new Set([
306
- 'value',
307
- 'checked',
308
- 'selected',
309
- 'disabled',
310
- 'readOnly',
311
- 'multiple',
312
- 'muted',
313
- ])
314
-
315
- let prevValue: unknown = undefined
316
- return createRenderEffect(() => {
317
- const next = getValue()
318
- if (next === prevValue) return
319
- prevValue = next
320
-
321
- if (PROPERTY_BINDING_KEYS.has(key) && (next === undefined || next === null)) {
322
- const fallback = key === 'checked' || key === 'selected' ? false : ''
323
- ;(el as unknown as Record<string, unknown>)[key] = fallback
324
- return
325
- }
326
- ;(el as unknown as Record<string, unknown>)[key] = next
327
- })
411
+ return createRenderEffect(() => setProp(el, key, getValue()))
412
+ }
413
+
414
+ /**
415
+ * Patch DOM property with per-node, per-property cache.
416
+ */
417
+ export function setProp(el: Element, key: string, value: unknown): void {
418
+ const cacheTarget = el as unknown as Record<PropertyKey, unknown>
419
+ const propCache =
420
+ (cacheTarget[PROP_CACHE] as Record<string, unknown> | undefined) ??
421
+ (cacheTarget[PROP_CACHE] = Object.create(null))
422
+ if (propCache[key] === value) return
423
+ propCache[key] = value
424
+
425
+ if (PROPERTY_BINDING_KEYS.has(key) && (value === undefined || value === null)) {
426
+ const fallback = key === 'checked' || key === 'selected' ? false : ''
427
+ ;(el as unknown as Record<string, unknown>)[key] = fallback
428
+ return
429
+ }
430
+ ;(el as unknown as Record<string, unknown>)[key] = value
328
431
  }
329
432
 
330
433
  // ============================================================================
@@ -338,16 +441,12 @@ export function createStyleBinding(
338
441
  el: Element,
339
442
  value: MaybeReactive<string | Record<string, string | number> | null | undefined>,
340
443
  ): void {
341
- const target = el as Element & { style: CSSStyleDeclaration }
342
444
  if (isReactive(value)) {
343
- let prev: unknown
344
445
  createRenderEffect(() => {
345
- const next = (value as () => unknown)()
346
- applyStyle(target, next, prev)
347
- prev = next
446
+ setStyle(el, (value as () => unknown)() as string | Record<string, string | number> | null)
348
447
  })
349
448
  } else {
350
- applyStyle(target, value, undefined)
449
+ setStyle(el, value)
351
450
  }
352
451
  }
353
452
 
@@ -358,13 +457,23 @@ export function bindStyle(
358
457
  el: Element,
359
458
  getValue: () => string | Record<string, string | number> | null | undefined,
360
459
  ): Cleanup {
460
+ return createRenderEffect(() => setStyle(el, getValue()))
461
+ }
462
+
463
+ /**
464
+ * Patch style value with cached previous style payload.
465
+ */
466
+ export function setStyle(
467
+ el: Element,
468
+ value: string | Record<string, string | number> | null | undefined,
469
+ ): void {
361
470
  const target = el as Element & { style: CSSStyleDeclaration }
362
- let prev: unknown
363
- return createRenderEffect(() => {
364
- const next = getValue()
365
- applyStyle(target, next, prev)
366
- prev = next
367
- })
471
+ const cache = target as unknown as Record<PropertyKey, unknown>
472
+ const prev = cache[STYLE_CACHE]
473
+ if (typeof value === 'string' && prev === value) return
474
+ if ((value === null || value === undefined) && (prev === null || prev === undefined)) return
475
+ applyStyle(target, value, prev)
476
+ cache[STYLE_CACHE] = value
368
477
  }
369
478
 
370
479
  /**
@@ -388,23 +497,25 @@ function applyStyle(
388
497
  // Remove styles that were present in prev but not in current
389
498
  if (prev && typeof prev === 'object') {
390
499
  const prevStyles = prev as Record<string, string | number>
391
- for (const key of Object.keys(prevStyles)) {
392
- if (!(key in styles)) {
393
- const cssProperty = key.replace(/([A-Z])/g, '-$1').toLowerCase()
500
+ for (const key in prevStyles) {
501
+ if (!hasOwn.call(prevStyles, key)) continue
502
+ if (!hasOwn.call(styles, key)) {
503
+ const cssProperty = normalizeStyleProperty(key)
394
504
  el.style.removeProperty(cssProperty)
395
505
  }
396
506
  }
397
507
  }
398
508
 
399
- for (const [prop, v] of Object.entries(styles)) {
509
+ for (const prop in styles) {
510
+ if (!hasOwn.call(styles, prop)) continue
511
+ const v = styles[prop]
400
512
  if (v != null) {
401
- // Handle camelCase to kebab-case conversion
402
- const cssProperty = prop.replace(/([A-Z])/g, '-$1').toLowerCase()
513
+ const cssProperty = normalizeStyleProperty(prop)
403
514
  const unitless = isUnitlessStyleProperty(prop) || isUnitlessStyleProperty(cssProperty)
404
515
  const valueStr = typeof v === 'number' && !unitless ? `${v}px` : String(v)
405
516
  el.style.setProperty(cssProperty, valueStr)
406
517
  } else {
407
- const cssProperty = prop.replace(/([A-Z])/g, '-$1').toLowerCase()
518
+ const cssProperty = normalizeStyleProperty(prop)
408
519
  el.style.removeProperty(cssProperty) // Handle null/undefined values by removing
409
520
  }
410
521
  }
@@ -414,8 +525,9 @@ function applyStyle(
414
525
  // Ideally we remove keys from prev.
415
526
  if (prev && typeof prev === 'object') {
416
527
  const prevStyles = prev as Record<string, string | number>
417
- for (const key of Object.keys(prevStyles)) {
418
- const cssProperty = key.replace(/([A-Z])/g, '-$1').toLowerCase()
528
+ for (const key in prevStyles) {
529
+ if (!hasOwn.call(prevStyles, key)) continue
530
+ const cssProperty = normalizeStyleProperty(key)
419
531
  el.style.removeProperty(cssProperty)
420
532
  }
421
533
  } else if (typeof prev === 'string') {
@@ -428,6 +540,14 @@ const isUnitlessStyleProperty = isDev
428
540
  ? (prop: string): boolean => UnitlessStyles.has(prop)
429
541
  : (prop: string): boolean => prop === 'opacity' || prop === 'zIndex'
430
542
 
543
+ function normalizeStyleProperty(prop: string): string {
544
+ const cached = STYLE_PROP_CACHE.get(prop)
545
+ if (cached) return cached
546
+ const normalized = prop.includes('-') ? prop : prop.replace(/([A-Z])/g, '-$1').toLowerCase()
547
+ STYLE_PROP_CACHE.set(prop, normalized)
548
+ return normalized
549
+ }
550
+
431
551
  // ============================================================================
432
552
  // Class Binding
433
553
  // ============================================================================
@@ -440,13 +560,11 @@ export function createClassBinding(
440
560
  value: MaybeReactive<string | Record<string, boolean> | null | undefined>,
441
561
  ): void {
442
562
  if (isReactive(value)) {
443
- let prev: Record<string, boolean> = {}
444
- createRenderEffect(() => {
445
- const next = (value as () => unknown)()
446
- prev = applyClass(el, next, prev)
447
- })
563
+ createRenderEffect(() =>
564
+ setClass(el, (value as () => string | Record<string, boolean> | null | undefined)()),
565
+ )
448
566
  } else {
449
- applyClass(el, value, {})
567
+ setClass(el, value)
450
568
  }
451
569
  }
452
570
 
@@ -457,21 +575,31 @@ export function bindClass(
457
575
  el: Element,
458
576
  getValue: () => string | Record<string, boolean> | null | undefined,
459
577
  ): Cleanup {
460
- let prev: Record<string, boolean> = {}
461
- let prevString: string | undefined
462
- return createRenderEffect(() => {
463
- const next = getValue()
464
- // Short-circuit for string values to avoid DOM writes when unchanged
465
- if (typeof next === 'string') {
466
- if (next === prevString) return
467
- prevString = next
468
- el.className = next
469
- prev = {}
470
- return
471
- }
472
- prevString = undefined
473
- prev = applyClass(el, next, prev)
474
- })
578
+ return createRenderEffect(() => setClass(el, getValue()))
579
+ }
580
+
581
+ /**
582
+ * Patch class value using per-node cached class state.
583
+ */
584
+ export function setClass(
585
+ el: Element,
586
+ value: string | Record<string, boolean> | null | undefined,
587
+ ): void {
588
+ const cache = el as unknown as Record<PropertyKey, unknown>
589
+ const prevValue = cache[CLASS_VALUE_CACHE]
590
+ const prevState = (cache[CLASS_STATE_CACHE] as Record<string, boolean> | undefined) ?? {}
591
+
592
+ // Preserve existing behavior: short-circuit only for stable string values.
593
+ if (typeof value === 'string') {
594
+ if (typeof prevValue === 'string' && prevValue === value) return
595
+ el.className = value
596
+ cache[CLASS_STATE_CACHE] = {}
597
+ cache[CLASS_VALUE_CACHE] = value
598
+ return
599
+ }
600
+
601
+ cache[CLASS_STATE_CACHE] = applyClass(el, value, prevState)
602
+ cache[CLASS_VALUE_CACHE] = value
475
603
  }
476
604
 
477
605
  /**
package/src/constants.ts CHANGED
@@ -15,7 +15,7 @@ import { DelegatedEventNames } from './delegated-events'
15
15
  const isDev =
16
16
  typeof __DEV__ !== 'undefined'
17
17
  ? __DEV__
18
- : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
18
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
19
19
 
20
20
  // ============================================================================
21
21
  // Boolean Attributes
@@ -3,10 +3,10 @@ import { getDevtoolsHook } from './devtools'
3
3
  const isDev =
4
4
  typeof __DEV__ !== 'undefined'
5
5
  ? __DEV__
6
- : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
6
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
7
7
 
8
8
  export interface CycleProtectionOptions {
9
- /** Enable cycle protection guards (defaults to dev-only) */
9
+ /** Enable cycle protection guards (enabled by default in all modes) */
10
10
  enabled?: boolean
11
11
  maxFlushCyclesPerMicrotask?: number
12
12
  maxEffectRunsPerFlush?: number
@@ -35,7 +35,8 @@ let enterRootGuard: (root: object) => boolean = () => true
35
35
  let exitRootGuard: (root: object) => void = () => {}
36
36
 
37
37
  const defaultOptions = {
38
- enabled: isDev,
38
+ // Keep cycle guards on in production to avoid infinite flush loops.
39
+ enabled: true,
39
40
  maxFlushCyclesPerMicrotask: 10_000,
40
41
  maxEffectRunsPerFlush: 20_000,
41
42
  windowSize: 5,
package/src/dom.ts CHANGED
@@ -60,7 +60,7 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'
60
60
  const isDev =
61
61
  typeof __DEV__ !== 'undefined'
62
62
  ? __DEV__
63
- : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
63
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
64
64
 
65
65
  let nextComponentId = 1
66
66
 
@@ -201,6 +201,12 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
201
201
  return createElementWithContext(resolved, namespace)
202
202
  }
203
203
 
204
+ // Non-reactive function values are not valid DOM nodes.
205
+ // Keep callback values inert instead of stringifying function source.
206
+ if (typeof node === 'function') {
207
+ return document.createTextNode('')
208
+ }
209
+
204
210
  if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
205
211
  // Handle BindingHandle (list/conditional bindings, etc)
206
212
  if ('marker' in node) {
@@ -507,10 +513,15 @@ function appendChildNode(
507
513
  return
508
514
  }
509
515
 
510
- // Handle getter function (recursive)
511
- if (typeof child === 'function' && (child as () => FictNode).length === 0) {
516
+ // Handle function children:
517
+ // - reactive accessors (signals/computed/getters) become child bindings
518
+ // - non-reactive callbacks stay inert
519
+ if (typeof child === 'function') {
512
520
  const childGetter = child as () => FictNode
513
- createChildBinding(parent, childGetter, node => createElementWithContext(node, namespace))
521
+ if (isReactive(childGetter)) {
522
+ createChildBinding(parent, childGetter, node => createElementWithContext(node, namespace))
523
+ return
524
+ }
514
525
  return
515
526
  }
516
527
 
package/src/hooks.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  const isDev =
12
12
  typeof __DEV__ !== 'undefined'
13
13
  ? __DEV__
14
- : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
14
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
15
15
 
16
16
  export interface HookContext {
17
17
  slots: unknown[]
package/src/internal.ts CHANGED
@@ -82,10 +82,17 @@ export {
82
82
  bindAttribute,
83
83
  bindStyle,
84
84
  bindClass,
85
+ setText,
86
+ setAttr,
87
+ setProp,
88
+ setStyle,
89
+ setClass,
85
90
  bindEvent,
86
91
  callEventHandler,
87
92
  bindProperty,
88
93
  bindRef,
94
+ nonReactive,
95
+ reactive,
89
96
  insert,
90
97
  insertBetween,
91
98
  createConditional,
package/src/lifecycle.ts CHANGED
@@ -4,7 +4,7 @@ import type { Cleanup, ErrorInfo, SuspenseToken } from './types'
4
4
  const isDev =
5
5
  typeof __DEV__ !== 'undefined'
6
6
  ? __DEV__
7
- : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
7
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
8
8
 
9
9
  type LifecycleFn = () => void | Cleanup
10
10
 
package/src/props.ts CHANGED
@@ -1,10 +1,28 @@
1
1
  import { createMemo } from './memo'
2
+ import { isComputed, isEffect, isEffectScope, isSignal } from './signal'
2
3
 
3
4
  const PROP_GETTER_MARKER = Symbol.for('fict:prop-getter')
5
+ const NON_REACTIVE_FN_MARKER = Symbol.for('fict:non-reactive-fn')
6
+ const REACTIVE_FN_MARKER = Symbol.for('fict:reactive-fn')
7
+ const NON_REACTIVE_FN_REGISTRY_KEY = Symbol.for('fict:non-reactive-fn-registry')
4
8
  const propGetters = new WeakSet<(...args: unknown[]) => unknown>()
5
9
  const rawToProxy = new WeakMap<object, object>()
6
10
  const proxyToRaw = new WeakMap<object, object>()
7
11
 
12
+ type NonReactiveRegistryHost = typeof globalThis & {
13
+ [NON_REACTIVE_FN_REGISTRY_KEY]?: WeakSet<(...args: unknown[]) => unknown>
14
+ }
15
+
16
+ function getNonReactiveFnRegistry(): WeakSet<(...args: unknown[]) => unknown> {
17
+ const host = globalThis as NonReactiveRegistryHost
18
+ let registry = host[NON_REACTIVE_FN_REGISTRY_KEY]
19
+ if (!registry) {
20
+ registry = new WeakSet<(...args: unknown[]) => unknown>()
21
+ host[NON_REACTIVE_FN_REGISTRY_KEY] = registry
22
+ }
23
+ return registry
24
+ }
25
+
8
26
  /**
9
27
  * @internal
10
28
  * Marks a zero-arg getter so props proxy can lazily evaluate it.
@@ -30,6 +48,47 @@ function isPropGetter(value: unknown): value is () => unknown {
30
48
  return propGetters.has(fn as (...args: unknown[]) => unknown) || fn[PROP_GETTER_MARKER] === true
31
49
  }
32
50
 
51
+ function isNonReactiveFn(value: unknown): boolean {
52
+ if (typeof value !== 'function') return false
53
+ if (getNonReactiveFnRegistry().has(value as (...args: unknown[]) => unknown)) return true
54
+ return (
55
+ (value as ((...args: unknown[]) => unknown) & { [NON_REACTIVE_FN_MARKER]?: boolean })[
56
+ NON_REACTIVE_FN_MARKER
57
+ ] === true
58
+ )
59
+ }
60
+
61
+ function markNonReactiveFn<T extends (...args: unknown[]) => unknown>(fn: T): T {
62
+ getNonReactiveFnRegistry().add(fn as (...args: unknown[]) => unknown)
63
+ if (Object.isExtensible(fn)) {
64
+ try {
65
+ ;(fn as T & { [NON_REACTIVE_FN_MARKER]?: boolean })[NON_REACTIVE_FN_MARKER] = true
66
+ } catch {
67
+ // Ignore marker failures on non-standard function objects.
68
+ }
69
+ }
70
+ return fn
71
+ }
72
+
73
+ function isExplicitReactiveFn(value: unknown): boolean {
74
+ if (typeof value !== 'function') return false
75
+ return (
76
+ (value as ((...args: unknown[]) => unknown) & { [REACTIVE_FN_MARKER]?: boolean })[
77
+ REACTIVE_FN_MARKER
78
+ ] === true
79
+ )
80
+ }
81
+
82
+ function normalizePropsFunction(value: unknown): unknown {
83
+ if (typeof value !== 'function') return value
84
+ if (value.length !== 0) return value
85
+ if (isPropGetter(value) || isSignal(value) || isComputed(value) || isExplicitReactiveFn(value)) {
86
+ return value
87
+ }
88
+ if (isEffect(value) || isEffectScope(value) || isNonReactiveFn(value)) return value
89
+ return markNonReactiveFn(value as (...args: unknown[]) => unknown)
90
+ }
91
+
33
92
  export function createPropsProxy<T extends Record<string, unknown>>(props: T): T {
34
93
  if (!props || typeof props !== 'object') {
35
94
  return props
@@ -50,7 +109,7 @@ export function createPropsProxy<T extends Record<string, unknown>>(props: T): T
50
109
  if (isPropGetter(value)) {
51
110
  return value()
52
111
  }
53
- return value
112
+ return normalizePropsFunction(value)
54
113
  },
55
114
  set(target, prop, value, receiver) {
56
115
  return Reflect.set(target, prop, value, receiver)