@fictjs/runtime 0.9.0 → 0.11.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 (87) hide show
  1. package/dist/advanced.cjs +9 -9
  2. package/dist/advanced.d.cts +4 -4
  3. package/dist/advanced.d.ts +4 -4
  4. package/dist/advanced.js +4 -4
  5. package/dist/{binding-BWchH3Kp.d.cts → binding-DcnhUSQK.d.ts} +5 -3
  6. package/dist/{binding-BWchH3Kp.d.ts → binding-FRyTeLDn.d.cts} +5 -3
  7. package/dist/{chunk-FVX77557.js → chunk-2UR2UWE2.js} +3 -3
  8. package/dist/{chunk-LBE6DC3V.cjs → chunk-44EQF3AR.cjs} +63 -52
  9. package/dist/chunk-44EQF3AR.cjs.map +1 -0
  10. package/dist/{chunk-OAM7HABA.cjs → chunk-4QGEN5SJ.cjs} +340 -263
  11. package/dist/chunk-4QGEN5SJ.cjs.map +1 -0
  12. package/dist/{chunk-PD6IQY2Y.cjs → chunk-C5IE4WUG.cjs} +8 -8
  13. package/dist/{chunk-PD6IQY2Y.cjs.map → chunk-C5IE4WUG.cjs.map} +1 -1
  14. package/dist/{chunk-DXG3TARY.js → chunk-DIK33H5U.js} +202 -30
  15. package/dist/chunk-DIK33H5U.js.map +1 -0
  16. package/dist/{chunk-JVYH76ZX.js → chunk-FESAXMHT.js} +7 -6
  17. package/dist/{chunk-JVYH76ZX.js.map → chunk-FESAXMHT.js.map} +1 -1
  18. package/dist/chunk-FHQZCAAK.cjs +112 -0
  19. package/dist/chunk-FHQZCAAK.cjs.map +1 -0
  20. package/dist/{chunk-UBFDB6OL.cjs → chunk-QNMYVXRL.cjs} +222 -50
  21. package/dist/chunk-QNMYVXRL.cjs.map +1 -0
  22. package/dist/{chunk-N6ODUM2Y.js → chunk-S63VBIWN.js} +27 -16
  23. package/dist/chunk-S63VBIWN.js.map +1 -0
  24. package/dist/{chunk-T2LNV5Q5.js → chunk-WIHNVN6L.js} +153 -76
  25. package/dist/chunk-WIHNVN6L.js.map +1 -0
  26. package/dist/{devtools-BDp76luf.d.ts → devtools-BtIkN77t.d.cts} +14 -2
  27. package/dist/{devtools-5AipK9CX.d.cts → devtools-D2z4llpA.d.ts} +14 -2
  28. package/dist/index.cjs +60 -58
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +5 -5
  31. package/dist/index.d.ts +5 -5
  32. package/dist/index.dev.js +300 -74
  33. package/dist/index.dev.js.map +1 -1
  34. package/dist/index.js +13 -11
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal-list.cjs +4 -4
  37. package/dist/internal-list.d.cts +2 -2
  38. package/dist/internal-list.d.ts +2 -2
  39. package/dist/internal-list.js +3 -3
  40. package/dist/internal.cjs +5 -5
  41. package/dist/internal.d.cts +6 -6
  42. package/dist/internal.d.ts +6 -6
  43. package/dist/internal.js +4 -4
  44. package/dist/jsx-dev-runtime.d.cts +671 -0
  45. package/dist/jsx-dev-runtime.d.ts +671 -0
  46. package/dist/jsx-runtime.d.cts +671 -0
  47. package/dist/jsx-runtime.d.ts +671 -0
  48. package/dist/{list-DL5DOFcO.d.ts → list-BKM6YOPq.d.ts} +2 -2
  49. package/dist/{list-hP7hQ9Vk.d.cts → list-Bi8dDF8Q.d.cts} +2 -2
  50. package/dist/loader.cjs +34 -28
  51. package/dist/loader.cjs.map +1 -1
  52. package/dist/loader.d.cts +2 -2
  53. package/dist/loader.d.ts +2 -2
  54. package/dist/loader.js +17 -11
  55. package/dist/loader.js.map +1 -1
  56. package/dist/{props-BpZz0AOq.d.cts → props-9chMyBGb.d.cts} +2 -2
  57. package/dist/{props-CjLH0JE-.d.ts → props-D1nj2p_3.d.ts} +2 -2
  58. package/dist/{resume-BJ4oHLi_.d.cts → resume-C5IKAIdh.d.ts} +2 -2
  59. package/dist/{resume-CuyJWXP_.d.ts → resume-DPZxmA95.d.cts} +2 -2
  60. package/dist/{scope-jPt5DHRT.d.ts → scope-BSkhJr0a.d.ts} +1 -1
  61. package/dist/{scope-BJCtq8hJ.d.cts → scope-Bn3sxem5.d.cts} +1 -1
  62. package/dist/{signal-C4ISF17w.d.cts → signal-Z4KkDk9h.d.cts} +12 -1
  63. package/dist/{signal-C4ISF17w.d.ts → signal-Z4KkDk9h.d.ts} +12 -1
  64. package/package.json +2 -2
  65. package/src/binding.ts +59 -29
  66. package/src/context.ts +4 -3
  67. package/src/devtools.ts +19 -2
  68. package/src/dom.ts +122 -42
  69. package/src/effect.ts +5 -5
  70. package/src/error-boundary.ts +5 -5
  71. package/src/hooks.ts +13 -5
  72. package/src/lifecycle.ts +48 -3
  73. package/src/list-helpers.ts +30 -13
  74. package/src/loader.ts +20 -12
  75. package/src/node-ops.ts +8 -5
  76. package/src/signal.ts +191 -18
  77. package/src/suspense.ts +5 -4
  78. package/src/transition.ts +9 -3
  79. package/dist/chunk-DXG3TARY.js.map +0 -1
  80. package/dist/chunk-LBE6DC3V.cjs.map +0 -1
  81. package/dist/chunk-N6ODUM2Y.js.map +0 -1
  82. package/dist/chunk-OAM7HABA.cjs.map +0 -1
  83. package/dist/chunk-PG4QX2I2.cjs +0 -111
  84. package/dist/chunk-PG4QX2I2.cjs.map +0 -1
  85. package/dist/chunk-T2LNV5Q5.js.map +0 -1
  86. package/dist/chunk-UBFDB6OL.cjs.map +0 -1
  87. /package/dist/{chunk-FVX77557.js.map → chunk-2UR2UWE2.js.map} +0 -0
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
  // ============================================================================
