@plastic-js/plastic 1.0.1 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plastic-js/plastic",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "main": "src/index.js",
5
5
  "access": "public",
6
6
  "sideEffects": false,
@@ -57,9 +57,9 @@
57
57
  "@babel/core": "^7.29.0",
58
58
  "@babel/preset-react": "^7.28.5",
59
59
  "@testing-library/jest-dom": "^6.9.1",
60
- "babel-preset-plastic": "^0.1.0",
61
60
  "@vitejs/plugin-react": "^6.0.2",
62
61
  "@vitejs/plugin-vue": "^6.0.7",
62
+ "babel-preset-plastic": "^0.1.5",
63
63
  "eslint": "^9.39.2",
64
64
  "eslint-config-janus": "^9.0.21",
65
65
  "eslint-plugin-mocha": "^11.3.0",
package/src/async.js ADDED
@@ -0,0 +1,88 @@
1
+ import { createSignal } from './reactivity.js'
2
+
3
+ const normalizeService = (service)=> {
4
+ if (typeof service === 'function'){
5
+ return service
6
+ }
7
+
8
+ if (service instanceof Promise){
9
+ return ()=> service
10
+ }
11
+
12
+ throw new TypeError('createAsync expects a Promise or a function returning a Promise')
13
+ }
14
+
15
+ const createAsync = (service)=> {
16
+ const runner = normalizeService(service)
17
+ const isLoading = createSignal(false)
18
+ const data = createSignal(undefined)
19
+ const error = createSignal(null)
20
+ let latestRunId = 0
21
+
22
+ const run = (...args)=> {
23
+ const runId = ++latestRunId
24
+ isLoading(true)
25
+ error(null)
26
+
27
+ let promise
28
+ try {
29
+ promise = runner(...args)
30
+ } catch(err){
31
+ error(err)
32
+ isLoading(false)
33
+ return Promise.reject(err)
34
+ }
35
+
36
+ if (!(promise instanceof Promise)){
37
+ const typeError = new TypeError('createAsync runner must return a Promise')
38
+ error(typeError)
39
+ isLoading(false)
40
+ return Promise.reject(typeError)
41
+ }
42
+
43
+ return promise
44
+ .then((value)=> {
45
+ if (runId !== latestRunId){
46
+ return value
47
+ }
48
+
49
+ data(value)
50
+ error(null)
51
+ return value
52
+ })
53
+ .catch((err)=> {
54
+ if (runId === latestRunId){
55
+ error(err)
56
+ }
57
+ throw err
58
+ })
59
+ .finally(()=> {
60
+ if (runId === latestRunId){
61
+ isLoading(false)
62
+ }
63
+ })
64
+ }
65
+
66
+ const cancel = ()=> {
67
+ if (!isLoading()){
68
+ return
69
+ }
70
+ latestRunId++
71
+ isLoading(false)
72
+ }
73
+
74
+ // Default behavior: trigger once on creation for immediate data fetch.
75
+ run().catch(()=> {})
76
+
77
+ return {
78
+ isLoading,
79
+ data,
80
+ error,
81
+ run,
82
+ cancel,
83
+ }
84
+ }
85
+
86
+ export {
87
+ createAsync,
88
+ }
@@ -1,3 +1,9 @@
1
+ // Shared module-level state for the currently running computation.
2
+ // Extracted into its own module so the two consumers can share the same
3
+ // reference without forming a circular import between them:
4
+ // - the computation runner writes it before/after executing a tracked fn
5
+ // - the signal/state layer reads it when a signal is invoked (e.g.
6
+ // `signal()`) to register the running computation as a dependency
1
7
  let currentComputation = null
2
8
 
3
9
  const getCurrentComputation = ()=> currentComputation
package/src/index.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  useSearchParams,
40
40
  } from './router.js'
41
41
  import { batch } from './reactivity.js'
42
+ import { createAsync } from './async.js'
42
43
  import { mergeProps } from './merge-props.js'
43
44
  import { createSplitProps, splitProps } from './split-props.js'
44
45
 
@@ -80,6 +81,7 @@ export {
80
81
  createContext,
81
82
  useContext,
82
83
  batch,
84
+ createAsync,
83
85
  mergeProps,
84
86
  splitProps,
85
87
  createSplitProps,
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  batch, createComputed, createSignal, createTree, effect, isComputed, isSignal, isTree, runUntracked, toRaw,
3
3
  } from './reactivity.js'
