@fictjs/runtime 0.8.0 → 0.10.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 (86) hide show
  1. package/README.md +46 -0
  2. package/dist/advanced.cjs +9 -9
  3. package/dist/advanced.d.cts +5 -5
  4. package/dist/advanced.d.ts +5 -5
  5. package/dist/advanced.js +4 -4
  6. package/dist/{effect-DAzpH7Mm.d.ts → binding-DUEukRxl.d.cts} +35 -24
  7. package/dist/{effect-DAzpH7Mm.d.cts → binding-DqxS9ZQf.d.ts} +35 -24
  8. package/dist/{chunk-WRU3IZOA.js → chunk-2JRPPCG7.js} +3 -3
  9. package/dist/{chunk-TLDT76RV.js → chunk-DKA2I6ET.js} +3 -3
  10. package/dist/{chunk-FSCBL7RI.cjs → chunk-EQ5E4WOV.cjs} +702 -534
  11. package/dist/chunk-EQ5E4WOV.cjs.map +1 -0
  12. package/dist/{chunk-7YQK3XKY.js → chunk-F4RVNXOL.js} +687 -519
  13. package/dist/chunk-F4RVNXOL.js.map +1 -0
  14. package/dist/{chunk-PRF4QG73.cjs → chunk-I4GKKAAY.cjs} +469 -248
  15. package/dist/chunk-I4GKKAAY.cjs.map +1 -0
  16. package/dist/{chunk-HHDHQGJY.cjs → chunk-K3DH5SD5.cjs} +17 -17
  17. package/dist/{chunk-HHDHQGJY.cjs.map → chunk-K3DH5SD5.cjs.map} +1 -1
  18. package/dist/chunk-P4TZLFV6.js +768 -0
  19. package/dist/chunk-P4TZLFV6.js.map +1 -0
  20. package/dist/{chunk-4LCHQ7U4.js → chunk-R6FINS25.js} +318 -97
  21. package/dist/chunk-R6FINS25.js.map +1 -0
  22. package/dist/chunk-SZLJCQFZ.cjs +768 -0
  23. package/dist/chunk-SZLJCQFZ.cjs.map +1 -0
  24. package/dist/{chunk-CEV6TO5U.cjs → chunk-V7BC64W2.cjs} +8 -8
  25. package/dist/{chunk-CEV6TO5U.cjs.map → chunk-V7BC64W2.cjs.map} +1 -1
  26. package/dist/{context-BFbHf9nC.d.cts → devtools-C4Hgfa-S.d.ts} +47 -35
  27. package/dist/{context-C4vBQbb4.d.ts → devtools-CMxlJUTx.d.cts} +47 -35
  28. package/dist/index.cjs +42 -42
  29. package/dist/index.d.cts +5 -5
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.dev.js +233 -28
  32. package/dist/index.dev.js.map +1 -1
  33. package/dist/index.js +3 -3
  34. package/dist/internal-list.cjs +12 -0
  35. package/dist/internal-list.cjs.map +1 -0
  36. package/dist/internal-list.d.cts +2 -0
  37. package/dist/internal-list.d.ts +2 -0
  38. package/dist/internal-list.js +12 -0
  39. package/dist/internal-list.js.map +1 -0
  40. package/dist/internal.cjs +6 -746
  41. package/dist/internal.cjs.map +1 -1
  42. package/dist/internal.d.cts +7 -75
  43. package/dist/internal.d.ts +7 -75
  44. package/dist/internal.js +12 -752
  45. package/dist/internal.js.map +1 -1
  46. package/dist/jsx-dev-runtime.d.cts +671 -0
  47. package/dist/jsx-dev-runtime.d.ts +671 -0
  48. package/dist/jsx-runtime.d.cts +671 -0
  49. package/dist/jsx-runtime.d.ts +671 -0
  50. package/dist/list-BBzsJhrm.d.ts +71 -0
  51. package/dist/list-_NJCcjl1.d.cts +71 -0
  52. package/dist/loader.cjs +99 -16
  53. package/dist/loader.cjs.map +1 -1
  54. package/dist/loader.d.cts +17 -3
  55. package/dist/loader.d.ts +17 -3
  56. package/dist/loader.js +92 -9
  57. package/dist/loader.js.map +1 -1
  58. package/dist/{props-84UJeWO8.d.cts → props--zJ4ebbT.d.cts} +3 -3
  59. package/dist/{props-BRhFK50f.d.ts → props-BAGR7j-j.d.ts} +3 -3
  60. package/dist/{resume-i-A3EFox.d.cts → resume-C5IKAIdh.d.ts} +5 -3
  61. package/dist/{resume-CqeQ3v_q.d.ts → resume-DPZxmA95.d.cts} +5 -3
  62. package/dist/{scope-D3DpsfoG.d.ts → scope-CuImnvh1.d.ts} +1 -1
  63. package/dist/{scope-DlCBL1Ft.d.cts → scope-Dq5hOu7c.d.cts} +1 -1
  64. package/dist/{signal-C4ISF17w.d.cts → signal-Z4KkDk9h.d.cts} +12 -1
  65. package/dist/{signal-C4ISF17w.d.ts → signal-Z4KkDk9h.d.ts} +12 -1
  66. package/package.json +9 -2
  67. package/src/binding.ts +113 -36
  68. package/src/cycle-guard.ts +3 -3
  69. package/src/devtools.ts +19 -2
  70. package/src/dom.ts +58 -4
  71. package/src/effect.ts +5 -5
  72. package/src/hooks.ts +13 -5
  73. package/src/internal/list.ts +7 -0
  74. package/src/internal.ts +1 -0
  75. package/src/lifecycle.ts +41 -3
  76. package/src/list-helpers.ts +1 -1
  77. package/src/loader.ts +128 -12
  78. package/src/resume.ts +6 -3
  79. package/src/signal.ts +200 -20
  80. package/src/transition.ts +9 -3
  81. package/dist/chunk-4LCHQ7U4.js.map +0 -1
  82. package/dist/chunk-7YQK3XKY.js.map +0 -1
  83. package/dist/chunk-FSCBL7RI.cjs.map +0 -1
  84. package/dist/chunk-PRF4QG73.cjs.map +0 -1
  85. /package/dist/{chunk-WRU3IZOA.js.map → chunk-2JRPPCG7.js.map} +0 -0
  86. /package/dist/{chunk-TLDT76RV.js.map → chunk-DKA2I6ET.js.map} +0 -0
