@fictjs/runtime 0.17.0 → 0.17.1

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 (64) hide show
  1. package/dist/advanced.cjs +9 -9
  2. package/dist/advanced.d.cts +3 -3
  3. package/dist/advanced.d.ts +3 -3
  4. package/dist/advanced.js +4 -4
  5. package/dist/{binding-CQUGLLBI.d.ts → binding-BfzY9rae.d.ts} +2 -2
  6. package/dist/{binding-BlABuUiG.d.cts → binding-CDR2ERoq.d.cts} +2 -2
  7. package/dist/{chunk-5FVWBK4M.cjs → chunk-2J4INHDT.cjs} +40 -40
  8. package/dist/{chunk-5FVWBK4M.cjs.map → chunk-2J4INHDT.cjs.map} +1 -1
  9. package/dist/{chunk-6DNYVH5U.cjs → chunk-CKKZDUHM.cjs} +21 -18
  10. package/dist/chunk-CKKZDUHM.cjs.map +1 -0
  11. package/dist/{chunk-UQTWIV3S.js → chunk-DHRRJJ6W.js} +8 -5
  12. package/dist/chunk-DHRRJJ6W.js.map +1 -0
  13. package/dist/{chunk-IIWHTV23.js → chunk-LFLFSJFU.js} +3 -3
  14. package/dist/{chunk-ECKYFH5Q.cjs → chunk-NBDEMBBX.cjs} +43 -81
  15. package/dist/chunk-NBDEMBBX.cjs.map +1 -0
  16. package/dist/{chunk-CFAWL76V.js → chunk-OKPQWORE.js} +43 -81
  17. package/dist/chunk-OKPQWORE.js.map +1 -0
  18. package/dist/{chunk-M42N54LG.js → chunk-OLHZBAIF.js} +3 -3
  19. package/dist/{chunk-F5SDRX4J.js → chunk-R2HYEOP7.js} +470 -172
  20. package/dist/chunk-R2HYEOP7.js.map +1 -0
  21. package/dist/{chunk-INYTG4NG.cjs → chunk-UG2IFQOY.cjs} +650 -352
  22. package/dist/chunk-UG2IFQOY.cjs.map +1 -0
  23. package/dist/{chunk-WY4LI5PB.cjs → chunk-VP2WC7X3.cjs} +8 -8
  24. package/dist/{chunk-WY4LI5PB.cjs.map → chunk-VP2WC7X3.cjs.map} +1 -1
  25. package/dist/{devtools-DWIZRe7L.d.cts → devtools-BwkkQ6DN.d.cts} +1 -1
  26. package/dist/{devtools-DNnnDGu1.d.ts → devtools-CK3SVU_w.d.ts} +1 -1
  27. package/dist/index.cjs +55 -42
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +4 -4
  30. package/dist/index.d.ts +4 -4
  31. package/dist/index.dev.js +260 -156
  32. package/dist/index.dev.js.map +1 -1
  33. package/dist/index.js +16 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal-list.cjs +4 -4
  36. package/dist/internal-list.js +3 -3
  37. package/dist/internal.cjs +5 -5
  38. package/dist/internal.d.cts +3 -3
  39. package/dist/internal.d.ts +3 -3
  40. package/dist/internal.js +4 -4
  41. package/dist/loader.cjs +18 -18
  42. package/dist/loader.js +1 -1
  43. package/dist/{props-C04ScJgm.d.ts → props-CFoQ471Y.d.ts} +1 -1
  44. package/dist/{props-CdmuXCiu.d.cts → props-D4tK8Gn0.d.cts} +1 -1
  45. package/dist/{scope-gpOMWTlf.d.ts → scope-BFzD_7hx.d.ts} +1 -1
  46. package/dist/{scope-GwC4DJ50.d.cts → scope-Ck3mTQVS.d.cts} +1 -1
  47. package/package.json +1 -1
  48. package/src/binding.ts +561 -166
  49. package/src/context.ts +8 -1
  50. package/src/dom.ts +26 -44
  51. package/src/effect.ts +9 -12
  52. package/src/error-boundary.ts +8 -0
  53. package/src/hydration.ts +25 -6
  54. package/src/lifecycle.ts +31 -79
  55. package/src/signal.ts +4 -1
  56. package/src/suspense.ts +8 -0
  57. package/dist/chunk-6DNYVH5U.cjs.map +0 -1
  58. package/dist/chunk-CFAWL76V.js.map +0 -1
  59. package/dist/chunk-ECKYFH5Q.cjs.map +0 -1
  60. package/dist/chunk-F5SDRX4J.js.map +0 -1
  61. package/dist/chunk-INYTG4NG.cjs.map +0 -1
  62. package/dist/chunk-UQTWIV3S.js.map +0 -1
  63. /package/dist/{chunk-IIWHTV23.js.map → chunk-LFLFSJFU.js.map} +0 -0
  64. /package/dist/{chunk-M42N54LG.js.map → chunk-OLHZBAIF.js.map} +0 -0