4
+ import { getActiveSub, setActiveSub } from 'alien-signals'
4
5
  import {
5
6
  flattenChildren, isEventProp, normalizeTextNodeValue, toClassMap, toClassTokens,
6
7
  } from './utils.js'
7
8
  import { getCurrentComputation, setCurrentComputation } from './computation-context.js'
8
9
  import { createControlFlow } from './control-flow.js'
9
- import { isMergedProps, mergeProps } from './merge-props.js'
10
+ import { hasMergedPropsStaticKeys, isMergedProps, mergeProps } from './merge-props.js'
10
11
 
11
12
  const Fragment = Symbol('Fragment')
12
13
  const OWNER = Symbol('owner')
@@ -21,32 +22,32 @@ const CONTEXT_ID = Symbol('context-id')
21
22
 
22
23
  const getCurrentOwner = ()=> currentOwner
23
24
 
25
+ // Owner fields are lazily allocated. Most components register nothing
26
+ // (no onMount, no context, no refs), so paying for a Set + Map + 4 arrays
27
+ // per component dominated mount cost. Allocate each collection only on first
28
+ // use. Readers must tolerate `undefined`.
24
29
  const createOwner = (parent = null)=> {
25
30
  const owner = {
26
31
  parent,
27
- children: new Set(),
28
- cleanups: [],
29
- effects: [],
30
- contexts: new Map(),
31
- refs: [],
32
- mounts: [],
32
+ children: undefined,
33
+ cleanups: undefined,
34
+ effects: undefined,
35
+ contexts: undefined,
36
+ refs: undefined,
37
+ mounts: undefined,
33
38
  mounted: false,
34
39
  }
35
40
  if (parent){
36
- parent.children.add(owner)
41
+ (parent.children ??= new Set()).add(owner)
37
42
  }
38
43
  return owner
39
44
  }
40
45
 
41
46
  const runOwnerMounts = (owner)=> {
42
47
  runUntracked(()=> {
43
- owner.children.forEach(child=> runOwnerMounts(child))
44
- owner.refs.forEach((fn)=> {
45
- fn()
46
- })
47
- owner.mounts.forEach((fn)=> {
48
- fn()
49
- })
48
+ owner.children?.forEach(child=> runOwnerMounts(child))
49
+ owner.refs?.forEach((fn)=> { fn() })
50
+ owner.mounts?.forEach((fn)=> { fn() })
50
51
  owner.mounted = true
51
52
  })
52
53
  }