package/src/binding.ts CHANGED
@@ -112,6 +112,16 @@ export interface BindingHandle {
112
112
  dispose: Cleanup
113
113
  }
114
114
 
115
+ export interface ConditionalBindingOptions {
116
+ /**
117
+ * When true, track signal reads inside active branch render callbacks and
118
+ * re-run the branch callback on updates even if the top-level condition stays
119
+ * the same. This preserves reactivity for control-flow callbacks that cannot
120
+ * be lowered into fine-grained bindings.
121
+ */
122
+ trackBranchReads?: boolean
123
+ }
124
+
115
125
  /** Managed child node with its dispose function */
116
126
  // ============================================================================
117
127
  // Utility Functions
@@ -1743,7 +1753,9 @@ export function createConditional(
1743
1753
  renderFalse?: () => FictNode,
1744
1754
  startOverride?: Comment,
1745
1755
  endOverride?: Comment,
1756
+ options?: ConditionalBindingOptions,
1746
1757
  ): BindingHandle {
1758
+ const trackBranchReads = options?.trackBranchReads === true
1747
1759
  const useProvided = !!(startOverride && endOverride)
1748
1760
  const startMarker = useProvided ? startOverride! : document.createComment('fict:cond:start')
1749
1761
  const endMarker = useProvided ? endOverride! : document.createComment('fict:cond:end')
@@ -1805,7 +1817,7 @@ export function createConditional(
1805
1817
  endMarker,
1806
1818
  parent.ownerDocument ?? document,
1807
1819
  () => {
1808
- const output = untrack(render)
1820
+ const output = trackBranchReads ? render() : untrack(render)
1809
1821
  if (output == null || output === false) {
1810
1822
  return
1811
1823
  }
@@ -1837,10 +1849,102 @@ export function createConditional(
1837
1849
  return
1838
1850
  }
1839
1851
 
1840
- if (lastCondition === cond && currentNodes.length > 0) {
1841
- return
1842
- }
1843
- if (lastCondition === cond && lastCondition === false && renderFalse === undefined) {
1852
+ if (!trackBranchReads) {
1853
+ if (lastCondition === cond && currentNodes.length > 0) {
1854
+ return
1855
+ }
1856
+ if (lastCondition === cond && lastCondition === false && renderFalse === undefined) {
1857
+ return
1858
+ }
1859
+ } else if (lastCondition === cond) {
1860
+ const render = cond ? renderTrue : renderFalse
1861
+ if (!render) {
1862
+ return
1863
+ }
1864
+
1865
+ let patched = false
1866
+ const scratchRoot = createRootContext(hostRoot)
1867
+ const prevScratch = pushRoot(scratchRoot)
1868
+ let handledPatchError = false
1869
+ let scratchOutput: FictNode = null
1870
+ try {
1871
+ const output = render()
1872
+ scratchOutput = output
1873
+ if (output != null && output !== false) {
1874
+ if (currentNodes.length === 1) {
1875
+ patched = patchNode(currentNodes[0] ?? null, output)
1876
+ }
1877
+ if (!patched && Array.isArray(output)) {
1878
+ patched = _patchFragmentChildren(currentNodes, output)
1879
+ }
1880
+ if (!patched && _isFragmentVNode(output)) {
1881
+ patched = _patchFragmentChildren(currentNodes, output.props?.children)
1882
+ }
1883
+ }
1884
+ } catch (err) {
1885
+ if (handleSuspend(err as any, scratchRoot)) {
1886
+ handledPatchError = true
1887
+ return
1888
+ }
1889
+ if (handleError(err, { source: 'renderChild' }, scratchRoot)) {
1890
+ handledPatchError = true
1891
+ return
1892
+ }
1893
+ throw err
1894
+ } finally {
1895
+ popRoot(prevScratch)
1896
+ }
1897
+
1898
+ if (handledPatchError) {
1899
+ destroyRoot(scratchRoot)
1900
+ return
1901
+ }
1902
+
1903
+ if (patched) {
1904
+ destroyRoot(scratchRoot)
1905
+ return
1906
+ }
1907
+
1908
+ lastCondition = cond
1909
+
1910
+ if (currentRoot) {
1911
+ destroyRoot(currentRoot)
1912
+ currentRoot = null
1913
+ }
1914
+ removeNodes(currentNodes)
1915
+ currentNodes = []
1916
+
1917
+ const prev = pushRoot(scratchRoot)
1918
+ let handledError = false
1919
+ try {
1920
+ if (scratchOutput == null || scratchOutput === false) {
1921
+ return
1922
+ }
1923
+ const el = createElementFn(scratchOutput)
1924
+ const nodes = toNodeArray(el)
1925
+ insertNodesBefore(parent, nodes, endMarker)
1926
+ currentNodes = nodes
1927
+ } catch (err) {
1928
+ if (handleSuspend(err as any, scratchRoot)) {
1929
+ handledError = true
1930
+ destroyRoot(scratchRoot)
1931
+ return
1932
+ }
1933
+ if (handleError(err, { source: 'renderChild' }, scratchRoot)) {
1934
+ handledError = true
1935
+ destroyRoot(scratchRoot)
1936
+ return
1937
+ }
1938
+ throw err
1939
+ } finally {
1940
+ popRoot(prev)
1941
+ if (!handledError) {
1942
+ flushOnMount(scratchRoot)
1943
+ currentRoot = scratchRoot
1944
+ } else {
1945
+ currentRoot = null
1946
+ }
1947
+ }
1844
1948
  return
1845
1949
  }
1846
1950
  lastCondition = cond
@@ -1865,7 +1969,7 @@ export function createConditional(
1865
1969
  // tracked by createConditional's effect. This ensures that signals used
1866
1970
  // inside the render function (e.g., nested if conditions) don't cause
1867
1971
  // createConditional to re-run, which would purge child effect deps.
1868
- const output = untrack(render)
1972
+ const output = trackBranchReads ? render() : untrack(render)
1869
1973
  if (output == null || output === false) {
1870
1974
  return
1871
1975
  }
@@ -2050,23 +2154,6 @@ export function createPortal(
2050
2154
  }
2051
2155
 
2052
2156
  function patchElement(el: Element, output: FictNode): boolean {
2053
- if (
2054
- output === null ||
2055
- output === undefined ||
2056
- output === false ||
2057
- typeof output === 'string' ||
2058
- typeof output === 'number'
2059
- ) {
2060
- el.textContent =
2061
- output === null || output === undefined || output === false ? '' : String(output)
2062
- return true
2063
- }
2064
-
2065
- if (output instanceof Text) {
2066
- el.textContent = output.data
2067
- return true
2068
- }
2069
-
2070
2157
  if (output && typeof output === 'object' && !(output instanceof Node)) {
2071
2158
  const vnode = output as { type?: unknown; props?: Record<string, unknown> }
2072
2159
  if (typeof vnode.type === 'string' && vnode.type.toLowerCase() === el.tagName.toLowerCase()) {
@@ -2131,19 +2218,6 @@ function patchElement(el: Element, output: FictNode): boolean {
2131
2218
  }
2132
2219
  }
2133
2220
 
2134
- if (output instanceof Node) {
2135
- if (output.nodeType === Node.ELEMENT_NODE) {
2136
- const nextEl = output as Element
2137
- if (nextEl.tagName.toLowerCase() === el.tagName.toLowerCase()) {
2138
- el.textContent = nextEl.textContent
2139
- return true
2140
- }
2141
- } else if (output.nodeType === Node.TEXT_NODE) {
2142
- el.textContent = (output as Text).data
2143
- return true
2144
- }
2145
- }
2146
-
2147
2221
  return false
2148
2222
  }
2149
2223
 
@@ -2207,6 +2281,9 @@ function normalizeChildren(
2207
2281
  if (children === null || children === false) {
2208
2282
  return result
2209
2283
  }
2284
+ if (_isFragmentVNode(children)) {
2285
+ return normalizeChildren(children.props?.children, result)
2286
+ }
2210
2287
  result.push(children)
2211
2288
  return result
2212
2289
  }
@@ -6,7 +6,7 @@ const isDev =
6
6
  : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
7
7
 
8
8
  export interface CycleProtectionOptions {
9
- /** Enable cycle protection guards (enabled by default in all modes) */
9
+ /** Enable cycle protection guards (enabled by default in dev mode) */
10
10
  enabled?: boolean
11
11
  maxFlushCyclesPerMicrotask?: number
12
12
  maxEffectRunsPerFlush?: number
@@ -35,8 +35,8 @@ let enterRootGuard: (root: object) => boolean = () => true
35
35
  let exitRootGuard: (root: object) => void = () => {}
36
36
 
37
37
  const defaultOptions = {
38
- // Keep cycle guards on in production to avoid infinite flush loops.
39
- enabled: true,
38
+ // DX-first in development, performance-first in production.
39
+ enabled: isDev,
40
40
  maxFlushCyclesPerMicrotask: 10_000,
41
41
  maxEffectRunsPerFlush: 20_000,
42
42
  windowSize: 5,
package/src/devtools.ts CHANGED
@@ -5,18 +5,35 @@ export interface FictDevtoolsHook {
5
5
  options?: { name?: string; source?: string; ownerId?: number },
6
6
  ) => void
7
7
  updateSignal: (id: number, value: unknown) => void
8
+ disposeSignal?: (id: number) => void
8
9
  registerComputed: (
9
10
  id: number,
10
11
  value: unknown,
11
- options?: { name?: string; source?: string; ownerId?: number; hasValue?: boolean },
12
+ options?: {
13
+ name?: string
14
+ source?: string
15
+ ownerId?: number
16
+ hasValue?: boolean
17
+ internal?: boolean
18
+ },
12
19
  ) => void
13
20
  updateComputed: (id: number, value: unknown) => void
21
+ disposeComputed?: (id: number) => void
14
22
  registerEffect: (id: number, options?: { ownerId?: number; source?: string }) => void
15
- effectRun: (id: number) => void
23
+ effectRun: (id: number, duration?: number) => void
24
+ effectCleanup?: (id: number) => void
25
+ disposeEffect?: (id: number) => void
16
26
  /** Track a dependency relationship between subscriber and dependency */
17
27
  trackDependency?: (subscriberId: number, dependencyId: number) => void
18
28
  /** Remove a dependency relationship when unlinked */
19
29
  untrackDependency?: (subscriberId: number, dependencyId: number) => void
30
+ registerRoot?: (id: number, name?: string) => void
31
+ disposeRoot?: (id: number) => void
32
+ rootSuspend?: (id: number, suspended: boolean) => void
33
+ batchStart?: () => void
34
+ batchEnd?: () => void
35
+ flushStart?: () => void
36
+ flushEnd?: () => void
20
37
  cycleDetected?: (payload: { reason: string; detail?: Record<string, unknown> }) => void
21
38
 
22
39
  // Component lifecycle
package/src/dom.ts CHANGED
@@ -64,6 +64,46 @@ const isDev =
64
64
 
65
65
  let nextComponentId = 1
66
66
 
67
+ type DevtoolsAnnotatedElement = HTMLElement & {
68
+ __fict_component_id__?: number
69
+ __fict_component_name__?: string
70
+ }
71
+
72
+ function collectComponentMountElements(node: Node): HTMLElement[] {
73
+ if (node instanceof DocumentFragment) {
74
+ return Array.from(node.childNodes).filter(
75
+ (child): child is HTMLElement => child instanceof HTMLElement,
76
+ )
77
+ }
78
+
79
+ if (node instanceof HTMLElement) {
80
+ // Resumable hosts use display: contents; surface concrete child elements for inspection.
81
+ if (node.hasAttribute('data-fict-host')) {
82
+ const children = Array.from(node.children).filter(
83
+ (child): child is HTMLElement => child instanceof HTMLElement,
84
+ )
85
+ if (children.length > 0) return children
86
+ }
87
+ return [node]
88
+ }
89
+
90
+ return []
91
+ }
92
+
93
+ function annotateComponentElements(
94
+ elements: HTMLElement[],
95
+ componentId: number,
96
+ componentName: string,
97
+ ): void {
98
+ for (const element of elements) {
99
+ element.setAttribute('data-fict-component', componentName)
100
+ element.setAttribute('data-fict-component-id', String(componentId))
101
+ const annotated = element as DevtoolsAnnotatedElement
102
+ annotated.__fict_component_id__ = componentId
103
+ annotated.__fict_component_name__ = componentName
104
+ }
105
+ }
106
+
67
107
  // ============================================================================
68
108
  // Main Render Function
69
109
  // ============================================================================
@@ -282,12 +322,13 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
282
322
  // Create a fresh hook context for this component instance.
283
323
  // This preserves slot state across re-renders driven by __fictRender.
284
324
  const hook = isDev ? getDevtoolsHook() : undefined
325
+ const componentName = vnode.type.name || 'Anonymous'
285
326
  const parentId = hook ? __fictGetCurrentComponentId() : undefined
286
327
  const componentId = hook ? nextComponentId++ : undefined
287
328
 
288
329
  // Register component
289
330
  if (hook?.registerComponent && componentId !== undefined) {
290
- hook.registerComponent(componentId, vnode.type.name || 'Anonymous', parentId)
331
+ hook.registerComponent(componentId, componentName, parentId)
291
332
  }
292
333
 
293
334
  const ctx = __fictPushContext()
@@ -300,11 +341,16 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
300
341
 
301
342
  try {
302
343
  const rendered = vnode.type(props)
344
+ let mountElements: HTMLElement[] | undefined
345
+
346
+ if (hook && componentId !== undefined) {
347
+ hook.componentRender?.(componentId)
348
+ }
303
349
 
304
350
  // Register lifecycle hooks
305
351
  if (hook && componentId !== undefined) {
306
352
  onMount(() => {
307
- hook.componentMount?.(componentId)
353
+ hook.componentMount?.(componentId, mountElements)
308
354
  })
309
355
  onCleanup(() => hook.componentUnmount?.(componentId))
310
356
  }
@@ -332,10 +378,18 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
332
378
  } else {
333
379
  host.appendChild(content)
334
380
  }
381
+ if (hook && componentId !== undefined) {
382
+ mountElements = collectComponentMountElements(host)
383
+ annotateComponentElements(mountElements, componentId, componentName)
384
+ }
335
385
  return host as DOMElement
336
386
  }
337
-
338
- return createElementWithContext(rendered as FictNode, namespace)
387
+ const componentRoot = createElementWithContext(rendered as FictNode, namespace)
388
+ if (hook && componentId !== undefined) {
389
+ mountElements = collectComponentMountElements(componentRoot)
390
+ annotateComponentElements(mountElements, componentId, componentName)
391
+ }
392
+ return componentRoot
339
393
  } catch (err) {
340
394
  if (handleSuspend(err as any)) {
341
395
  return document.createComment('fict:suspend')
package/src/effect.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  runCleanupList,
7
7
  withEffectCleanups,
8
8
  } from './lifecycle'
9
- import { effectWithCleanup } from './signal'
9
+ import { effectWithCleanup, type EffectOptions } from './signal'
10
10
  import type { Cleanup } from './types'
11
11
 
12
12
  /**
@@ -15,7 +15,7 @@ import type { Cleanup } from './types'
15
15
  */
16
16
  export type Effect = () => void | Cleanup
17
17
 
18
- export function createEffect(fn: Effect): () => void {
18
+ export function createEffect(fn: Effect, options?: EffectOptions): () => void {
19
19
  let cleanups: Cleanup[] = []
20
20
  const rootForError = getCurrentRoot()
21
21
 
@@ -47,7 +47,7 @@ export function createEffect(fn: Effect): () => void {
47
47
  cleanups = bucket
48
48
  }
49
49
 
50
- const disposeEffect = effectWithCleanup(run, doCleanup, rootForError)
50
+ const disposeEffect = effectWithCleanup(run, doCleanup, rootForError, options)
51
51
  const teardown = () => {
52
52
  runCleanupList(cleanups)
53
53
  disposeEffect()
@@ -60,7 +60,7 @@ export function createEffect(fn: Effect): () => void {
60
60
 
61
61
  export const $effect = createEffect
62
62
 
63
- export function createRenderEffect(fn: Effect): () => void {
63
+ export function createRenderEffect(fn: Effect, options?: EffectOptions): () => void {
64
64
  let cleanup: Cleanup | undefined
65
65
  const rootForError = getCurrentRoot()
66
66
 
@@ -91,7 +91,7 @@ export function createRenderEffect(fn: Effect): () => void {
91
91
  }
92
92
  }
93
93
 
94
- const disposeEffect = effectWithCleanup(run, doCleanup, rootForError)
94
+ const disposeEffect = effectWithCleanup(run, doCleanup, rootForError, options)
95
95
  const teardown = () => {
96
96
  if (cleanup) {
97
97
  cleanup()
package/src/hooks.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  createSignal,
5
5
  type SignalAccessor,
6
6
  type ComputedAccessor,
7
+ type EffectOptions,
7
8
  type MemoOptions,
8
9
  type SignalOptions,
9
10
  } from './signal'
@@ -118,17 +119,24 @@ export function __fictUseMemo<T>(
118
119
  return ctx.slots[index] as ComputedAccessor<T>
119
120
  }
120
121
 
121
- export function __fictUseEffect(ctx: HookContext, fn: () => void, slot?: number): void {
122
+ export function __fictUseEffect(
123
+ ctx: HookContext,
124
+ fn: () => void,
125
+ optionsOrSlot?: number | EffectOptions,
126
+ slot?: number,
127
+ ): void {
128
+ const options = typeof optionsOrSlot === 'number' ? undefined : optionsOrSlot
129
+ const resolvedSlot = typeof optionsOrSlot === 'number' ? optionsOrSlot : slot
122
130
  // fix: When a slot number is provided, we trust the compiler has allocated this slot.
123
131
  // This allows effects inside conditional callbacks to work even outside render context.
124
132
  // The slot number proves this is a known, statically-allocated effect location.
125
- if (slot !== undefined) {
126
- if (ctx.slots[slot]) {
133
+ if (resolvedSlot !== undefined) {
134
+ if (ctx.slots[resolvedSlot]) {
127
135
  // Effect already exists, nothing to do
128
136
  return
129
137
  }
130
138
  // Create the effect even outside render context - the slot number proves validity
131
- ctx.slots[slot] = createEffect(fn)
139
+ ctx.slots[resolvedSlot] = createEffect(fn, options)
132
140
  return
133
141
  }
134
142
 
@@ -136,7 +144,7 @@ export function __fictUseEffect(ctx: HookContext, fn: () => void, slot?: number)
136
144
  assertRenderContext(ctx, '__fictUseEffect')
137
145
  const index = ctx.cursor++
138
146
  if (!ctx.slots[index]) {
139
- ctx.slots[index] = createEffect(fn)
147
+ ctx.slots[index] = createEffect(fn, options)
140
148
  }
141
149
  }
142
150
 
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Lightweight internal list helpers for compiler-generated keyed list paths.
3
+ *
4
+ * This subpath avoids pulling the broad `@fictjs/runtime/internal` barrel when
5
+ * code only needs list primitives.
6
+ */
7
+ export { createKeyedList, toNodeArray, type KeyedListBinding } from '../list-helpers'
package/src/internal.ts CHANGED
@@ -62,6 +62,7 @@ export {
62
62
  __fictQrl,
63
63
  __fictRegisterResume,
64
64
  __fictGetResume,
65
+ FICT_SSR_SNAPSHOT_SCHEMA_VERSION,
65
66
  serializeValue,
66
67
  deserializeValue,
67
68
  } from './resume'
package/src/lifecycle.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { enterRootGuard, exitRootGuard } from './cycle-guard'
2
+ import { getDevtoolsHook } from './devtools'
2
3
  import type { Cleanup, ErrorInfo, SuspenseToken } from './types'
3
4
 
4
5
  const isDev =
@@ -29,9 +30,39 @@ let currentRoot: RootContext | undefined
29
30
  let currentEffectCleanups: Cleanup[] | undefined
30
31
  const globalErrorHandlers = new WeakMap<RootContext, ErrorHandler[]>()
31
32
  const globalSuspenseHandlers = new WeakMap<RootContext, SuspenseHandler[]>()
33
+ const rootDevtoolsIds = new WeakMap<RootContext, number>()
34
+ let nextRootDevtoolsId = 0
35
+
36
+ function registerRootDevtools(root: RootContext): void {
37
+ if (!isDev) return
38
+ const hook = getDevtoolsHook()
39
+ if (!hook?.registerRoot) return
40
+ const id = ++nextRootDevtoolsId
41
+ rootDevtoolsIds.set(root, id)
42
+ hook.registerRoot(id)
43
+ }
44
+
45
+ function disposeRootDevtools(root: RootContext): void {
46
+ if (!isDev) return
47
+ const id = rootDevtoolsIds.get(root)
48
+ if (id === undefined) return
49
+ const hook = getDevtoolsHook()
50
+ hook?.disposeRoot?.(id)
51
+ rootDevtoolsIds.delete(root)
52
+ }
53
+
54
+ function setRootSuspendDevtools(root: RootContext, suspended: boolean): void {
55
+ if (!isDev) return
56
+ const id = rootDevtoolsIds.get(root)
57
+ if (id === undefined) return
58
+ const hook = getDevtoolsHook()
59
+ hook?.rootSuspend?.(id, suspended)
60
+ }
32
61
 
33
62
  export function createRootContext(parent?: RootContext): RootContext {
34
- return { parent, cleanups: [], destroyCallbacks: [], suspended: false }
63
+ const root = { parent, cleanups: [], destroyCallbacks: [], suspended: false }
64
+ registerRootDevtools(root)
65
+ return root
35
66
  }
36
67
 
37
68
  export function pushRoot(root: RootContext): RootContext | undefined {
@@ -122,6 +153,7 @@ export function destroyRoot(root: RootContext): void {
122
153
  if (globalSuspenseHandlers.has(root)) {
123
154
  globalSuspenseHandlers.delete(root)
124
155
  }
156
+ disposeRootDevtools(root)
125
157
  }
126
158
 
127
159
  export function createRoot<T>(
@@ -285,7 +317,10 @@ export function handleSuspend(
285
317
  const handled = handler(token)
286
318
  if (handled !== false) {
287
319
  // Only set suspended = true when a handler actually handles the token
288
- if (originRoot) originRoot.suspended = true
320
+ if (originRoot) {
321
+ originRoot.suspended = true
322
+ setRootSuspendDevtools(originRoot, true)
323
+ }
289
324
  return true
290
325
  }
291
326
  }
@@ -304,7 +339,10 @@ export function handleSuspend(
304
339
  const handled = handler(token)
305
340
  if (handled !== false) {
306
341
  // Only set suspended = true when a handler actually handles the token
307
- if (originRoot) originRoot.suspended = true
342
+ if (originRoot) {
343
+ originRoot.suspended = true
344
+ setRootSuspendDevtools(originRoot, true)
345
+ }
308
346
  return true
309
347
  }
310
348
  }
@@ -30,7 +30,7 @@ export { insertNodesBefore, removeNodes, toNodeArray }
30
30
  const isDev =
31
31
  typeof __DEV__ !== 'undefined'
32
32
  ? __DEV__
33
- : typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
33
+ : typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
34
34
 
35
35
  const isShadowRoot = (node: Node): node is ShadowRoot =>
36
36
  typeof ShadowRoot !== 'undefined' && node instanceof ShadowRoot