package/src/context.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  type RootContext,
57
57
  } from './lifecycle'
58
58
  import { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'
59
+ import { untrack } from './signal'
59
60
  import type { BaseProps, FictNode } from './types'
60
61
 
61
62
  // ============================================================================
@@ -222,7 +223,13 @@ export function createContext<T>(defaultValue: T): Context<T> {
222
223
  createRenderEffect(() => {
223
224
  // Update context value on re-render (if value prop changes reactively)
224
225
  contextMap.set(id, props.value)
225
- renderChildren(props.children)
226
+
227
+ // Provider value updates should not subscribe this effect to arbitrary
228
+ // signal reads that happen while rendering descendants. Child trees own
229
+ // their own reactivity; the provider only needs to react to its props.
230
+ untrack(() => {
231
+ renderChildren(props.children)
232
+ })
226
233
  })
227
234
 
228
235
  return fragment
package/src/dom.ts CHANGED
@@ -19,7 +19,9 @@ import {
19
19
  createClassBinding,
20
20
  createChildBinding,
21
21
  bindEvent,
22
+ bindRef,
22
23
  isReactive,
24
+ registerCreateElement,
23
25
  type MaybeReactive,
24
26
  type AttributeSetter,
25
27
  type BindingHandle,
@@ -27,7 +29,7 @@ import {
27
29
  import { Properties, ChildProperties, getPropAlias, SVGElements, SVGNamespace } from './constants'
28
30
  import { getDevtoolsHook } from './devtools'
29
31
  import { __fictPushContext, __fictPopContext, __fictGetCurrentComponentId } from './hooks'
30
- import { claimNodes, isHydratingActive, withHydration } from './hydration'
32
+ import { claimNodes, claimText, isHydratingActive, withHydration } from './hydration'
31
33
  import { Fragment } from './jsx'
32
34
  import {
33
35
  createRootContext,
@@ -51,7 +53,7 @@ import {
51
53
  __fictExitHydration,
52
54
  } from './resume'
53
55
  import { untrack } from './scheduler'
54
- import type { DOMElement, FictNode, FictVNode, RefObject } from './types'
56
+ import type { DOMElement, FictNode, FictVNode } from './types'
55
57
 
56
58
  type NamespaceContext = 'svg' | 'mathml' | null
57
59
 
@@ -214,6 +216,8 @@ export function createElement(node: FictNode): DOMElement {
214
216
  return createElementWithContext(node, null, resolveOwnerDocument())
215
217
  }
216
218
 
219
+ registerCreateElement(createElement)
220
+
217
221
  function resolveNamespace(tagName: string, namespace: NamespaceContext): NamespaceContext {
218
222
  if (tagName === 'svg') return 'svg'
219
223
  if (tagName === 'math') return 'mathml'
@@ -227,6 +231,13 @@ function resolveOwnerDocument(ownerDocument?: Document): Document {
227
231
  return ownerDocument ?? getCurrentRoot()?.ownerDocument ?? document
228
232
  }
229
233
 
234
+ function createTextNodeWithHydration(value: string, ownerDocument: Document): Text {
235
+ if (!isHydratingActive()) {
236
+ return ownerDocument.createTextNode(value)
237
+ }
238
+ return claimText(value, () => ownerDocument.createTextNode(value))
239
+ }
240
+
230
241
  function createElementWithContext(
231
242
  node: FictNode,
232
243
  namespace: NamespaceContext,
@@ -239,14 +250,14 @@ function createElementWithContext(
239
250
 
240
251
  // Null/undefined/false - empty placeholder
241
252
  if (node === null || node === undefined || node === false) {
242
- return ownerDocument.createTextNode('')
253
+ return createTextNodeWithHydration('', ownerDocument)
243
254
  }
244
255
 
245
256
  // Reactive getter function - resolve to actual node
246
257
  if (isReactive(node)) {
247
258
  const resolved = (node as () => FictNode)()
248
259
  if (resolved === node) {
249
- return ownerDocument.createTextNode('')
260
+ return createTextNodeWithHydration('', ownerDocument)
250
261
  }
251
262
  return createElementWithContext(resolved, namespace, ownerDocument)
252
263
  }
@@ -254,7 +265,7 @@ function createElementWithContext(
254
265
  // Non-reactive function values are not valid DOM nodes.
255
266
  // Keep callback values inert instead of stringifying function source.
256
267
  if (typeof node === 'function') {
257
- return ownerDocument.createTextNode('')
268
+ return createTextNodeWithHydration('', ownerDocument)
258
269
  }
259
270
 
260
271
  if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
@@ -290,11 +301,11 @@ function createElementWithContext(
290
301
 
291
302
  // Primitive values - text node
292
303
  if (typeof node === 'string' || typeof node === 'number') {
293
- return ownerDocument.createTextNode(String(node))
304
+ return createTextNodeWithHydration(String(node), ownerDocument)
294
305
  }
295
306
 
296
307
  if (typeof node === 'boolean') {
297
- return ownerDocument.createTextNode('')
308
+ return createTextNodeWithHydration('', ownerDocument)
298
309
  }
299
310
 
300
311
  // VNode
@@ -615,7 +626,7 @@ function appendChildNode(
615
626
  // Cast to Node for remaining logic
616
627
  let domNode: Node
617
628
  if (typeof child !== 'object' || child === null) {
618
- domNode = parentOwnerDocument.createTextNode(String(child ?? ''))
629
+ domNode = createTextNodeWithHydration(String(child ?? ''), parentOwnerDocument)
619
630
  } else {
620
631
  domNode = createElementWithContext(child as any, namespace, parentOwnerDocument) as Node
621
632
  }
@@ -675,43 +686,14 @@ function appendChildren(
675
686
  * Both types are automatically cleaned up on unmount.
676
687
  */
677
688
  function applyRef(el: Element, value: unknown): void {
678
- if (typeof value === 'function') {
679
- // Callback ref
680
- const refFn = value as (el: Element | null) => void
681
- refFn(el)
682
-
683
- // Match React behavior: call ref(null) on unmount
684
- const root = getCurrentRoot()
685
- if (root) {
686
- registerRootCleanup(() => {
687
- refFn(null)
688
- })
689
- } else if (isDev) {
690
- console.warn(
691
- '[fict] Ref applied outside of a root context. ' +
692
- 'The ref cleanup (setting to null) will not run automatically. ' +
693
- 'Consider using createRoot() or ensure the element is created within a component.',
694
- )
695
- }
696
- } else if (value && typeof value === 'object' && 'current' in value) {
697
- // Object ref
698
- const refObj = value as RefObject<Element>
699
- refObj.current = el
700
-
701
- // Auto-cleanup on unmount
702
- const root = getCurrentRoot()
703
- if (root) {
704
- registerRootCleanup(() => {
705
- refObj.current = null
706
- })
707
- } else if (isDev) {
708
- console.warn(
709
- '[fict] Ref applied outside of a root context. ' +
710
- 'The ref cleanup (setting to null) will not run automatically. ' +
711
- 'Consider using createRoot() or ensure the element is created within a component.',
712
- )
713
- }
689
+ if (!getCurrentRoot() && isDev) {
690
+ console.warn(
691
+ '[fict] Ref applied outside of a root context. ' +
692
+ 'The ref cleanup (setting to null) will not run automatically. ' +
693
+ 'Consider using createRoot() or ensure the element is created within a component.',
694
+ )
714
695
  }
696
+ bindRef(el, value)
715
697
  }
716
698
 
717
699
  // ============================================================================
package/src/effect.ts CHANGED
@@ -21,7 +21,7 @@ export function createEffect(fn: Effect, options?: EffectOptions): () => void {
21
21
 
22
22
  // Cleanup runner - called by runEffect BEFORE signal values are committed
23
23
  const doCleanup = () => {
24
- runCleanupList(cleanups)
24
+ runCleanupList(cleanups, rootForError)
25
25
  cleanups = []
26
26
  }
27
27
 
@@ -49,7 +49,7 @@ export function createEffect(fn: Effect, options?: EffectOptions): () => void {
49
49
 
50
50
  const disposeEffect = effectWithCleanup(run, doCleanup, rootForError, options)
51
51
  const teardown = () => {
52
- runCleanupList(cleanups)
52
+ runCleanupList(cleanups, rootForError)
53
53
  disposeEffect()
54
54
  }
55
55
 
@@ -61,15 +61,13 @@ export function createEffect(fn: Effect, options?: EffectOptions): () => void {
61
61
  export const $effect = createEffect
62
62
 
63
63
  export function createRenderEffect(fn: Effect, options?: EffectOptions): () => void {
64
- let cleanup: Cleanup | undefined
64
+ let cleanups: Cleanup[] = []
65
65
  const rootForError = getCurrentRoot()
66
66
 
67
67
  // Cleanup runner - called by runEffect BEFORE signal values are committed
68
68
  const doCleanup = () => {
69
- if (cleanup) {
70
- cleanup()
71
- cleanup = undefined
72
- }
69
+ runCleanupList(cleanups, rootForError)
70
+ cleanups = []
73
71
  }
74
72
 
75
73
  const run = () => {
@@ -77,7 +75,9 @@ export function createRenderEffect(fn: Effect, options?: EffectOptions): () => v
77
75
  try {
78
76
  const maybeCleanup = fn()
79
77
  if (typeof maybeCleanup === 'function') {
80
- cleanup = maybeCleanup
78
+ cleanups = [maybeCleanup]
79
+ } else {
80
+ cleanups = []
81
81
  }
82
82
  } catch (err) {
83
83
  if (handleSuspend(err as any, rootForError)) {
@@ -93,10 +93,7 @@ export function createRenderEffect(fn: Effect, options?: EffectOptions): () => v
93
93
 
94
94
  const disposeEffect = effectWithCleanup(run, doCleanup, rootForError, options)
95
95
  const teardown = () => {
96
- if (cleanup) {
97
- cleanup()
98
- cleanup = undefined
99
- }
96
+ runCleanupList(cleanups, rootForError)
100
97
  disposeEffect()
101
98
  }
102
99
 
@@ -8,6 +8,7 @@ import {
8
8
  pushRoot,
9
9
  popRoot,
10
10
  registerErrorHandler,
11
+ registerRootCleanup,
11
12
  } from './lifecycle'
12
13
  import { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'
13
14
  import type { BaseProps, FictNode } from './types'
@@ -105,6 +106,13 @@ export function ErrorBoundary(props: ErrorBoundaryProps): FictNode {
105
106
 
106
107
  renderValue(props.children ?? null)
107
108
 
109
+ registerRootCleanup(() => {
110
+ if (cleanup) {
111
+ cleanup()
112
+ cleanup = undefined
113
+ }
114
+ })
115
+
108
116
  registerErrorHandler(err => {
109
117
  renderValue(toView(err))
110
118
  props.onError?.(err)
package/src/hydration.ts CHANGED
@@ -6,7 +6,7 @@ interface HydrationContext {
6
6
 
7
7
  const hydrationStack: HydrationContext[] = []
8
8
 
9
- export function withHydration(root: ParentNode & Node, fn: () => void): void {
9
+ export function withHydration<T>(root: ParentNode & Node, fn: () => T): T {
10
10
  const owner = root.ownerDocument ?? document
11
11
  hydrationStack.push({
12
12
  cursor: root.firstChild,
@@ -14,25 +14,25 @@ export function withHydration(root: ParentNode & Node, fn: () => void): void {
14
14
  owner,
15
15
  })
16
16
  try {
17
- fn()
17
+ return fn()
18
18
  } finally {
19
19
  hydrationStack.pop()
20
20
  }
21
21
  }
22
22
 
23
- export function withHydrationRange(
23
+ export function withHydrationRange<T>(
24
24
  start: Node | null,
25
25
  end: Node | null,
26
26
  owner: Document,
27
- fn: () => void,
28
- ): void {
27
+ fn: () => T,
28
+ ): T {
29
29
  hydrationStack.push({
30
30
  cursor: start,
31
31
  boundary: end,
32
32
  owner,
33
33
  })
34
34
  try {
35
- fn()
35
+ return fn()
36
36
  } finally {
37
37
  hydrationStack.pop()
38
38
  }
@@ -70,6 +70,25 @@ export function claimNodes(templateRoot: Node, fallback: () => Node): Node {
70
70
  return frag
71
71
  }
72
72
 
73
+ export function claimText(value: string, fallback: () => Text): Text {
74
+ const ctx = hydrationStack[hydrationStack.length - 1]
75
+ if (
76
+ !ctx ||
77
+ !ctx.cursor ||
78
+ ctx.cursor === ctx.boundary ||
79
+ ctx.cursor.nodeType !== Node.TEXT_NODE
80
+ ) {
81
+ return fallback()
82
+ }
83
+
84
+ const text = ctx.cursor as Text
85
+ ctx.cursor = text.nextSibling
86
+ if (text.data !== value) {
87
+ text.data = value
88
+ }
89
+ return text
90
+ }
91
+
73
92
  export function isHydratingActive(): boolean {
74
93
  return hydrationStack.length > 0
75
94
  }
package/src/lifecycle.ts CHANGED
@@ -29,8 +29,6 @@ type SuspenseHandler = (token: SuspenseToken | PromiseLike<unknown>) => boolean
29
29
 
30
30
  let currentRoot: RootContext | undefined
31
31
  let currentEffectCleanups: Cleanup[] | undefined
32
- const globalErrorHandlers = new WeakMap<RootContext, ErrorHandler[]>()
33
- const globalSuspenseHandlers = new WeakMap<RootContext, SuspenseHandler[]>()
34
32
  const rootDevtoolsIds = new WeakMap<RootContext, number>()
35
33
  let nextRootDevtoolsId = 0
36
34
 
@@ -115,20 +113,28 @@ export function onCleanup(fn: Cleanup): void {
115
113
  export function flushOnMount(root: RootContext): void {
116
114
  const cbs = root.onMountCallbacks
117
115
  if (!cbs || cbs.length === 0) return
118
- // Temporarily restore root context so onCleanup calls inside
119
- // mount callbacks register correctly
116
+ try {
117
+ withRootContext(root, () => {
118
+ for (let i = 0; i < cbs.length; i++) {
119
+ const cleanup = cbs[i]!()
120
+ if (typeof cleanup === 'function') {
121
+ root.cleanups.push(cleanup)
122
+ }
123
+ }
124
+ })
125
+ } finally {
126
+ cbs.length = 0
127
+ }
128
+ }
129
+
130
+ export function withRootContext<T>(root: RootContext | undefined, fn: () => T): T {
131
+ if (!root) return fn()
120
132
  const prevRoot = currentRoot
121
133
  currentRoot = root
122
134
  try {
123
- for (let i = 0; i < cbs.length; i++) {
124
- const cleanup = cbs[i]!()
125
- if (typeof cleanup === 'function') {
126
- root.cleanups.push(cleanup)
127
- }
128
- }
135
+ return fn()
129
136
  } finally {
130
137
  currentRoot = prevRoot
131
- cbs.length = 0
132
138
  }
133
139
  }
134
140
 
@@ -139,7 +145,7 @@ export function registerRootCleanup(fn: Cleanup): void {
139
145
  }
140
146
 
141
147
  export function clearRoot(root: RootContext): void {
142
- runCleanupList(root.cleanups)
148
+ runCleanupList(root.cleanups, root)
143
149
  if (root.onMountCallbacks) {
144
150
  root.onMountCallbacks.length = 0
145
151
  }
@@ -147,19 +153,13 @@ export function clearRoot(root: RootContext): void {
147
153
 
148
154
  export function destroyRoot(root: RootContext): void {
149
155
  clearRoot(root)
150
- runCleanupList(root.destroyCallbacks)
156
+ runCleanupList(root.destroyCallbacks, root)
151
157
  if (root.errorHandlers) {
152
158
  root.errorHandlers.length = 0
153
159
  }
154
- if (globalErrorHandlers.has(root)) {
155
- globalErrorHandlers.delete(root)
156
- }
157
160
  if (root.suspenseHandlers) {
158
161
  root.suspenseHandlers.length = 0
159
162
  }
160
- if (globalSuspenseHandlers.has(root)) {
161
- globalSuspenseHandlers.delete(root)
162
- }
163
163
  disposeRootDevtools(root)
164
164
  }
165
165
 
@@ -201,21 +201,23 @@ export function registerEffectCleanup(fn: Cleanup): void {
201
201
  }
202
202
  }
203
203
 
204
- export function runCleanupList(list: Cleanup[]): void {
204
+ export function runCleanupList(list: Cleanup[], root?: RootContext): void {
205
205
  let error: unknown
206
- for (let i = list.length - 1; i >= 0; i--) {
207
- try {
208
- const cleanup = list[i]
209
- if (cleanup) cleanup()
210
- } catch (err) {
211
- if (error === undefined) {
212
- error = err
206
+ withRootContext(root, () => {
207
+ for (let i = list.length - 1; i >= 0; i--) {
208
+ try {
209
+ const cleanup = list[i]
210
+ if (cleanup) cleanup()
211
+ } catch (err) {
212
+ if (error === undefined) {
213
+ error = err
214
+ }
213
215
  }
214
216
  }
215
- }
217
+ })
216
218
  list.length = 0
217
219
  if (error !== undefined) {
218
- if (!handleError(error, { source: 'cleanup' })) {
220
+ if (!handleError(error, { source: 'cleanup' }, root)) {
219
221
  throw error
220
222
  }
221
223
  }
@@ -239,12 +241,6 @@ export function registerErrorHandler(fn: ErrorHandler): void {
239
241
  currentRoot.errorHandlers = []
240
242
  }
241
243
  currentRoot.errorHandlers.push(fn)
242
- const existing = globalErrorHandlers.get(currentRoot)
243
- if (existing) {
244
- existing.push(fn)
245
- } else {
246
- globalErrorHandlers.set(currentRoot, [fn])
247
- }
248
244
  }
249
245
 
250
246
  export function registerSuspenseHandler(fn: SuspenseHandler): void {
@@ -258,12 +254,6 @@ export function registerSuspenseHandler(fn: SuspenseHandler): void {
258
254
  currentRoot.suspenseHandlers = []
259
255
  }
260
256
  currentRoot.suspenseHandlers.push(fn)
261
- const existing = globalSuspenseHandlers.get(currentRoot)
262
- if (existing) {
263
- existing.push(fn)
264
- } else {
265
- globalSuspenseHandlers.set(currentRoot, [fn])
266
- }
267
257
  }
268
258
 
269
259
  export function handleError(err: unknown, info?: ErrorInfo, startRoot?: RootContext): boolean {
@@ -286,24 +276,6 @@ export function handleError(err: unknown, info?: ErrorInfo, startRoot?: RootCont
286
276
  }
287
277
  root = root.parent
288
278
  }
289
- const globalForRoot = startRoot
290
- ? globalErrorHandlers.get(startRoot)
291
- : currentRoot
292
- ? globalErrorHandlers.get(currentRoot)
293
- : undefined
294
- if (globalForRoot && globalForRoot.length) {
295
- for (let i = globalForRoot.length - 1; i >= 0; i--) {
296
- const handler = globalForRoot[i]!
297
- try {
298
- const handled = handler(error, info)
299
- if (handled !== false) {
300
- return true
301
- }
302
- } catch (nextErr) {
303
- error = nextErr
304
- }
305
- }
306
- }
307
279
  // The caller (e.g., runCleanupList) can decide whether to rethrow.
308
280
  // This makes the API consistent: handleError always returns a boolean
309
281
  // indicating whether the error was handled.
@@ -334,25 +306,5 @@ export function handleSuspend(
334
306
  }
335
307
  root = root.parent
336
308
  }
337
- const globalForRoot =
338
- startRoot && globalSuspenseHandlers.get(startRoot)
339
- ? globalSuspenseHandlers.get(startRoot)
340
- : currentRoot
341
- ? globalSuspenseHandlers.get(currentRoot)
342
- : undefined
343
- if (globalForRoot && globalForRoot.length) {
344
- for (let i = globalForRoot.length - 1; i >= 0; i--) {
345
- const handler = globalForRoot[i]!
346
- const handled = handler(token)
347
- if (handled !== false) {
348
- // Only set suspended = true when a handler actually handles the token
349
- if (originRoot) {
350
- originRoot.suspended = true
351
- setRootSuspendDevtools(originRoot, true)
352
- }
353
- return true
354
- }
355
- }
356
- }
357
309
  return false
358
310
  }
package/src/signal.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  handleError,
7
7
  handleSuspend,
8
8
  registerRootCleanup,
9
+ withRootContext,
9
10
  type RootContext,
10
11
  } from './lifecycle'
11
12
  import type { SuspenseToken } from './types'
@@ -890,7 +891,9 @@ function runEffect(e: EffectNode): void {
890
891
  inCleanup = true
891
892
  activeCleanupFlushId = currentFlushId
892
893
  try {
893
- e.runCleanup()
894
+ withRootContext(e.root, () => {
895
+ e.runCleanup!()
896
+ })
894
897
  } finally {
895
898
  activeCleanupFlushId = 0
896
899
  inCleanup = false
package/src/suspense.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  handleError,
9
9
  pushRoot,
10
10
  popRoot,
11
+ registerRootCleanup,
11
12
  registerSuspenseHandler,
12
13
  } from './lifecycle'
13
14
  import { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'
@@ -234,6 +235,13 @@ export function Suspense(props: SuspenseProps): FictNode {
234
235
  // If children suspend, the handler above will be called and switch to fallback.
235
236
  renderView(props.children ?? null)
236
237
 
238
+ registerRootCleanup(() => {
239
+ if (cleanup) {
240
+ cleanup()
241
+ cleanup = undefined
242
+ }
243
+ })
244
+
237
245
  if (props.resetKeys !== undefined) {
238
246
  const isGetter =
239
247
  typeof props.resetKeys === 'function' && (props.resetKeys as () => unknown).length === 0
@@ -1 +0,0 @@
1
- {"version":3,"sources":["/home/runner/work/fict/fict/packages/runtime/dist/chunk-6DNYVH5U.cjs","../src/context.ts"],"names":[],"mappings":"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","file":"/home/runner/work/fict/fict/packages/runtime/dist/chunk-6DNYVH5U.cjs","sourcesContent":[null,"/**\n * @fileoverview Context API for Fict\n *\n * Provides a way to pass data through the component tree without having to pass\n * props down manually at every level. Context is designed for:\n *\n * - SSR isolation (different request = different context values)\n * - Multi-instance support (multiple app roots with different values)\n * - Subtree scoping (override values in specific parts of the tree)\n *\n * ## Design Principles\n *\n * 1. **Reuses existing RootContext hierarchy** - Uses parent chain for value lookup,\n * consistent with handleError/handleSuspend mechanisms.\n *\n * 2. **Zero extra root creation overhead** - Provider doesn't create new root,\n * only mounts value on current root.\n *\n * 3. **Auto-aligned with insert/suspense boundaries** - Because they create child\n * roots that inherit parent, context values propagate correctly.\n *\n * ## Usage\n *\n * ```tsx\n * // Create context with default value\n * const ThemeContext = createContext<'light' | 'dark'>('light')\n *\n * // Provide value to subtree\n * function App() {\n * return (\n * <ThemeContext.Provider value=\"dark\">\n * <ThemedComponent />\n * </ThemeContext.Provider>\n * )\n * }\n *\n * // Consume value\n * function ThemedComponent() {\n * const theme = useContext(ThemeContext)\n * return <div class={theme}>...</div>\n * }\n * ```\n *\n * @module\n */\n\nimport { createElement } from './dom'\nimport { createRenderEffect } from './effect'\nimport {\n createRootContext,\n destroyRoot,\n flushOnMount,\n getCurrentRoot,\n popRoot,\n pushRoot,\n type RootContext,\n} from './lifecycle'\nimport { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'\nimport type { BaseProps, FictNode } from './types'\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Context object created by createContext.\n * Contains the Provider component and serves as a key for context lookup.\n */\nexport interface Context<T> {\n /** Unique identifier for this context */\n readonly id: symbol\n /** Default value when no provider is found */\n readonly defaultValue: T\n /** Provider component for supplying context values */\n Provider: ContextProvider<T>\n /** Display name for debugging */\n displayName?: string\n}\n\n/**\n * Props for the Context Provider component\n */\nexport interface ProviderProps<T> extends BaseProps {\n /** The value to provide to the subtree */\n value: T\n}\n\n/**\n * Provider component type\n */\nexport type ContextProvider<T> = (props: ProviderProps<T>) => FictNode\n\n// ============================================================================\n// Internal Context Storage\n// ============================================================================\n\n/**\n * WeakMap to store context values per RootContext.\n * Using WeakMap ensures proper garbage collection when roots are destroyed.\n */\nconst contextStorage = new WeakMap<RootContext, Map<symbol, unknown>>()\n\n/**\n * Get the context map for a root, creating it if needed\n */\nfunction getContextMap(root: RootContext): Map<symbol, unknown> {\n let map = contextStorage.get(root)\n if (!map) {\n map = new Map()\n contextStorage.set(root, map)\n }\n return map\n}\n\n// ============================================================================\n// Context API\n// ============================================================================\n\n/**\n * Creates a new context with the given default value.\n *\n * Context provides a way to pass values through the component tree without\n * explicit props drilling. It's especially useful for:\n *\n * - Theme data\n * - Locale/i18n settings\n * - Authentication state\n * - Feature flags\n * - Any data that many components at different nesting levels need\n *\n * @param defaultValue - The value to use when no Provider is found above in the tree\n * @returns A context object with a Provider component\n *\n * @example\n * ```tsx\n * // Create a theme context\n * const ThemeContext = createContext<'light' | 'dark'>('light')\n *\n * // Use the provider\n * function App() {\n * return (\n * <ThemeContext.Provider value=\"dark\">\n * <Content />\n * </ThemeContext.Provider>\n * )\n * }\n *\n * // Consume the context\n * function Content() {\n * const theme = useContext(ThemeContext)\n * return <div class={`theme-${theme}`}>Hello</div>\n * }\n * ```\n */\nexport function createContext<T>(defaultValue: T): Context<T> {\n const id = Symbol('fict.context')\n\n const context: Context<T> = {\n id,\n defaultValue,\n Provider: null as unknown as ContextProvider<T>,\n }\n\n // Create the Provider component\n context.Provider = function Provider(props: ProviderProps<T>): FictNode {\n const hostRoot = getCurrentRoot()\n\n // Create a child root for the provider's subtree\n // This establishes the provider boundary - children will look up from here\n const providerRoot = createRootContext(hostRoot)\n\n // Store the context value on this root\n const contextMap = getContextMap(providerRoot)\n contextMap.set(id, props.value)\n\n // Create DOM structure\n const markerOwnerDocument = providerRoot.ownerDocument ?? hostRoot?.ownerDocument ?? document\n const fragment = markerOwnerDocument.createDocumentFragment()\n const marker = markerOwnerDocument.createComment('fict:ctx')\n fragment.appendChild(marker)\n\n let cleanup: (() => void) | undefined\n let activeNodes: Node[] = []\n\n const renderChildren = (children: FictNode) => {\n // Cleanup previous render\n if (cleanup) {\n cleanup()\n cleanup = undefined\n }\n if (activeNodes.length) {\n removeNodes(activeNodes)\n activeNodes = []\n }\n\n if (children == null || children === false) {\n return\n }\n\n const prev = pushRoot(providerRoot)\n let nodes: Node[] = []\n try {\n const output = createElement(children)\n nodes = toNodeArray(output, markerOwnerDocument)\n const parentNode = marker.parentNode as (ParentNode & Node) | null\n if (parentNode) {\n insertNodesBefore(parentNode, nodes, marker)\n }\n } finally {\n popRoot(prev)\n flushOnMount(providerRoot)\n }\n\n cleanup = () => {\n destroyRoot(providerRoot)\n removeNodes(nodes)\n }\n activeNodes = nodes\n }\n\n // Initial render\n createRenderEffect(() => {\n // Update context value on re-render (if value prop changes reactively)\n contextMap.set(id, props.value)\n renderChildren(props.children)\n })\n\n return fragment\n }\n\n return context\n}\n\n/**\n * Reads the current value of a context.\n *\n * useContext looks up through the RootContext parent chain to find the\n * nearest Provider for this context. If no Provider is found, returns\n * the context's default value.\n *\n * @param context - The context object created by createContext\n * @returns The current context value\n *\n * @example\n * ```tsx\n * const ThemeContext = createContext('light')\n *\n * function ThemedButton() {\n * const theme = useContext(ThemeContext)\n * return <button class={theme === 'dark' ? 'btn-dark' : 'btn-light'}>Click</button>\n * }\n * ```\n */\nexport function useContext<T>(context: Context<T>): T {\n let root = getCurrentRoot()\n\n // Walk up the parent chain looking for the context value\n while (root) {\n const contextMap = contextStorage.get(root)\n if (contextMap && contextMap.has(context.id)) {\n return contextMap.get(context.id) as T\n }\n root = root.parent\n }\n\n // No provider found, return default value\n return context.defaultValue\n}\n\n/**\n * Checks if a context value is currently provided in the tree.\n *\n * Useful for conditional behavior when a provider may or may not exist.\n *\n * @param context - The context object to check\n * @returns true if a Provider exists above in the tree\n *\n * @example\n * ```tsx\n * function OptionalTheme() {\n * if (hasContext(ThemeContext)) {\n * const theme = useContext(ThemeContext)\n * return <div class={theme}>Themed content</div>\n * }\n * return <div>Default content</div>\n * }\n * ```\n */\nexport function hasContext<T>(context: Context<T>): boolean {\n let root = getCurrentRoot()\n\n while (root) {\n const contextMap = contextStorage.get(root)\n if (contextMap && contextMap.has(context.id)) {\n return true\n }\n root = root.parent\n }\n\n return false\n}\n"]}