@plastic-js/plastic 1.0.2 → 1.0.4

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.2",
3
+ "version": "1.0.4",
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.1",
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",
@@ -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
  }
@@ -622,7 +640,45 @@ const disposeBindings = (bindings)=> {
622
640
  // free. The value is resolved through `resolveReactiveValue` so signals and
623
641
  // zero-arg accessor thunks (used widely by ark-plastic / zag adapters) are
624
642
  // unwrapped before being applied to the DOM.
625
- const bindReactiveProp = (element, props, key)=> {
643
+ const bindReactiveProp = (element, props, key, propsIsTracked)=> {
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
+ // Skip the fast path when `props` is itself a tracking proxy (tree /
651
+ // mergeProps): the value may be a plain string but reading `props[key]`
652
+ // subscribes through the proxy, so we need an effect to re-run on change.
653
+ if (!propsIsTracked && !isReactive(rawValue)){
654
+ let prevStyleValue
655
+ if (key === 'className' || key === 'class'){
656
+ applyClassProp(element, rawValue)
657
+ } else if (key === 'style'){
658
+ prevStyleValue = applyStyleProp(element, rawValue, undefined)
659
+ } else {
660
+ const domKey = JSX_PROP_MAP[key] ?? key
661
+ setDomProp(element, domKey, rawValue)
662
+ }
663
+
664
+ return ()=> {
665
+ if (key === 'className' || key === 'class'){
666
+ element.removeAttribute('class')
667
+ return
668
+ }
669
+ if (key === 'style'){
670
+ if (prevStyleValue && typeof prevStyleValue === 'object'){
671
+ Object.keys(prevStyleValue).forEach(prop=> clearStyleKey(element, prop))
672
+ } else if (typeof prevStyleValue === 'string'){
673
+ element.style.cssText = ''
674
+ }
675
+ return
676
+ }
677
+ const domKey = JSX_PROP_MAP[key] ?? key
678
+ clearDomProp(element, domKey)
679
+ }
680
+ }
681
+
626
682
  let prevStyleValue
627
683
  const stop = createBindingEffect(()=> {
628
684
  const value = resolveReactiveValue(props[key])
@@ -694,11 +750,16 @@ const bindReactiveEvent = (element, props, key)=> {
694
750
  // keys later we tear down the previous bindings and rebuild them from the
695
751
  // current key set.
696
752
  const applyProps = (element, props = {})=> {
697
- createBindingEffect(()=> {
753
+ const propsIsTracked = isMergedProps(props) || isTree(props)
754
+ const setup = ()=> {
698
755
  const bindings = []
699
- registerCleanup(()=> {
700
- disposeBindings(bindings)
701
- })
756
+ // Only register cleanup if there's an owner/computation to attach to.
757
+ // Allows top-level h() calls outside any component scope (e.g. tests).
758
+ if (currentOwner || getCurrentComputation()){
759
+ registerCleanup(()=> {
760
+ disposeBindings(bindings)
761
+ })
762
+ }
702
763
 
703
764
  for (const key of Reflect.ownKeys(props)){
704
765
  if (typeof key === 'symbol' || key === 'children' || key === 'key'){
@@ -721,42 +782,71 @@ const applyProps = (element, props = {})=> {
721
782
  bindings.push(bindReactiveEvent(element, props, key))
722
783
  continue
723
784
  }
724
- bindings.push(bindReactiveProp(element, props, key))
785
+ bindings.push(bindReactiveProp(element, props, key, propsIsTracked))
725
786
  }
726
- })
787
+ }
788
+
789
+ // Only wrap in an outer binding effect when the proxy has dynamic keys (a
790
+ // function-typed spread source like `{...api()}`, or any tree proxy whose
791
+ // key set may grow/shrink at runtime). For static-key proxies (the common
792
+ // babel reactive transform output with no spreads), and for plain object
793
+ // props, keys never change — skip the outer effect to avoid one
794
+ // owner-effect allocation per element.
795
+ if ((isMergedProps(props) && !hasMergedPropsStaticKeys(props)) || isTree(props)){
796
+ createBindingEffect(setup)
797
+ } else {
798
+ setup()
799
+ }
727
800
  return element
728
801
  }
729
802
 
730
803
  // Normalize any JSX return value into a DOM node that can be appended safely.
804
+ // Dispatch is organized as a `typeof` switch so the engine can lower it to a
805
+ // jump table, and the most frequent shapes are handled first:
806
+ // - 'string'/'number': text children like `["item ", i]` (hottest in lists)
807
+ // - 'object' → Node: the Element returned from a component body
808
+ // - 'object' → descriptor: child components in an array
809
+ // Within the 'object' arm we use `node.nodeType != null` instead of
810
+ // `instanceof Node`, since a property read is cheaper than walking the
811
+ // prototype chain, and we read the descriptor symbol directly to skip the
812
+ // redundant `typeof === 'object'` check inside isComponentDescriptor.
731
813
  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
814
+ switch (typeof node){
815
+ case 'string':
816
+ case 'number':
817
+ return document.createTextNode(String(node))
818
+ case 'function':
819
+ // Signals and computeds are functions too (alien-signals binds an
820
+ // operator function) — they render as reactive text nodes, while a
821
+ // plain function child is a thunk that returns dynamic content.
822
+ if (isReactivePrimitive(node)) return createReactiveTextNode(node)
823
+ return createReactiveChildNode(node)
824
+ case 'undefined':
825
+ return createPlaceholder()
826
+ case 'object': {
827
+ if (node === null) return createPlaceholder()
828
+ // Real DOM node returned from h()/jsx() — by far the most common
829
+ // 'object' case during initial mount.
830
+ if (node.nodeType != null){
831
+ flushPendingDescriptors(node)
832
+ return node
833
+ }
834
+ if (node[COMPONENT_DESCRIPTOR] === true){
835
+ return materializeComponentDescriptor(node)
836
+ }
837
+ if (Array.isArray(node)){
838
+ const fragment = document.createDocumentFragment()
839
+ appendChildren(fragment, node)
840
+ // Do NOT flush here — the caller (appendChild) will transfer any pending
841
+ // descriptors to the real parent element before draining the fragment, so
842
+ // flushPendingDescriptors runs later with the correct owner active.
843
+ return fragment
844
+ }
845
+ return createPlaceholder()
846
+ }
847
+ default:
848
+ return createPlaceholder()
758
849
  }
759
- return createPlaceholder()
760
850
  }
761
851
 
762
852
  const materializeNode = node=> node2Element(node)
@@ -956,10 +1046,212 @@ const jsx = (tag, props, key)=> {
956
1046
  return h(tag, mergeProps(props, { key }))
957
1047
  }
958
1048
  const jsxs = jsx
1049
+
1050
+ // Static fast path counterpart to applyProps: writes a single literal value
1051
+ // straight to the DOM with no isReactive check, no binding-effect wrapper, and
1052
+ // no JSX_PROP_MAP miss for the hot keys. Used exclusively by jsxStatic, which
1053
+ // the babel reactive transform emits when every attribute value is provably a
1054
+ // scalar literal (string/number/boolean/null) — so there is nothing to track,
1055
+ // nothing to dispose, and the prop never changes after initial mount.
1056
+ const applyStaticProp = (element, key, value)=> {
1057
+ if (key === 'className' || key === 'class'){
1058
+ applyClassProp(element, value)
1059
+ return
1060
+ }
1061
+ if (key === 'style'){
1062
+ applyStyleProp(element, value, undefined)
1063
+ return
1064
+ }
1065
+ setDomProp(element, JSX_PROP_MAP[key] ?? key, value)
1066
+ }
1067
+
1068
+ // jsxStatic — emitted by the babel reactive transform when ALL of these hold:
1069
+ // - tag is a lowercase intrinsic string (no components, no Dynamic)
1070
+ // - at least one attribute, none of them spread
1071
+ // - every attribute value is a scalar literal (string/number/boolean/null)
1072
+ //
1073
+ // Under those conditions there are no reactive props, no event handlers, no
1074
+ // refs, no merged-props proxy — applyProps' entire dispatch machinery
1075
+ // (createBindingEffect / isMergedProps / Reflect.ownKeys / bindReactiveProp /
1076
+ // isReactive) is pure overhead. We bypass it and write each prop directly.
1077
+ // Children pass through unchanged: babel emits the same child expressions it
1078
+ // would for jsx(), so dynamic children still arrive as thunks/descriptors and
1079
+ // are routed through appendChild's existing reactive/defer paths.
1080
+ const jsxStatic = (tag, props, child)=> {
1081
+ const element = SVG_TAGS.has(tag)
1082
+ ? document.createElementNS('http://www.w3.org/2000/svg', tag)
1083
+ : document.createElement(tag)
1084
+ for (const key in props){
1085
+ // Defensive: skip framework-reserved keys in case the babel transform
1086
+ // ever emits them here. `children` shouldn't appear (it's the third
1087
+ // arg), and `key` is consumed by the reconciler upstream.
1088
+ if (key === 'children' || key === 'key') continue
1089
+ applyStaticProp(element, key, props[key])
1090
+ }
1091
+ if (child !== undefined){
1092
+ // Babel emits the third arg as a single value when there is one child
1093
+ // and an array when there are several — mirror jsx() / jsxs() shape.
1094
+ if (Array.isArray(child)){
1095
+ appendChildren(element, child)
1096
+ } else {
1097
+ appendChild(element, child)
1098
+ }
1099
+ }
1100
+ return element
1101
+ }
959
1102
  // jsxDEV is the development-mode variant used by automatic JSX transforms; the extra
960
1103
  // debug arguments (isStaticChildren, source, self) are unused at runtime.
961
1104
  const jsxDEV = (tag, props, key) => jsx(tag, props, key)
962
1105
 
1106
+ // ============ DOM template cloning (Solid-style) ============
1107
+ // Three helpers the babel reactive transform can emit when it identifies a
1108
+ // statically-shaped JSX subtree. They let the framework skip per-element
1109
+ // createElement/applyProps/appendChild for the static skeleton and only do
1110
+ // runtime work for the dynamic holes that babel can't fold into the template.
1111
+ //
1112
+ // Emission shape from babel:
1113
+ // const _tmpl$ = /*#__PURE__*/ template('<div class="row"><span>item </span></div>')
1114
+ // const Row = ({i}) => {
1115
+ // const _el = _tmpl$.cloneNode(true)
1116
+ // const _span = _el.firstChild
1117
+ // insert(_span, () => i, null) // dynamic text appended at end of span
1118
+ // return _el
1119
+ // }
1120
+ // Static props go into the template string itself; dynamic props use setProp;
1121
+ // dynamic children use insert. There is no separate clone helper — babel emits
1122
+ // `_tmpl$.cloneNode(true)` directly.
1123
+
1124
+ // Parse `html` into a detached template and return the first child of its
1125
+ // content. Babel emits ONE module-level `template()` call per unique static
1126
+ // shape, so the parse cost is amortized across every instance — only the
1127
+ // per-instance `cloneNode(true)` remains hot.
1128
+ //
1129
+ // SVG note: an `<svg>` element parsed via innerHTML of an HTMLTemplateElement
1130
+ // correctly inherits the SVG namespace, but standalone SVG children (`<circle>`
1131
+ // at the root) do not — pass isSVG=true to wrap-then-unwrap so the namespace
1132
+ // resolves on every node.
1133
+ const template = (html, isSVG = false)=> {
1134
+ const t = document.createElement('template')
1135
+ if (isSVG){
1136
+ t.innerHTML = `<svg>${html}</svg>`
1137
+ const svgRoot = t.content.firstChild
1138
+ // Unwrap so the caller's first cloneNode points at the real root.
1139
+ return svgRoot.firstChild ?? svgRoot
1140
+ }
1141
+ t.innerHTML = html
1142
+ return t.content.firstChild
1143
+ }
1144
+
1145
+ // Recursively resolve `value` (which may be a thunk, signal, descriptor, node,
1146
+ // primitive, or array) into a flat list of Nodes ready to insert. Mirrors what
1147
+ // node2Element does but flattens arrays in place instead of producing a
1148
+ // DocumentFragment, since insert() needs direct control over each Node so the
1149
+ // next reactive run can remove exactly the nodes it inserted.
1150
+ const resolveChildToNodes = (value, out)=> {
1151
+ let v = value
1152
+ // Walk through plain thunks (NOT signals/computeds — those resolve to a
1153
+ // reactive text node so the framework can update .data in place).
1154
+ let steps = 0
1155
+ while (typeof v === 'function' && !isReactivePrimitive(v) && steps < MAX_REACTIVE_RESOLVE_STEPS){
1156
+ v = v()
1157
+ steps += 1
1158
+ }
1159
+ if (v == null || v === true || v === false) return
1160
+ if (v instanceof Node){
1161
+ out.push(v)
1162
+ return
1163
+ }
1164
+ if (Array.isArray(v)){
1165
+ for (const item of v) resolveChildToNodes(item, out)
1166
+ return
1167
+ }
1168
+ if (isComponentDescriptor(v)){
1169
+ out.push(materializeComponentDescriptor(v))
1170
+ return
1171
+ }
1172
+ if (isReactivePrimitive(v)){
1173
+ out.push(createReactiveTextNode(v))
1174
+ return
1175
+ }
1176
+ out.push(document.createTextNode(String(v)))
1177
+ }
1178
+
1179
+ // Insert dynamic content into `parent` immediately before `marker` (or at the
1180
+ // end when marker is null). Static accessors run once; functions/signals wrap
1181
+ // in a binding effect so the slot updates whenever the source changes.
1182
+ //
1183
+ // Single-text-node fast path: when both the previous and next render produce a
1184
+ // single text/string/number, mutate the existing text node's `.data` instead
1185
+ // of swapping nodes. This matches Solid's behavior and avoids DOM thrash for
1186
+ // the common `{count()}` shape.
1187
+ const insert = (parent, accessor, marker = null)=> {
1188
+ if (typeof accessor !== 'function'){
1189
+ const nodes = []
1190
+ resolveChildToNodes(accessor, nodes)
1191
+ for (const n of nodes) parent.insertBefore(n, marker)
1192
+ return
1193
+ }
1194
+ let current = []
1195
+ createBindingEffect(()=> {
1196
+ const next = accessor()
1197
+
1198
+ // Fast path: single existing text node + scalar next value → in-place data update.
1199
+ if (current.length === 1 && current[0].nodeType === 3
1200
+ && (typeof next === 'string' || typeof next === 'number')){
1201
+ const str = String(next)
1202
+ if (current[0].data !== str) current[0].data = str
1203
+ return
1204
+ }
1205
+
1206
+ const nodes = []
1207
+ resolveChildToNodes(next, nodes)
1208
+
1209
+ // Naive diff: remove current, insert next. Adequate for single-slot
1210
+ // inserts; keyed list reconciliation is handled separately by <Loop>.
1211
+ for (const n of current){
1212
+ if (n.parentNode === parent) parent.removeChild(n)
1213
+ }
1214
+ for (const n of nodes) parent.insertBefore(n, marker)
1215
+
1216
+ // Mount any owners attached to inserted subtrees (component roots inside
1217
+ // the inserted nodes register their owner on the DOM node — runOwnerMounts
1218
+ // must fire once they're connected).
1219
+ if (parent.isConnected){
1220
+ for (const n of nodes) mountOwnedSubtree(n)
1221
+ }
1222
+
1223
+ current = nodes
1224
+ })
1225
+ }
1226
+
1227
+ // Apply a single prop (static or reactive) to an element. Static values write
1228
+ // once; function/signal values wrap in a binding effect that re-applies on
1229
+ // change. Class and style go through the existing helpers so the
1230
+ // merge/diff semantics stay identical to the JSX path.
1231
+ const setProp = (element, key, accessor)=> {
1232
+ if (typeof accessor !== 'function' || isReactivePrimitive(accessor)){
1233
+ // Reactive primitives (signal/computed) also go through the effect path
1234
+ // so the prop stays in sync — handled in the else branch below.
1235
+ if (!isReactive(accessor)){
1236
+ applyStaticProp(element, key, accessor)
1237
+ return
1238
+ }
1239
+ }
1240
+ let prevStyle
1241
+ createBindingEffect(()=> {
1242
+ const v = resolveReactiveValue(accessor)
1243
+ if (key === 'className' || key === 'class'){
1244
+ applyClassProp(element, v)
1245
+ return
1246
+ }
1247
+ if (key === 'style'){
1248
+ prevStyle = applyStyleProp(element, v, prevStyle)
1249
+ return
1250
+ }
1251
+ setDomProp(element, JSX_PROP_MAP[key] ?? key, v)
1252
+ })
1253
+ }
1254
+
963
1255
  // Render by appending the normalized root node into the target container.
964
1256
  // Returns a disposer function that cleans up all effects and listeners.
965
1257
  const renderApp = (container, node)=> {
@@ -995,6 +1287,13 @@ export {
995
1287
  jsx,
996
1288
  jsxDEV,
997
1289
  jsxs,
1290
+ jsxStatic,
1291
+ // DOM template cloning helpers (babel reactive transform emits these for
1292
+ // statically-shaped JSX subtrees — they let cloned templates skip the
1293
+ // per-element jsx()/applyProps machinery)
1294
+ template,
1295
+ insert,
1296
+ setProp,
998
1297
  onMount,
999
1298
  onUnmount,
1000
1299
  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 {