@fictjs/runtime 0.0.8 → 0.0.10

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.
package/src/dom.ts CHANGED
@@ -25,7 +25,14 @@ import {
25
25
  type AttributeSetter,
26
26
  type BindingHandle,
27
27
  } from './binding'
28
- import { Properties, ChildProperties, Aliases, getPropAlias, SVGNamespace } from './constants'
28
+ import {
29
+ Properties,
30
+ ChildProperties,
31
+ Aliases,
32
+ getPropAlias,
33
+ SVGElements,
34
+ SVGNamespace,
35
+ } from './constants'
29
36
  import { __fictPushContext, __fictPopContext } from './hooks'
30
37
  import { Fragment } from './jsx'
31
38
  import {
@@ -43,6 +50,11 @@ import { createPropsProxy, unwrapProps } from './props'
43
50
  import { untrack } from './scheduler'
44
51
  import type { DOMElement, FictNode, FictVNode, RefObject } from './types'
45
52
 
53
+ type NamespaceContext = 'svg' | 'mathml' | null
54
+
55
+ const SVG_NS = 'http://www.w3.org/2000/svg'
56
+ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'
57
+
46
58
  // ============================================================================
47
59
  // Main Render Function
48
60
  // ============================================================================
@@ -104,6 +116,19 @@ export function render(view: () => FictNode, container: HTMLElement): () => void
104
116
  * - Reactive values (functions returning any of the above)
105
117
  */
106
118
  export function createElement(node: FictNode): DOMElement {
119
+ return createElementWithContext(node, null)
120
+ }
121
+
122
+ function resolveNamespace(tagName: string, namespace: NamespaceContext): NamespaceContext {
123
+ if (tagName === 'svg') return 'svg'
124
+ if (tagName === 'math') return 'mathml'
125
+ if (namespace === 'mathml') return 'mathml'
126
+ if (namespace === 'svg') return 'svg'
127
+ if (SVGElements.has(tagName)) return 'svg'
128
+ return null
129
+ }
130
+
131
+ function createElementWithContext(node: FictNode, namespace: NamespaceContext): DOMElement {
107
132
  // Already a DOM node - pass through
108
133
  if (node instanceof Node) {
109
134
  return node
@@ -118,7 +143,22 @@ export function createElement(node: FictNode): DOMElement {
118
143
  if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
119
144
  // Handle BindingHandle (createList, createConditional, etc)
120
145
  if ('marker' in node) {
121
- return createElement((node as { marker: unknown }).marker as FictNode)
146
+ const handle = node as { marker: unknown; dispose?: () => void; flush?: () => void }
147
+ // Register dispose cleanup if available
148
+ if (typeof handle.dispose === 'function') {
149
+ registerRootCleanup(handle.dispose)
150
+ }
151
+ if (typeof handle.flush === 'function') {
152
+ const runFlush = () => handle.flush && handle.flush()
153
+ if (typeof queueMicrotask === 'function') {
154
+ queueMicrotask(runFlush)
155
+ } else {
156
+ Promise.resolve()
157
+ .then(runFlush)
158
+ .catch(() => undefined)
159
+ }
160
+ }
161
+ return createElement(handle.marker as FictNode)
122
162
  }
123
163
 
124
164
  const nodeRecord = node as unknown as Record<PropertyKey, unknown>
@@ -134,7 +174,7 @@ export function createElement(node: FictNode): DOMElement {
134
174
  if (Array.isArray(node)) {
135
175
  const frag = document.createDocumentFragment()
136
176
  for (const child of node) {
137
- appendChildNode(frag, child)
177
+ appendChildNode(frag, child, namespace)
138
178
  }
139
179
  return frag
140
180
  }
@@ -180,20 +220,20 @@ export function createElement(node: FictNode): DOMElement {
180
220
  })
181
221
 
182
222
  const props = createPropsProxy(baseProps)
223
+ // Create a fresh hook context for this component instance.
224
+ // This preserves slot state across re-renders driven by __fictRender.
225
+ __fictPushContext()
183
226
  try {
184
- // Create a fresh hook context for this component instance.
185
- // This preserves slot state across re-renders driven by __fictRender.
186
- __fictPushContext()
187
227
  const rendered = vnode.type(props)
188
- __fictPopContext()
189
- return createElement(rendered as FictNode)
228
+ return createElementWithContext(rendered as FictNode, namespace)
190
229
  } catch (err) {
191
- __fictPopContext()
192
230
  if (handleSuspend(err as any)) {
193
231
  return document.createComment('fict:suspend')
194
232
  }
195
233
  handleError(err, { source: 'render', componentName: vnode.type.name })
196
234
  throw err
235
+ } finally {
236
+ __fictPopContext()
197
237
  }
198
238
  }
199
239
 
@@ -201,15 +241,26 @@ export function createElement(node: FictNode): DOMElement {
201
241
  if (vnode.type === Fragment) {
202
242
  const frag = document.createDocumentFragment()
203
243
  const children = vnode.props?.children as FictNode | FictNode[] | undefined
204
- appendChildren(frag, children)
244
+ appendChildren(frag, children, namespace)
205
245
  return frag
206
246
  }
207
247
 
208
248
  // HTML Element
209
249
  const tagName = typeof vnode.type === 'string' ? vnode.type : 'div'
210
- const el = document.createElement(tagName)
211
- applyProps(el, vnode.props ?? {})
212
- return el
250
+ const resolvedNamespace = resolveNamespace(tagName, namespace)
251
+ const el =
252
+ resolvedNamespace === 'svg'
253
+ ? document.createElementNS(SVG_NS, tagName)
254
+ : resolvedNamespace === 'mathml'
255
+ ? document.createElementNS(MATHML_NS, tagName)
256
+ : document.createElement(tagName)
257
+ applyProps(el, vnode.props ?? {}, resolvedNamespace === 'svg')
258
+ appendChildren(
259
+ el as unknown as ParentNode & Node,
260
+ vnode.props?.children as FictNode | FictNode[] | undefined,
261
+ tagName === 'foreignObject' ? null : resolvedNamespace,
262
+ )
263
+ return el as DOMElement
213
264
  }
214
265
 
215
266
  /**
@@ -231,7 +282,7 @@ export function template(
231
282
 
232
283
  const create = (): Node => {
233
284
  const t = isMathML
234
- ? document.createElementNS('http://www.w3.org/1998/Math/MathML', 'template')
285
+ ? document.createElementNS(MATHML_NS, 'template')
235
286
  : document.createElement('template')
236
287
  t.innerHTML = html
237
288
 
@@ -279,7 +330,11 @@ function isBindingHandle(node: unknown): node is BindingHandle {
279
330
  /**
280
331
  * Append a child node to a parent, handling all node types including reactive values.
281
332
  */
282
- function appendChildNode(parent: HTMLElement | DocumentFragment, child: FictNode): void {
333
+ function appendChildNode(
334
+ parent: ParentNode & Node,
335
+ child: FictNode,
336
+ namespace: NamespaceContext,
337
+ ): void {
283
338
  // Skip nullish values
284
339
  if (child === null || child === undefined || child === false) {
285
340
  return
@@ -287,7 +342,7 @@ function appendChildNode(parent: HTMLElement | DocumentFragment, child: FictNode
287
342
 
288
343
  // Handle BindingHandle (recursive)
289
344
  if (isBindingHandle(child)) {
290
- appendChildNode(parent, child.marker)
345
+ appendChildNode(parent, child.marker, namespace)
291
346
  // Flush pending nodes now that markers are in the DOM
292
347
  child.flush?.()
293
348
  return
@@ -296,14 +351,14 @@ function appendChildNode(parent: HTMLElement | DocumentFragment, child: FictNode
296
351
  // Handle getter function (recursive)
297
352
  if (typeof child === 'function' && (child as () => FictNode).length === 0) {
298
353
  const childGetter = child as () => FictNode
299
- createChildBinding(parent as HTMLElement | DocumentFragment, childGetter, createElement)
354
+ createChildBinding(parent, childGetter, node => createElementWithContext(node, namespace))
300
355
  return
301
356
  }
302
357
 
303
358
  // Static child - create element and append
304
359
  if (Array.isArray(child)) {
305
360
  for (const item of child) {
306
- appendChildNode(parent, item)
361
+ appendChildNode(parent, item, namespace)
307
362
  }
308
363
  return
309
364
  }
@@ -313,14 +368,14 @@ function appendChildNode(parent: HTMLElement | DocumentFragment, child: FictNode
313
368
  if (typeof child !== 'object' || child === null) {
314
369
  domNode = document.createTextNode(String(child ?? ''))
315
370
  } else {
316
- domNode = createElement(child as any) as Node
371
+ domNode = createElementWithContext(child as any, namespace) as Node
317
372
  }
318
373
 
319
374
  // Handle DocumentFragment manually to avoid JSDOM issues
320
375
  if (domNode.nodeType === 11) {
321
376
  const children = Array.from(domNode.childNodes)
322
377
  for (const node of children) {
323
- appendChildNode(parent, node)
378
+ appendChildNode(parent, node as FictNode, namespace)
324
379
  }
325
380
  return
326
381
  }
@@ -345,19 +400,20 @@ function appendChildNode(parent: HTMLElement | DocumentFragment, child: FictNode
345
400
  * Append multiple children, handling arrays and nested structures.
346
401
  */
347
402
  function appendChildren(
348
- parent: HTMLElement | DocumentFragment,
403
+ parent: ParentNode & Node,
349
404
  children: FictNode | FictNode[] | undefined,
405
+ namespace: NamespaceContext,
350
406
  ): void {
351
407
  if (children === undefined) return
352
408
 
353
409
  if (Array.isArray(children)) {
354
410
  for (const child of children) {
355
- appendChildren(parent, child)
411
+ appendChildren(parent, child, namespace)
356
412
  }
357
413
  return
358
414
  }
359
415
 
360
- appendChildNode(parent, children)
416
+ appendChildNode(parent, children, namespace)
361
417
  }
362
418
 
363
419
  // ============================================================================
@@ -368,10 +424,10 @@ function appendChildren(
368
424
  * Apply a ref to an element, supporting both callback and object refs.
369
425
  * Both types are automatically cleaned up on unmount.
370
426
  */
371
- function applyRef(el: HTMLElement, value: unknown): void {
427
+ function applyRef(el: Element, value: unknown): void {
372
428
  if (typeof value === 'function') {
373
429
  // Callback ref
374
- const refFn = value as (el: HTMLElement | null) => void
430
+ const refFn = value as (el: Element | null) => void
375
431
  refFn(el)
376
432
 
377
433
  // Match React behavior: call ref(null) on unmount
@@ -382,7 +438,7 @@ function applyRef(el: HTMLElement, value: unknown): void {
382
438
  }
383
439
  } else if (value && typeof value === 'object' && 'current' in value) {
384
440
  // Object ref
385
- const refObj = value as RefObject<HTMLElement>
441
+ const refObj = value as RefObject<Element>
386
442
  refObj.current = el
387
443
 
388
444
  // Auto-cleanup on unmount
@@ -402,7 +458,7 @@ function applyRef(el: HTMLElement, value: unknown): void {
402
458
  * Apply props to an HTML element, setting up reactive bindings as needed.
403
459
  * Uses comprehensive property constants for correct attribute/property handling.
404
460
  */
405
- function applyProps(el: HTMLElement, props: Record<string, unknown>, isSVG = false): void {
461
+ function applyProps(el: Element, props: Record<string, unknown>, isSVG = false): void {
406
462
  props = unwrapProps(props)
407
463
  const tagName = el.tagName
408
464
 
@@ -545,10 +601,6 @@ function applyProps(el: HTMLElement, props: Record<string, unknown>, isSVG = fal
545
601
  const attrName = Aliases[key] || key
546
602
  createAttributeBinding(el, attrName, value as MaybeReactive<unknown>, setAttribute)
547
603
  }
548
-
549
- // Handle children
550
- const children = props.children as FictNode | FictNode[] | undefined
551
- appendChildren(el, children)
552
604
  }
553
605
 
554
606
  /**
@@ -565,7 +617,7 @@ function toPropertyName(name: string): string {
565
617
  /**
566
618
  * Set an attribute on an element, handling various value types.
567
619
  */
568
- const setAttribute: AttributeSetter = (el: HTMLElement, key: string, value: unknown): void => {
620
+ const setAttribute: AttributeSetter = (el: Element, key: string, value: unknown): void => {
569
621
  // Remove attribute for nullish/false values
570
622
  if (value === undefined || value === null || value === false) {
571
623
  el.removeAttribute(key)
@@ -598,7 +650,7 @@ const setAttribute: AttributeSetter = (el: HTMLElement, key: string, value: unkn
598
650
  /**
599
651
  * Set a property on an element, ensuring nullish values clear sensibly.
600
652
  */
601
- const setProperty: AttributeSetter = (el: HTMLElement, key: string, value: unknown): void => {
653
+ const setProperty: AttributeSetter = (el: Element, key: string, value: unknown): void => {
602
654
  if (value === undefined || value === null) {
603
655
  const fallback = key === 'checked' || key === 'selected' ? false : ''
604
656
  ;(el as unknown as Record<string, unknown>)[key] = fallback
@@ -610,7 +662,7 @@ const setProperty: AttributeSetter = (el: HTMLElement, key: string, value: unkno
610
662
  for (const k in value as Record<string, string>) {
611
663
  const v = (value as Record<string, string>)[k]
612
664
  if (v !== undefined) {
613
- ;(el.style as unknown as Record<string, string>)[k] = String(v)
665
+ ;((el as HTMLElement).style as unknown as Record<string, string>)[k] = String(v)
614
666
  }
615
667
  }
616
668
  return
@@ -622,14 +674,14 @@ const setProperty: AttributeSetter = (el: HTMLElement, key: string, value: unkno
622
674
  /**
623
675
  * Set innerHTML on an element (used for dangerouslySetInnerHTML)
624
676
  */
625
- const setInnerHTML: AttributeSetter = (el: HTMLElement, _key: string, value: unknown): void => {
626
- el.innerHTML = value == null ? '' : String(value)
677
+ const setInnerHTML: AttributeSetter = (el: Element, _key: string, value: unknown): void => {
678
+ ;(el as HTMLElement).innerHTML = value == null ? '' : String(value)
627
679
  }
628
680
 
629
681
  /**
630
682
  * Set a boolean attribute on an element (empty string when true, removed when false)
631
683
  */
632
- const setBoolAttribute: AttributeSetter = (el: HTMLElement, key: string, value: unknown): void => {
684
+ const setBoolAttribute: AttributeSetter = (el: Element, key: string, value: unknown): void => {
633
685
  if (value) {
634
686
  el.setAttribute(key, '')
635
687
  } else {
@@ -640,7 +692,7 @@ const setBoolAttribute: AttributeSetter = (el: HTMLElement, key: string, value:
640
692
  /**
641
693
  * Set an attribute with a namespace (for SVG xlink:href, etc.)
642
694
  */
643
- function setAttributeNS(el: HTMLElement, namespace: string, name: string, value: unknown): void {
695
+ function setAttributeNS(el: Element, namespace: string, name: string, value: unknown): void {
644
696
  if (value == null) {
645
697
  el.removeAttributeNS(namespace, name)
646
698
  } else {
package/src/effect.ts CHANGED
@@ -61,7 +61,8 @@ export function createRenderEffect(fn: Effect): () => void {
61
61
  cleanup = maybeCleanup
62
62
  }
63
63
  } catch (err) {
64
- if (handleError(err, { source: 'effect' }, rootForError)) {
64
+ const handled = handleError(err, { source: 'effect' }, rootForError)
65
+ if (handled) {
65
66
  return
66
67
  }
67
68
  throw err
@@ -4,6 +4,7 @@ import {
4
4
  createRootContext,
5
5
  destroyRoot,
6
6
  flushOnMount,
7
+ getCurrentRoot,
7
8
  pushRoot,
8
9
  popRoot,
9
10
  registerErrorHandler,
@@ -24,6 +25,7 @@ export function ErrorBoundary(props: ErrorBoundaryProps): FictNode {
24
25
  fragment.appendChild(marker)
25
26
 
26
27
  const currentView = createSignal<FictNode | null>(props.children ?? null)
28
+ const hostRoot = getCurrentRoot()
27
29
 
28
30
  let cleanup: (() => void) | undefined
29
31
  let activeNodes: Node[] = []
@@ -52,7 +54,7 @@ export function ErrorBoundary(props: ErrorBoundaryProps): FictNode {
52
54
  return
53
55
  }
54
56
 
55
- const root = createRootContext()
57
+ const root = createRootContext(hostRoot)
56
58
  const prev = pushRoot(root)
57
59
  let nodes: Node[] = []
58
60
  try {
package/src/hooks.ts CHANGED
@@ -5,18 +5,26 @@ import { createSignal, type SignalAccessor, type ComputedAccessor } from './sign
5
5
  interface HookContext {
6
6
  slots: unknown[]
7
7
  cursor: number
8
+ rendering?: boolean
8
9
  }
9
10
 
10
11
  const ctxStack: HookContext[] = []
11
12
 
13
+ function assertRenderContext(ctx: HookContext, hookName: string): void {
14
+ if (!ctx.rendering) {
15
+ throw new Error(`${hookName} can only be used during render execution`)
16
+ }
17
+ }
18
+
12
19
  export function __fictUseContext(): HookContext {
13
20
  if (ctxStack.length === 0) {
14
- const ctx: HookContext = { slots: [], cursor: 0 }
21
+ const ctx: HookContext = { slots: [], cursor: 0, rendering: true }
15
22
  ctxStack.push(ctx)
16
23
  return ctx
17
24
  }
18
25
  const ctx = ctxStack[ctxStack.length - 1]!
19
26
  ctx.cursor = 0
27
+ ctx.rendering = true
20
28
  return ctx
21
29
  }
22
30
 
@@ -35,6 +43,7 @@ export function __fictResetContext(): void {
35
43
  }
36
44
 
37
45
  export function __fictUseSignal<T>(ctx: HookContext, initial: T, slot?: number): SignalAccessor<T> {
46
+ assertRenderContext(ctx, '__fictUseSignal')
38
47
  const index = slot ?? ctx.cursor++
39
48
  if (!ctx.slots[index]) {
40
49
  ctx.slots[index] = createSignal(initial)
@@ -47,6 +56,7 @@ export function __fictUseMemo<T>(
47
56
  fn: () => T,
48
57
  slot?: number,
49
58
  ): ComputedAccessor<T> {
59
+ assertRenderContext(ctx, '__fictUseMemo')
50
60
  const index = slot ?? ctx.cursor++
51
61
  if (!ctx.slots[index]) {
52
62
  ctx.slots[index] = createMemo(fn)
@@ -55,6 +65,7 @@ export function __fictUseMemo<T>(
55
65
  }
56
66
 
57
67
  export function __fictUseEffect(ctx: HookContext, fn: () => void, slot?: number): void {
68
+ assertRenderContext(ctx, '__fictUseEffect')
58
69
  const index = slot ?? ctx.cursor++
59
70
  if (!ctx.slots[index]) {
60
71
  ctx.slots[index] = createEffect(fn)
@@ -64,9 +75,11 @@ export function __fictUseEffect(ctx: HookContext, fn: () => void, slot?: number)
64
75
  export function __fictRender<T>(ctx: HookContext, fn: () => T): T {
65
76
  ctxStack.push(ctx)
66
77
  ctx.cursor = 0
78
+ ctx.rendering = true
67
79
  try {
68
80
  return fn()
69
81
  } finally {
82
+ ctx.rendering = false
70
83
  ctxStack.pop()
71
84
  }
72
85
  }