@@ -83,6 +123,7 @@ let nextComponentId = 1
83
123
  */
84
124
  export function render(view: () => FictNode, container: HTMLElement): () => void {
85
125
  const root = createRootContext()
126
+ root.ownerDocument = container.ownerDocument ?? document
86
127
  const prev = pushRoot(root)
87
128
  let dom: DOMElement = undefined as unknown as DOMElement
88
129
  try {
@@ -126,6 +167,7 @@ export function render(view: () => FictNode, container: HTMLElement): () => void
126
167
  */
127
168
  export function hydrateComponent(view: () => FictNode, container: HTMLElement): () => void {
128
169
  const root = createRootContext()
170
+ root.ownerDocument = container.ownerDocument ?? document
129
171
  const prev = pushRoot(root)
130
172
 
131
173
  // Enable hydration flags for bindings that check __fictIsHydrating()
@@ -169,7 +211,7 @@ export function hydrateComponent(view: () => FictNode, container: HTMLElement):
169
211
  * - Reactive values (functions returning any of the above)
170
212
  */
171
213
  export function createElement(node: FictNode): DOMElement {
172
- return createElementWithContext(node, null)
214
+ return createElementWithContext(node, null, resolveOwnerDocument())
173
215
  }
174
216
 
175
217
  function resolveNamespace(tagName: string, namespace: NamespaceContext): NamespaceContext {
@@ -181,7 +223,15 @@ function resolveNamespace(tagName: string, namespace: NamespaceContext): Namespa
181
223
  return null
182
224
  }
183
225
 
184
- function createElementWithContext(node: FictNode, namespace: NamespaceContext): DOMElement {
226
+ function resolveOwnerDocument(ownerDocument?: Document): Document {
227
+ return ownerDocument ?? getCurrentRoot()?.ownerDocument ?? document
228
+ }
229
+
230
+ function createElementWithContext(
231
+ node: FictNode,
232
+ namespace: NamespaceContext,
233
+ ownerDocument: Document,
234
+ ): DOMElement {
185
235
  // Already a DOM node - pass through
186
236
  if (node instanceof Node) {
187
237
  return node
@@ -189,22 +239,22 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
189
239
 
190
240
  // Null/undefined/false - empty placeholder
191
241
  if (node === null || node === undefined || node === false) {
192
- return document.createTextNode('')
242
+ return ownerDocument.createTextNode('')
193
243
  }
194
244
 
195
245
  // Reactive getter function - resolve to actual node
196
246
  if (isReactive(node)) {
197
247
  const resolved = (node as () => FictNode)()
198
248
  if (resolved === node) {
199
- return document.createTextNode('')
249
+ return ownerDocument.createTextNode('')
200
250
  }
201
- return createElementWithContext(resolved, namespace)
251
+ return createElementWithContext(resolved, namespace, ownerDocument)
202
252
  }
203
253
 
204
254
  // Non-reactive function values are not valid DOM nodes.
205
255
  // Keep callback values inert instead of stringifying function source.
206
256
  if (typeof node === 'function') {
207
- return document.createTextNode('')
257
+ return ownerDocument.createTextNode('')
208
258
  }
209
259
 
210
260
  if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
@@ -225,26 +275,26 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
225
275
  .catch(() => undefined)
226
276
  }
227
277
  }
228
- return createElement(handle.marker as FictNode)
278
+ return createElementWithContext(handle.marker as FictNode, namespace, ownerDocument)
229
279
  }
230
280
  }
231
281
 
232
282
  // Array - create fragment
233
283
  if (Array.isArray(node)) {
234
- const frag = document.createDocumentFragment()
284
+ const frag = ownerDocument.createDocumentFragment()
235
285
  for (const child of node) {
236
- appendChildNode(frag, child, namespace)
286
+ appendChildNode(frag, child, namespace, ownerDocument)
237
287
  }
238
288
  return frag
239
289
  }
240
290
 
241
291
  // Primitive values - text node
242
292
  if (typeof node === 'string' || typeof node === 'number') {
243
- return document.createTextNode(String(node))
293
+ return ownerDocument.createTextNode(String(node))
244
294
  }
245
295
 
246
296
  if (typeof node === 'boolean') {
247
- return document.createTextNode('')
297
+ return ownerDocument.createTextNode('')
248
298
  }
249
299
 
250
300
  // VNode
@@ -282,12 +332,13 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
282
332
  // Create a fresh hook context for this component instance.
283
333
  // This preserves slot state across re-renders driven by __fictRender.
284
334
  const hook = isDev ? getDevtoolsHook() : undefined
335
+ const componentName = vnode.type.name || 'Anonymous'
285
336
  const parentId = hook ? __fictGetCurrentComponentId() : undefined
286
337
  const componentId = hook ? nextComponentId++ : undefined
287
338
 
288
339
  // Register component
289
340
  if (hook?.registerComponent && componentId !== undefined) {
290
- hook.registerComponent(componentId, vnode.type.name || 'Anonymous', parentId)
341
+ hook.registerComponent(componentId, componentName, parentId)
291
342
  }
292
343
 
293
344
  const ctx = __fictPushContext()
@@ -300,22 +351,27 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
300
351
 
301
352
  try {
302
353
  const rendered = vnode.type(props)
354
+ let mountElements: HTMLElement[] | undefined
355
+
356
+ if (hook && componentId !== undefined) {
357
+ hook.componentRender?.(componentId)
358
+ }
303
359
 
304
360
  // Register lifecycle hooks
305
361
  if (hook && componentId !== undefined) {
306
362
  onMount(() => {
307
- hook.componentMount?.(componentId)
363
+ hook.componentMount?.(componentId, mountElements)
308
364
  })
309
365
  onCleanup(() => hook.componentUnmount?.(componentId))
310
366
  }
311
367
  if (__fictIsResumable() && !__fictIsHydrating()) {
312
- const content = createElementWithContext(rendered as FictNode, namespace)
368
+ const content = createElementWithContext(rendered as FictNode, namespace, ownerDocument)
313
369
  const host =
314
370
  namespace === 'svg'
315
- ? document.createElementNS(SVG_NS, 'fict-host')
371
+ ? ownerDocument.createElementNS(SVG_NS, 'fict-host')
316
372
  : namespace === 'mathml'
317
- ? document.createElementNS(MATHML_NS, 'fict-host')
318
- : document.createElement('fict-host')
373
+ ? ownerDocument.createElementNS(MATHML_NS, 'fict-host')
374
+ : ownerDocument.createElement('fict-host')
319
375
  host.setAttribute('data-fict-host', '')
320
376
  if (namespace === null && (host as HTMLElement).style) {
321
377
  ;(host as HTMLElement).style.display = 'contents'
@@ -332,13 +388,21 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
332
388
  } else {
333
389
  host.appendChild(content)
334
390
  }
391
+ if (hook && componentId !== undefined) {
392
+ mountElements = collectComponentMountElements(host)
393
+ annotateComponentElements(mountElements, componentId, componentName)
394
+ }
335
395
  return host as DOMElement
336
396
  }
337
-
338
- return createElementWithContext(rendered as FictNode, namespace)
397
+ const componentRoot = createElementWithContext(rendered as FictNode, namespace, ownerDocument)
398
+ if (hook && componentId !== undefined) {
399
+ mountElements = collectComponentMountElements(componentRoot)
400
+ annotateComponentElements(mountElements, componentId, componentName)
401
+ }
402
+ return componentRoot
339
403
  } catch (err) {
340
404
  if (handleSuspend(err as any)) {
341
- return document.createComment('fict:suspend')
405
+ return ownerDocument.createComment('fict:suspend')
342
406
  }
343
407
  handleError(err, { source: 'render', componentName: vnode.type.name })
344
408
  throw err
@@ -349,9 +413,9 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
349
413
 
350
414
  // Fragment
351
415
  if (vnode.type === Fragment) {
352
- const frag = document.createDocumentFragment()
416
+ const frag = ownerDocument.createDocumentFragment()
353
417
  const children = vnode.props?.children as FictNode | FictNode[] | undefined
354
- appendChildren(frag, children, namespace)
418
+ appendChildren(frag, children, namespace, ownerDocument)
355
419
  return frag
356
420
  }
357
421
 
@@ -360,15 +424,16 @@ function createElementWithContext(node: FictNode, namespace: NamespaceContext):
360
424
  const resolvedNamespace = resolveNamespace(tagName, namespace)
361
425
  const el =
362
426
  resolvedNamespace === 'svg'
363
- ? document.createElementNS(SVG_NS, tagName)
427
+ ? ownerDocument.createElementNS(SVG_NS, tagName)
364
428
  : resolvedNamespace === 'mathml'
365
- ? document.createElementNS(MATHML_NS, tagName)
366
- : document.createElement(tagName)
429
+ ? ownerDocument.createElementNS(MATHML_NS, tagName)
430
+ : ownerDocument.createElement(tagName)
367
431
  applyProps(el, vnode.props ?? {}, resolvedNamespace === 'svg')
368
432
  appendChildren(
369
433
  el as unknown as ParentNode & Node,
370
434
  vnode.props?.children as FictNode | FictNode[] | undefined,
371
435
  tagName === 'foreignObject' ? null : resolvedNamespace,
436
+ ownerDocument,
372
437
  )
373
438
  return el as DOMElement
374
439
  }
@@ -388,10 +453,10 @@ export function template(
388
453
  isSVG?: boolean,
389
454
  isMathML?: boolean,
390
455
  ): () => Node {
391
- let node: Node | null = null
456
+ const nodeByDocument = new WeakMap<Document, Node>()
392
457
 
393
- const create = (): Node => {
394
- const t = document.createElement('template')
458
+ const create = (ownerDocument: Document): Node => {
459
+ const t = ownerDocument.createElement('template')
395
460
 
396
461
  if (isSVG) {
397
462
  // fix: Wrap HTML in <svg> to parse content in SVG namespace
@@ -409,7 +474,7 @@ export function template(
409
474
  return wrapper.firstChild!
410
475
  }
411
476
  // Preserve all root nodes by returning a fragment
412
- const fragment = document.createDocumentFragment()
477
+ const fragment = ownerDocument.createDocumentFragment()
413
478
  fragment.append(...Array.from(wrapper.childNodes))
414
479
  return fragment
415
480
  }
@@ -429,7 +494,7 @@ export function template(
429
494
  return wrapper.firstChild!
430
495
  }
431
496
  // Preserve all root nodes by returning a fragment
432
- const fragment = document.createDocumentFragment()
497
+ const fragment = ownerDocument.createDocumentFragment()
433
498
  fragment.append(...Array.from(wrapper.childNodes))
434
499
  return fragment
435
500
  }
@@ -450,17 +515,26 @@ export function template(
450
515
  return content
451
516
  }
452
517
 
518
+ const getBase = (ownerDocument: Document): Node => {
519
+ const cached = nodeByDocument.get(ownerDocument)
520
+ if (cached) return cached
521
+ const created = create(ownerDocument)
522
+ nodeByDocument.set(ownerDocument, created)
523
+ return created
524
+ }
525
+
453
526
  // Create the cloning function
454
527
  const fn = isImportNode
455
528
  ? () =>
456
529
  untrack(() => {
457
- const base = node || (node = create())
530
+ const ownerDocument = resolveOwnerDocument()
531
+ const base = getBase(ownerDocument)
458
532
  return isHydratingActive()
459
- ? claimNodes(base, () => document.importNode(base, true))
460
- : document.importNode(base, true)
533
+ ? claimNodes(base, () => ownerDocument.importNode(base, true))
534
+ : ownerDocument.importNode(base, true)
461
535
  })
462
536
  : () => {
463
- const base = node || (node = create())
537
+ const base = getBase(resolveOwnerDocument())
464
538
  return isHydratingActive()
465
539
  ? claimNodes(base, () => base.cloneNode(true))
466
540
  : base.cloneNode(true)
@@ -499,7 +573,10 @@ function appendChildNode(
499
573
  parent: ParentNode & Node,
500
574
  child: FictNode,
501
575
  namespace: NamespaceContext,
576
+ ownerDocument: Document,
502
577
  ): void {
578
+ const parentOwnerDocument = parent.ownerDocument ?? ownerDocument
579
+
503
580
  // Skip nullish values
504
581
  if (child === null || child === undefined || child === false) {
505
582
  return
@@ -507,7 +584,7 @@ function appendChildNode(
507
584
 
508
585
  // Handle BindingHandle (recursive)
509
586
  if (isBindingHandle(child)) {
510
- appendChildNode(parent, child.marker, namespace)
587
+ appendChildNode(parent, child.marker, namespace, parentOwnerDocument)
511
588
  // Flush pending nodes now that markers are in the DOM
512
589
  child.flush?.()
513
590
  return
@@ -519,7 +596,9 @@ function appendChildNode(
519
596
  if (typeof child === 'function') {
520
597
  const childGetter = child as () => FictNode
521
598
  if (isReactive(childGetter)) {
522
- createChildBinding(parent, childGetter, node => createElementWithContext(node, namespace))
599
+ createChildBinding(parent, childGetter, node =>
600
+ createElementWithContext(node, namespace, parentOwnerDocument),
601
+ )
523
602
  return
524
603
  }
525
604
  return
@@ -528,7 +607,7 @@ function appendChildNode(
528
607
  // Static child - create element and append
529
608
  if (Array.isArray(child)) {
530
609
  for (const item of child) {
531
- appendChildNode(parent, item, namespace)
610
+ appendChildNode(parent, item, namespace, parentOwnerDocument)
532
611
  }
533
612
  return
534
613
  }
@@ -536,16 +615,16 @@ function appendChildNode(
536
615
  // Cast to Node for remaining logic
537
616
  let domNode: Node
538
617
  if (typeof child !== 'object' || child === null) {
539
- domNode = document.createTextNode(String(child ?? ''))
618
+ domNode = parentOwnerDocument.createTextNode(String(child ?? ''))
540
619
  } else {
541
- domNode = createElementWithContext(child as any, namespace) as Node
620
+ domNode = createElementWithContext(child as any, namespace, parentOwnerDocument) as Node
542
621
  }
543
622
 
544
623
  // Handle DocumentFragment manually to avoid JSDOM issues
545
624
  if (domNode.nodeType === 11) {
546
625
  const children = Array.from(domNode.childNodes)
547
626
  for (const node of children) {
548
- appendChildNode(parent, node as FictNode, namespace)
627
+ appendChildNode(parent, node as FictNode, namespace, parentOwnerDocument)
549
628
  }
550
629
  return
551
630
  }
@@ -573,17 +652,18 @@ function appendChildren(
573
652
  parent: ParentNode & Node,
574
653
  children: FictNode | FictNode[] | undefined,
575
654
  namespace: NamespaceContext,
655
+ ownerDocument: Document,
576
656
  ): void {
577
657
  if (children === undefined) return
578
658
 
579
659
  if (Array.isArray(children)) {
580
660
  for (const child of children) {
581
- appendChildren(parent, child, namespace)
661
+ appendChildren(parent, child, namespace, ownerDocument)
582
662
  }
583
663
  return
584
664
  }
585
665
 
586
- appendChildNode(parent, children, namespace)
666
+ appendChildNode(parent, children, namespace, ownerDocument)
587
667
  }
588
668
 
589
669
  // ============================================================================
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()
@@ -19,11 +19,11 @@ interface ErrorBoundaryProps extends BaseProps {
19
19
  }
20
20
 
21
21
  export function ErrorBoundary(props: ErrorBoundaryProps): FictNode {
22
- const fragment = document.createDocumentFragment()
23
- const marker = document.createComment('fict:error-boundary')
24
- fragment.appendChild(marker)
25
-
26
22
  const hostRoot = getCurrentRoot()
23
+ const markerOwnerDocument = hostRoot?.ownerDocument ?? document
24
+ const fragment = markerOwnerDocument.createDocumentFragment()
25
+ const marker = markerOwnerDocument.createComment('fict:error-boundary')
26
+ fragment.appendChild(marker)
27
27
 
28
28
  let cleanup: (() => void) | undefined
29
29
  let activeNodes: Node[] = []
@@ -58,7 +58,7 @@ export function ErrorBoundary(props: ErrorBoundaryProps): FictNode {
58
58
  let nodes: Node[] = []
59
59
  try {
60
60
  const output = createElement(value)
61
- nodes = toNodeArray(output)
61
+ nodes = toNodeArray(output, markerOwnerDocument)
62
62
  const parentNode = marker.parentNode as (ParentNode & Node) | null
63
63
  if (parentNode) {
64
64
  insertNodesBefore(parentNode, nodes, marker)
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
 
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 =
@@ -10,6 +11,7 @@ type LifecycleFn = () => void | Cleanup
10
11
 
11
12
  export interface RootContext {
12
13
  parent?: RootContext | undefined
14
+ ownerDocument?: Document | undefined
13
15
  onMountCallbacks?: LifecycleFn[]
14
16
  cleanups: Cleanup[]
15
17
  destroyCallbacks: Cleanup[]
@@ -29,9 +31,45 @@ let currentRoot: RootContext | undefined
29
31
  let currentEffectCleanups: Cleanup[] | undefined
30
32
  const globalErrorHandlers = new WeakMap<RootContext, ErrorHandler[]>()
31
33
  const globalSuspenseHandlers = new WeakMap<RootContext, SuspenseHandler[]>()
34
+ const rootDevtoolsIds = new WeakMap<RootContext, number>()
35
+ let nextRootDevtoolsId = 0
36
+
37
+ function registerRootDevtools(root: RootContext): void {
38
+ if (!isDev) return
39
+ const hook = getDevtoolsHook()
40
+ if (!hook?.registerRoot) return
41
+ const id = ++nextRootDevtoolsId
42
+ rootDevtoolsIds.set(root, id)
43
+ hook.registerRoot(id)
44
+ }
45
+
46
+ function disposeRootDevtools(root: RootContext): void {
47
+ if (!isDev) return
48
+ const id = rootDevtoolsIds.get(root)
49
+ if (id === undefined) return
50
+ const hook = getDevtoolsHook()
51
+ hook?.disposeRoot?.(id)
52
+ rootDevtoolsIds.delete(root)
53
+ }
54
+
55
+ function setRootSuspendDevtools(root: RootContext, suspended: boolean): void {
56
+ if (!isDev) return
57
+ const id = rootDevtoolsIds.get(root)
58
+ if (id === undefined) return
59
+ const hook = getDevtoolsHook()
60
+ hook?.rootSuspend?.(id, suspended)
61
+ }
32
62
 
33
63
  export function createRootContext(parent?: RootContext): RootContext {
34
- return { parent, cleanups: [], destroyCallbacks: [], suspended: false }
64
+ const root = {
65
+ parent,
66
+ ownerDocument: parent?.ownerDocument,
67
+ cleanups: [],
68
+ destroyCallbacks: [],
69
+ suspended: false,
70
+ }
71
+ registerRootDevtools(root)
72
+ return root
35
73
  }
36
74
 
37
75
  export function pushRoot(root: RootContext): RootContext | undefined {
@@ -122,6 +160,7 @@ export function destroyRoot(root: RootContext): void {
122
160
  if (globalSuspenseHandlers.has(root)) {
123
161
  globalSuspenseHandlers.delete(root)
124
162
  }
163
+ disposeRootDevtools(root)
125
164
  }
126
165
 
127
166
  export function createRoot<T>(
@@ -285,7 +324,10 @@ export function handleSuspend(
285
324
  const handled = handler(token)
286
325
  if (handled !== false) {
287
326
  // Only set suspended = true when a handler actually handles the token
288
- if (originRoot) originRoot.suspended = true
327
+ if (originRoot) {
328
+ originRoot.suspended = true
329
+ setRootSuspendDevtools(originRoot, true)
330
+ }
289
331
  return true
290
332
  }
291
333
  }
@@ -304,7 +346,10 @@ export function handleSuspend(
304
346
  const handled = handler(token)
305
347
  if (handled !== false) {
306
348
  // Only set suspended = true when a handler actually handles the token
307
- if (originRoot) originRoot.suspended = true
349
+ if (originRoot) {
350
+ originRoot.suspended = true
351
+ setRootSuspendDevtools(originRoot, true)
352
+ }
308
353
  return true
309
354
  }
310
355
  }