@@ -87,29 +88,33 @@ const mountOwnedSubtree = (node)=> {
87
88
 
88
89
  const disposeOwner = (owner)=> {
89
90
  if (owner.parent){
90
- owner.parent.children.delete(owner)
91
+ owner.parent.children?.delete(owner)
91
92
  }
92
93
 
93
94
  // Dispose all child owners recursively
94
- owner.children.forEach(child=> disposeOwner(child))
95
- owner.children.clear()
95
+ if (owner.children){
96
+ owner.children.forEach(child=> disposeOwner(child))
97
+ owner.children.clear()
98
+ }
96
99
 
97
100
  // Stop owner-bound effects for this scope during unmount.
98
- ;[...owner.effects].reverse().forEach((stop)=> {
99
- if (typeof stop === 'function'){
100
- stop()
101
+ if (owner.effects){
102
+ for (let i = owner.effects.length - 1; i >= 0; i -= 1){
103
+ const stop = owner.effects[i]
104
+ if (typeof stop === 'function') stop()
101
105
  }
102
- })
103
- owner.effects.length = 0
106
+ owner.effects.length = 0
107
+ }
104
108
 
105
- ;[...owner.cleanups].reverse().forEach((cleanup)=> {
106
- if (typeof cleanup === 'function'){
107
- cleanup()
109
+ if (owner.cleanups){
110
+ for (let i = owner.cleanups.length - 1; i >= 0; i -= 1){
111
+ const cleanup = owner.cleanups[i]
112
+ if (typeof cleanup === 'function') cleanup()
108
113
  }
109
- })
110
- owner.cleanups.length = 0
111
- owner.refs.length = 0
112
- owner.mounts.length = 0
114
+ owner.cleanups.length = 0
115
+ }
116
+ if (owner.refs) owner.refs.length = 0
117
+ if (owner.mounts) owner.mounts.length = 0
113
118
  }
114
119
 
115
120
  const flushCleanups = (list)=> {
@@ -151,8 +156,8 @@ const createBindingEffect = (runner)=> {
151
156
  return stop
152
157
  }
153
158
  // Register effect and its disposal in the owner
154
- owner.effects.push(stop)
155
- owner.cleanups.push(()=> {
159
+ (owner.effects ??= []).push(stop)
160
+ ;(owner.cleanups ??= []).push(()=> {
156
161
  // Run remaining local cleanups, untracked to avoid accidental dependency registration
157
162
  flushCleanups(local)
158
163
  })
@@ -168,7 +173,7 @@ const registerCleanup = (fn)=> {
168
173
  if (currentComputation){
169
174
  currentComputation.cleanups.push(fn)
170
175
  } else {
171
- currentOwner.cleanups.push(fn)
176
+ (currentOwner.cleanups ??= []).push(fn)
172
177
  }
173
178
  }
174
179
 
@@ -177,14 +182,14 @@ const onMount = (fn)=> {
177
182
  if (!currentOwner){
178
183
  throw new Error('onMount must be called within a component scope')
179
184
  }
180
- currentOwner.mounts.push(fn)
185
+ (currentOwner.mounts ??= []).push(fn)
181
186
  }
182
187
 
183
188
  const onUnmount = (fn)=> {
184
189
  if (!currentOwner){
185
190
  throw new Error('onUnmount must be called within a component scope')
186
191
  }
187
- currentOwner.cleanups.push(fn)
192
+ (currentOwner.cleanups ??= []).push(fn)
188
193
  }
189
194
 
190
195
  const createContext = (defaultValue)=> {
@@ -198,7 +203,7 @@ const createContext = (defaultValue)=> {
198
203
  throw new Error('Context Provider must be rendered within a component scope')
199
204
  }
200
205
 
201
- currentOwner.contexts.set(context[CONTEXT_ID], value)
206
+ (currentOwner.contexts ??= new Map()).set(context[CONTEXT_ID], value)
202
207
 
203
208
  if (typeof children === 'function'){
204
209
  return children()
@@ -221,7 +226,7 @@ const useContext = (context)=> {
221
226
 
222
227
  let owner = currentOwner
223
228
  while (owner){
224
- if (owner.contexts.has(context[CONTEXT_ID])){
229
+ if (owner.contexts?.has(context[CONTEXT_ID])){
225
230
  return owner.contexts.get(context[CONTEXT_ID])
226
231
  }
227
232
  owner = owner.parent
@@ -331,8 +336,21 @@ const materializeComponentDescriptor = (descriptor)=> {
331
336
  componentProps = mergeProps(componentProps, { children: kids })
332
337
  }
333
338
 
334
- const result = runUntracked(()=> runWithOwner(owner, ()=> descriptor.tag(componentProps)))
335
- const normalized = runUntracked(()=> renderInOwner(owner, result))
339
+ // Fuse the owner swap and untracked context into one try/finally. Inlining
340
+ // avoids the runUntracked closure + extra try/finally per component, which
341
+ // shows up in profile (3000 components × these wrappers was a meaningful
342
+ // share of mount time).
343
+ const prevOwner = currentOwner
344
+ const prevSub = getActiveSub()
345
+ currentOwner = owner
346
+ setActiveSub(undefined)
347
+ let normalized
348
+ try {
349
+ normalized = node2Element(descriptor.tag(componentProps))
350
+ } finally {
351
+ currentOwner = prevOwner
352
+ setActiveSub(prevSub)
353
+ }
336
354
 
337
355
  if (normalized instanceof Node){
338
356
  normalized[OWNER] = owner
@@ -594,7 +612,7 @@ const applyRefProp = (element, ref)=> {
594
612
  const owner = currentOwner
595
613
 
596
614
  if (owner && !owner.mounted) {
597
- owner.refs.push(() => assignRef(element))
615
+ (owner.refs ??= []).push(() => assignRef(element))
598
616
  } else {
599
617
  assignRef(element)
600
618
  }
@@ -623,6 +641,41 @@ const disposeBindings = (bindings)=> {
623
641
  // zero-arg accessor thunks (used widely by ark-plastic / zag adapters) are
624
642
  // unwrapped before being applied to the DOM.
625
643
  const bindReactiveProp = (element, props, key)=> {
644
+ const rawValue = props[key]
645
+
646
+ // Static (non-reactive) fast path: write the prop directly with no binding
647
+ // effect. Most JSX props in real apps are static (className='row', id=...,
648
+ // type='button' etc.) — building an effect for each one was the biggest
649
+ // per-component cost in mount benchmarks.
650
+ if (!isReactive(rawValue)){
651
+ let prevStyleValue
652
+ if (key === 'className' || key === 'class'){
653
+ applyClassProp(element, rawValue)
654
+ } else if (key === 'style'){
655
+ prevStyleValue = applyStyleProp(element, rawValue, undefined)
656
+ } else {
657
+ const domKey = JSX_PROP_MAP[key] ?? key
658
+ setDomProp(element, domKey, rawValue)
659
+ }
660
+
661
+ return ()=> {
662
+ if (key === 'className' || key === 'class'){
663
+ element.removeAttribute('class')
664
+ return
665
+ }
666
+ if (key === 'style'){
667
+ if (prevStyleValue && typeof prevStyleValue === 'object'){
668
+ Object.keys(prevStyleValue).forEach(prop=> clearStyleKey(element, prop))
669
+ } else if (typeof prevStyleValue === 'string'){
670
+ element.style.cssText = ''
671
+ }
672
+ return
673
+ }
674
+ const domKey = JSX_PROP_MAP[key] ?? key
675
+ clearDomProp(element, domKey)
676
+ }
677
+ }
678
+
626
679
  let prevStyleValue
627
680
  const stop = createBindingEffect(()=> {
628
681
  const value = resolveReactiveValue(props[key])
@@ -694,11 +747,15 @@ const bindReactiveEvent = (element, props, key)=> {
694
747
  // keys later we tear down the previous bindings and rebuild them from the
695
748
  // current key set.
696
749
  const applyProps = (element, props = {})=> {
697
- createBindingEffect(()=> {
750
+ const setup = ()=> {
698
751
  const bindings = []
699
- registerCleanup(()=> {
700
- disposeBindings(bindings)
701
- })
752
+ // Only register cleanup if there's an owner/computation to attach to.
753
+ // Allows top-level h() calls outside any component scope (e.g. tests).
754
+ if (currentOwner || getCurrentComputation()){
755
+ registerCleanup(()=> {
756
+ disposeBindings(bindings)
757
+ })
758
+ }
702
759
 
703
760
  for (const key of Reflect.ownKeys(props)){
704
761
  if (typeof key === 'symbol' || key === 'children' || key === 'key'){
@@ -723,40 +780,68 @@ const applyProps = (element, props = {})=> {
723
780
  }
724
781
  bindings.push(bindReactiveProp(element, props, key))
725
782
  }
726
- })
783
+ }
784
+
785
+ // Only wrap in an outer binding effect when the proxy has dynamic keys (a
786
+ // function-typed spread source like `{...api()}`). For static-key proxies
787
+ // (the common babel reactive transform output with no spreads), and for
788
+ // plain object props, keys never change — skip the outer effect to avoid
789
+ // one owner-effect allocation per element.
790
+ if (isMergedProps(props) && !hasMergedPropsStaticKeys(props)){
791
+ createBindingEffect(setup)
792
+ } else {
793
+ setup()
794
+ }
727
795
  return element
728
796
  }
729
797
 
730
798
  // Normalize any JSX return value into a DOM node that can be appended safely.
799
+ // Dispatch is organized as a `typeof` switch so the engine can lower it to a
800
+ // jump table, and the most frequent shapes are handled first:
801
+ // - 'string'/'number': text children like `["item ", i]` (hottest in lists)
802
+ // - 'object' → Node: the Element returned from a component body
803
+ // - 'object' → descriptor: child components in an array
804
+ // Within the 'object' arm we use `node.nodeType != null` instead of
805
+ // `instanceof Node`, since a property read is cheaper than walking the
806
+ // prototype chain, and we read the descriptor symbol directly to skip the
807
+ // redundant `typeof === 'object'` check inside isComponentDescriptor.
731
808
  const node2Element = (node)=> {
732
- if (node === null || node === undefined){
733
- return createPlaceholder()
734
- }
735
- if (isComponentDescriptor(node)){
736
- return materializeComponentDescriptor(node)
737
- }
738
- if (isReactivePrimitive(node)){
739
- return createReactiveTextNode(node)
740
- }
741
- if (typeof node === 'function'){
742
- return createReactiveChildNode(node)
743
- }
744
- if (typeof node === 'string' || typeof node === 'number'){
745
- return document.createTextNode(String(node))
746
- }
747
- if (node instanceof Node){
748
- flushPendingDescriptors(node)
749
- return node
750
- }
751
- if (Array.isArray(node)){
752
- const fragment = document.createDocumentFragment()
753
- appendChildren(fragment, node)
754
- // Do NOT flush here — the caller (appendChild) will transfer any pending
755
- // descriptors to the real parent element before draining the fragment, so
756
- // flushPendingDescriptors runs later with the correct owner active.
757
- return fragment
809
+ switch (typeof node){
810
+ case 'string':
811
+ case 'number':
812
+ return document.createTextNode(String(node))
813
+ case 'function':
814
+ // Signals and computeds are functions too (alien-signals binds an
815
+ // operator function) — they render as reactive text nodes, while a
816
+ // plain function child is a thunk that returns dynamic content.
817
+ if (isReactivePrimitive(node)) return createReactiveTextNode(node)
818
+ return createReactiveChildNode(node)
819
+ case 'undefined':
820
+ return createPlaceholder()
821
+ case 'object': {
822
+ if (node === null) return createPlaceholder()
823
+ // Real DOM node returned from h()/jsx() — by far the most common
824
+ // 'object' case during initial mount.
825
+ if (node.nodeType != null){
826
+ flushPendingDescriptors(node)
827
+ return node
828
+ }
829
+ if (node[COMPONENT_DESCRIPTOR] === true){
830
+ return materializeComponentDescriptor(node)
831
+ }
832
+ if (Array.isArray(node)){
833
+ const fragment = document.createDocumentFragment()
834
+ appendChildren(fragment, node)
835
+ // Do NOT flush here — the caller (appendChild) will transfer any pending
836
+ // descriptors to the real parent element before draining the fragment, so
837
+ // flushPendingDescriptors runs later with the correct owner active.
838
+ return fragment
839
+ }
840
+ return createPlaceholder()
841
+ }
842
+ default:
843
+ return createPlaceholder()
758
844
  }
759
- return createPlaceholder()
760
845
  }
761
846
 
762
847
  const materializeNode = node=> node2Element(node)
@@ -956,10 +1041,212 @@ const jsx = (tag, props, key)=> {
956
1041
  return h(tag, mergeProps(props, { key }))
957
1042
  }
958
1043
  const jsxs = jsx
1044
+
1045
+ // Static fast path counterpart to applyProps: writes a single literal value
1046
+ // straight to the DOM with no isReactive check, no binding-effect wrapper, and
1047
+ // no JSX_PROP_MAP miss for the hot keys. Used exclusively by jsxStatic, which
1048
+ // the babel reactive transform emits when every attribute value is provably a
1049
+ // scalar literal (string/number/boolean/null) — so there is nothing to track,
1050
+ // nothing to dispose, and the prop never changes after initial mount.
1051
+ const applyStaticProp = (element, key, value)=> {
1052
+ if (key === 'className' || key === 'class'){
1053
+ applyClassProp(element, value)
1054
+ return
1055
+ }
1056
+ if (key === 'style'){
1057
+ applyStyleProp(element, value, undefined)
1058
+ return
1059
+ }
1060
+ setDomProp(element, JSX_PROP_MAP[key] ?? key, value)
1061
+ }
1062
+
1063
+ // jsxStatic — emitted by the babel reactive transform when ALL of these hold:
1064
+ // - tag is a lowercase intrinsic string (no components, no Dynamic)
1065
+ // - at least one attribute, none of them spread
1066
+ // - every attribute value is a scalar literal (string/number/boolean/null)
1067
+ //
1068
+ // Under those conditions there are no reactive props, no event handlers, no
1069
+ // refs, no merged-props proxy — applyProps' entire dispatch machinery
1070
+ // (createBindingEffect / isMergedProps / Reflect.ownKeys / bindReactiveProp /
1071
+ // isReactive) is pure overhead. We bypass it and write each prop directly.
1072
+ // Children pass through unchanged: babel emits the same child expressions it
1073
+ // would for jsx(), so dynamic children still arrive as thunks/descriptors and
1074
+ // are routed through appendChild's existing reactive/defer paths.
1075
+ const jsxStatic = (tag, props, child)=> {
1076
+ const element = SVG_TAGS.has(tag)
1077
+ ? document.createElementNS('http://www.w3.org/2000/svg', tag)
1078
+ : document.createElement(tag)
1079
+ for (const key in props){
1080
+ // Defensive: skip framework-reserved keys in case the babel transform
1081
+ // ever emits them here. `children` shouldn't appear (it's the third
1082
+ // arg), and `key` is consumed by the reconciler upstream.
1083
+ if (key === 'children' || key === 'key') continue
1084
+ applyStaticProp(element, key, props[key])
1085
+ }
1086
+ if (child !== undefined){
1087
+ // Babel emits the third arg as a single value when there is one child
1088
+ // and an array when there are several — mirror jsx() / jsxs() shape.
1089
+ if (Array.isArray(child)){
1090
+ appendChildren(element, child)
1091
+ } else {
1092
+ appendChild(element, child)
1093
+ }
1094
+ }
1095
+ return element
1096
+ }
959
1097
  // jsxDEV is the development-mode variant used by automatic JSX transforms; the extra
960
1098
  // debug arguments (isStaticChildren, source, self) are unused at runtime.
961
1099
  const jsxDEV = (tag, props, key) => jsx(tag, props, key)
962
1100
 
1101
+ // ============ DOM template cloning (Solid-style) ============
1102
+ // Three helpers the babel reactive transform can emit when it identifies a
1103
+ // statically-shaped JSX subtree. They let the framework skip per-element
1104
+ // createElement/applyProps/appendChild for the static skeleton and only do
1105
+ // runtime work for the dynamic holes that babel can't fold into the template.
1106
+ //
1107
+ // Emission shape from babel:
1108
+ // const _tmpl$ = /*#__PURE__*/ template('<div class="row"><span>item </span></div>')
1109
+ // const Row = ({i}) => {
1110
+ // const _el = _tmpl$.cloneNode(true)
1111
+ // const _span = _el.firstChild
1112
+ // insert(_span, () => i, null) // dynamic text appended at end of span
1113
+ // return _el
1114
+ // }
1115
+ // Static props go into the template string itself; dynamic props use setProp;
1116
+ // dynamic children use insert. There is no separate clone helper — babel emits
1117
+ // `_tmpl$.cloneNode(true)` directly.
1118
+
1119
+ // Parse `html` into a detached template and return the first child of its
1120
+ // content. Babel emits ONE module-level `template()` call per unique static
1121
+ // shape, so the parse cost is amortized across every instance — only the
1122
+ // per-instance `cloneNode(true)` remains hot.
1123
+ //
1124
+ // SVG note: an `<svg>` element parsed via innerHTML of an HTMLTemplateElement
1125
+ // correctly inherits the SVG namespace, but standalone SVG children (`<circle>`
1126
+ // at the root) do not — pass isSVG=true to wrap-then-unwrap so the namespace
1127
+ // resolves on every node.
1128
+ const template = (html, isSVG = false)=> {
1129
+ const t = document.createElement('template')
1130
+ if (isSVG){
1131
+ t.innerHTML = `<svg>${html}</svg>`
1132
+ const svgRoot = t.content.firstChild
1133
+ // Unwrap so the caller's first cloneNode points at the real root.
1134
+ return svgRoot.firstChild ?? svgRoot
1135
+ }
1136
+ t.innerHTML = html
1137
+ return t.content.firstChild
1138
+ }
1139
+
1140
+ // Recursively resolve `value` (which may be a thunk, signal, descriptor, node,
1141
+ // primitive, or array) into a flat list of Nodes ready to insert. Mirrors what
1142
+ // node2Element does but flattens arrays in place instead of producing a
1143
+ // DocumentFragment, since insert() needs direct control over each Node so the
1144
+ // next reactive run can remove exactly the nodes it inserted.
1145
+ const resolveChildToNodes = (value, out)=> {
1146
+ let v = value
1147
+ // Walk through plain thunks (NOT signals/computeds — those resolve to a
1148
+ // reactive text node so the framework can update .data in place).
1149
+ let steps = 0
1150
+ while (typeof v === 'function' && !isReactivePrimitive(v) && steps < MAX_REACTIVE_RESOLVE_STEPS){
1151
+ v = v()
1152
+ steps += 1
1153
+ }
1154
+ if (v == null || v === true || v === false) return
1155
+ if (v instanceof Node){
1156
+ out.push(v)
1157
+ return
1158
+ }
1159
+ if (Array.isArray(v)){
1160
+ for (const item of v) resolveChildToNodes(item, out)
1161
+ return
1162
+ }
1163
+ if (isComponentDescriptor(v)){
1164
+ out.push(materializeComponentDescriptor(v))
1165
+ return
1166
+ }
1167
+ if (isReactivePrimitive(v)){
1168
+ out.push(createReactiveTextNode(v))
1169
+ return
1170
+ }
1171
+ out.push(document.createTextNode(String(v)))
1172
+ }
1173
+
1174
+ // Insert dynamic content into `parent` immediately before `marker` (or at the
1175
+ // end when marker is null). Static accessors run once; functions/signals wrap
1176
+ // in a binding effect so the slot updates whenever the source changes.
1177
+ //
1178
+ // Single-text-node fast path: when both the previous and next render produce a
1179
+ // single text/string/number, mutate the existing text node's `.data` instead
1180
+ // of swapping nodes. This matches Solid's behavior and avoids DOM thrash for
1181
+ // the common `{count()}` shape.
1182
+ const insert = (parent, accessor, marker = null)=> {
1183
+ if (typeof accessor !== 'function'){
1184
+ const nodes = []
1185
+ resolveChildToNodes(accessor, nodes)
1186
+ for (const n of nodes) parent.insertBefore(n, marker)
1187
+ return
1188
+ }
1189
+ let current = []
1190
+ createBindingEffect(()=> {
1191
+ const next = accessor()
1192
+
1193
+ // Fast path: single existing text node + scalar next value → in-place data update.
1194
+ if (current.length === 1 && current[0].nodeType === 3
1195
+ && (typeof next === 'string' || typeof next === 'number')){
1196
+ const str = String(next)
1197
+ if (current[0].data !== str) current[0].data = str
1198
+ return
1199
+ }
1200
+
1201
+ const nodes = []
1202
+ resolveChildToNodes(next, nodes)
1203
+
1204
+ // Naive diff: remove current, insert next. Adequate for single-slot
1205
+ // inserts; keyed list reconciliation is handled separately by <Loop>.
1206
+ for (const n of current){
1207
+ if (n.parentNode === parent) parent.removeChild(n)
1208
+ }
1209
+ for (const n of nodes) parent.insertBefore(n, marker)
1210
+
1211
+ // Mount any owners attached to inserted subtrees (component roots inside
1212
+ // the inserted nodes register their owner on the DOM node — runOwnerMounts
1213
+ // must fire once they're connected).
1214
+ if (parent.isConnected){
1215
+ for (const n of nodes) mountOwnedSubtree(n)
1216
+ }
1217
+
1218
+ current = nodes
1219
+ })
1220
+ }
1221
+
1222
+ // Apply a single prop (static or reactive) to an element. Static values write
1223
+ // once; function/signal values wrap in a binding effect that re-applies on
1224
+ // change. Class and style go through the existing helpers so the
1225
+ // merge/diff semantics stay identical to the JSX path.
1226
+ const setProp = (element, key, accessor)=> {
1227
+ if (typeof accessor !== 'function' || isReactivePrimitive(accessor)){
1228
+ // Reactive primitives (signal/computed) also go through the effect path
1229
+ // so the prop stays in sync — handled in the else branch below.
1230
+ if (!isReactive(accessor)){
1231
+ applyStaticProp(element, key, accessor)
1232
+ return
1233
+ }
1234
+ }
1235
+ let prevStyle
1236
+ createBindingEffect(()=> {
1237
+ const v = resolveReactiveValue(accessor)
1238
+ if (key === 'className' || key === 'class'){
1239
+ applyClassProp(element, v)
1240
+ return
1241
+ }
1242
+ if (key === 'style'){
1243
+ prevStyle = applyStyleProp(element, v, prevStyle)
1244
+ return
1245
+ }
1246
+ setDomProp(element, JSX_PROP_MAP[key] ?? key, v)
1247
+ })
1248
+ }
1249
+
963
1250
  // Render by appending the normalized root node into the target container.
964
1251
  // Returns a disposer function that cleans up all effects and listeners.
965
1252
  const renderApp = (container, node)=> {
@@ -995,6 +1282,13 @@ export {
995
1282
  jsx,
996
1283
  jsxDEV,
997
1284
  jsxs,
1285
+ jsxStatic,
1286
+ // DOM template cloning helpers (babel reactive transform emits these for
1287
+ // statically-shaped JSX subtrees — they let cloned templates skip the
1288
+ // per-element jsx()/applyProps machinery)
1289
+ template,
1290
+ insert,
1291
+ setProp,
998
1292
  onMount,
999
1293
  onUnmount,
1000
1294
  createContext,
@@ -218,11 +218,30 @@ const readOnlyTrap = ()=> {
218
218
  }
219
219
 
220
220
  const IS_MERGED_PROPS = Symbol('mergeProps')
221
+ const HAS_STATIC_KEYS = Symbol('mergeProps.staticKeys')
221
222
 
222
223
  export const mergeProps = (...sources)=> {
224
+ // Fast path: a single plain-object source is structurally equivalent to the
225
+ // object itself for prop access (no class/className aliasing across sources,
226
+ // no style merging, no spread thunks). Babel reactive transform wraps every
227
+ // JSX in `mergeProps({...})` even with no spreads, so this short-circuit
228
+ // eliminates one Proxy allocation per element on the typical render path.
229
+ if (sources.length === 1 && sources[0] != null && typeof sources[0] === 'object' && isPlainObject(sources[0])){
230
+ return sources[0]
231
+ }
232
+
233
+ // When no source is a function-typed spread (`{...api()}`), the key set is
234
+ // fixed at construction time — applyProps can skip its outer key-tracking
235
+ // effect entirely. This is the common case under the babel reactive transform
236
+ // which wraps every JSX in mergeProps even when there are no spreads.
237
+ let hasStaticKeys = true
238
+ for (let i = 0; i < sources.length; i += 1){
239
+ if (typeof sources[i] === 'function'){ hasStaticKeys = false; break }
240
+ }
223
241
  return new Proxy({}, {
224
242
  get: (_, key)=> {
225
243
  if (key === IS_MERGED_PROPS) return true
244
+ if (key === HAS_STATIC_KEYS) return hasStaticKeys
226
245
  return resolveKey(sources, key)
227
246
  },
228
247
  has: (_, key)=> hasKey(sources, key),
@@ -243,3 +262,4 @@ export const mergeProps = (...sources)=> {
243
262
  }
244
263
 
245
264
  export const isMergedProps = (value)=> value != null && typeof value === 'object' && value[IS_MERGED_PROPS] === true
265
+ export const hasMergedPropsStaticKeys = (value)=> value != null && typeof value === 'object' && value[HAS_STATIC_KEYS] === true
package/src/reactivity.js CHANGED
@@ -353,9 +353,19 @@ const tree = (obj)=> {
353
353
  // Internal binding effects/computations create their own active subscribers,
354
354
  // so suppressing the outer one here only affects bare signal reads in the
355
355
  // component body.
356
+ // Fast path: when no subscriber is currently active (the typical case during
357
+ // initial mount from `renderApp`, outside any enclosing effect), the
358
+ // swap-to-undefined and restore are both no-ops. Skip the try/finally and the
359
+ // swap entirely — this is invoked once per component, so eliminating the
360
+ // instrumentation here removes measurable per-component overhead during mount.
361
+ //
362
+ // Slow path: a single `setActiveSub(undefined)` swap returns the previous
363
+ // subscriber in one call, saving the separate `getActiveSub()` read.
356
364
  const runUntracked = (fn)=> {
357
- const prevSub = getActiveSub()
358
- setActiveSub(undefined)
365
+ if (getActiveSub() === undefined){
366
+ return fn()
367
+ }
368
+ const prevSub = setActiveSub(undefined)
359
369
  try {
360
370
  return fn()
361
371
  } finally {