@plastic-js/plastic 1.0.4 → 1.0.5

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/README.md CHANGED
@@ -24,7 +24,7 @@ Plastic enforces a strict one-way data flow contract: data travels **downward**
24
24
 
25
25
  ### Read-Only Props Proxy
26
26
 
27
- When a JSX element carries any attributes or children, the Babel plugin compiles all props into a single `mergeProps(...)` call. The result is a read-only Proxy — any attempt to write a prop throws immediately:
27
+ When a JSX element compiles to the general `jsx()` path (see [JSX Compilation Paths](#jsx-compilation-paths) — i.e. components, `<Dynamic>`, fragments, or intrinsics with a spread / non-literal attribute), the Babel plugin compiles all props into a single `mergeProps(...)` call. The result is a read-only Proxy — any attempt to write a prop throws immediately:
28
28
 
29
29
  ```js
30
30
  const Child = (props) => {
@@ -93,7 +93,7 @@ Plastic and Solid diverge significantly on how `class` / `className` is resolved
93
93
  - **Solid** has two distinct modes selected at compile time:
94
94
  - *Merging mode* (no spread present): static and dynamic class attributes are concatenated into a single space-separated string.
95
95
  - *Assignment mode* (any spread present): the compiler switches to sequential `element.className = value` assignment, so the **last** class-bearing prop or spread wins and any earlier class declarations are overwritten.
96
- - **Plastic** has only one mode: the Babel plugin hands every attribute — static, dynamic, and spread alike — to the runtime `mergeProps` unchanged, and `mergeProps` performs the merge. All three source types are concatenated additively, and each source's value may itself be either a string or an object (e.g. `{ foo: true, bar: isActive() }`); both forms are normalized and merged into the final class list.
96
+ - **Plastic** has only one mode. Whenever an element compiles to the `jsx()` path (which any spread forces it into — see [JSX Compilation Paths](#jsx-compilation-paths)), the Babel plugin hands every attribute — static, dynamic, and spread alike — to the runtime `mergeProps` unchanged, and `mergeProps` performs the merge. All three source types are concatenated additively, and each source's value may itself be either a string or an object (e.g. `{ foo: true, bar: isActive() }`); both forms are normalized and merged into the final class list.
97
97
 
98
98
  In short: introducing a spread in Solid can silently erase previously declared classes; in Plastic the same code keeps all contributions and combines them. This makes host components' class contributions safe under composition without the consumer having to know whether spreads are involved downstream.
99
99
 
@@ -128,6 +128,80 @@ The Babel plugin rejects duplicate attribute names on the same JSX element at **
128
128
  - **Mount/dispose API**: `renderApp(container, node)` returns an idempotent disposer that unmounts DOM and disposes owner/effect scopes.
129
129
  - **Lifecycle hooks**: `onMount` and cleanup registration (`onCleanup` wrapper) are available for component-level setup and teardown.
130
130
 
131
+ ## JSX Compilation Paths
132
+
133
+ The `babel-preset-plastic` reactive transform inspects every JSX element and lowers it to one of four runtime shapes. Paths are tried top-to-bottom; the first match wins. Earlier paths are strictly cheaper at runtime, so the transform always picks the most specialized form an element qualifies for.
134
+
135
+ ### 1. Template clone (pure static subtree)
136
+
137
+ **Trigger:** an entire subtree is "foldable" — intrinsic lowercase tags only, no spread attrs, no namespaced attr names, and **every attribute value and child (recursively) is a scalar literal**. No dynamic holes anywhere.
138
+
139
+ **Emitted code:**
140
+ ```js
141
+ const _tmpl$ = /*#__PURE__*/ template('<div class="row"><span>hello</span></div>')
142
+ // per render site:
143
+ _tmpl$.cloneNode(true)
144
+ ```
145
+
146
+ **Runtime cost per instance:** one native `cloneNode(true)`. The DOM skeleton is parsed once at module load via `innerHTML`; no `createElement`, no per-attribute writes, no per-child append.
147
+
148
+ ### 2. Template clone with holes
149
+
150
+ **Trigger:** foldable skeleton (same rules as path 1) but with **dynamic attribute values and/or dynamic children**. Static parts fold into the template string; dynamic parts become `setProp` / `insert` ops.
151
+
152
+ **Emitted code:**
153
+ ```js
154
+ const _tmpl$ = template('<div class="row"><span></span></div>')
155
+ // per render site:
156
+ (() => {
157
+ const _el = _tmpl$.cloneNode(true)
158
+ const _span = _el.firstChild
159
+ insert(_span, () => count())
160
+ return _el
161
+ })()
162
+ ```
163
+
164
+ **Runtime cost per instance:** `cloneNode(true)` + N hole patches. Skips `createElement` and `applyProps` for the static skeleton; only the dynamic holes pay reactive-wrapper cost.
165
+
166
+ ### 3. `jsxStatic` (single element, all-literal attrs)
167
+
168
+ **Trigger:** lowercase intrinsic tag, no spread, **every attribute value is a scalar literal** (string / number / boolean / null). Children are unconstrained — they pass through the normal `jsx` pipeline as the third argument, so dynamic / component / nested-JSX children are fine. Reached when path 2's whole-subtree foldability declined (typically because babel chose the single-call form for a standalone element).
169
+
170
+ **Emitted code:**
171
+ ```js
172
+ jsxStatic('div', { class: 'row' }, () => count())
173
+ ```
174
+
175
+ **Runtime cost per instance:** `createElement` + one direct DOM write per attribute (no `isReactive` check, no binding-effect wrapper, no `mergeProps` proxy). Children flow through `appendChild`'s usual reactive/defer paths.
176
+
177
+ ### 4. `jsx` / `mergeProps` (general path)
178
+
179
+ **Trigger:** everything else. In particular:
180
+ - Capitalized tag (`<Foo />`) → component invocation
181
+ - `JSXMemberExpression` (`<obj.Foo />`)
182
+ - `<Dynamic component={...} />`
183
+ - Fragment (`<>...</>`)
184
+ - Intrinsic tag with **any spread attribute**
185
+ - Intrinsic tag with no spread but at least one **non-literal attribute value** (and the subtree wasn't foldable for path 2)
186
+
187
+ **Emitted code:**
188
+ ```js
189
+ jsx(Tag, mergeProps({ class: 'row' }, () => ({ id: id() }), { children: ... }))
190
+ ```
191
+
192
+ **Runtime cost per instance:** full reactive dispatch — `mergeProps` proxy, per-prop `isReactive` / binding-effect wrapping, `applyProps` with `JSX_PROP_MAP` lookups, recursive child handling.
193
+
194
+ ### Summary
195
+
196
+ | # | Path | Tag | Spread allowed | Attr values | Children | Per-instance work |
197
+ |---|---|---|---|---|---|---|
198
+ | 1 | template (pure) | intrinsic | no | all literal | all literal (recursive) | `cloneNode` |
199
+ | 2 | template + holes | intrinsic | no | any | any | `cloneNode` + hole ops |
200
+ | 3 | `jsxStatic` | intrinsic | no | all scalar literal | any | `createElement` + direct attr writes |
201
+ | 4 | `jsx` / `mergeProps` | any | yes | any | any | full reactive dispatch |
202
+
203
+ Rule of thumb: **non-intrinsic tags always go through path 4**. The fast paths exist only because lowercase intrinsics map 1:1 to DOM elements and don't need component invocation or descriptor materialization.
204
+
131
205
  ## Reactivity
132
206
 
133
207
  Plastic's reactivity layer is built on top of [alien-signals](https://github.com/stackblitz/alien-signals) and extends it with deep object reactivity. All primitives are exported from `jsx`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plastic-js/plastic",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "main": "src/index.js",
5
5
  "access": "public",
6
6
  "sideEffects": false,
@@ -59,7 +59,7 @@
59
59
  "@testing-library/jest-dom": "^6.9.1",
60
60
  "@vitejs/plugin-react": "^6.0.2",
61
61
  "@vitejs/plugin-vue": "^6.0.7",
62
- "babel-preset-plastic": "^0.1.5",
62
+ "babel-preset-plastic": "^0.1.6",
63
63
  "eslint": "^9.39.2",
64
64
  "eslint-config-janus": "^9.0.21",
65
65
  "eslint-plugin-mocha": "^11.3.0",
@@ -1229,6 +1229,46 @@ const insert = (parent, accessor, marker = null)=> {
1229
1229
  // change. Class and style go through the existing helpers so the
1230
1230
  // merge/diff semantics stay identical to the JSX path.
1231
1231
  const setProp = (element, key, accessor)=> {
1232
+ // Event handlers (onClick, etc.) — the babel transform emits them as
1233
+ // `() => handler` thunks. Resolve once to the handler and attach a single
1234
+ // listener that re-reads the current accessor at dispatch time (so a signal
1235
+ // returning different handlers stays live without re-attaching).
1236
+ if (isEventProp(key)){
1237
+ const eventName = key.slice(2).toLowerCase()
1238
+ if (!isSupportedEvent(element, eventName)){
1239
+ return
1240
+ }
1241
+ // The babel transform wraps identifier accessors as `() => handler`
1242
+ // (a thunk that returns the real handler), but passes arrow/function
1243
+ // expressions through verbatim (those *are* the handler). Probe once
1244
+ // to figure out which shape we have, then dispatch accordingly.
1245
+ // Re-read each event so identifier-bound handlers stay live if the
1246
+ // outer binding swaps the reference.
1247
+ let useUnwrap = false
1248
+ if (typeof accessor === 'function'){
1249
+ try {
1250
+ const probe = accessor()
1251
+ if (typeof probe === 'function'){
1252
+ useUnwrap = true
1253
+ }
1254
+ } catch {
1255
+ // arrow that throws when called outside of an event — treat as handler
1256
+ }
1257
+ }
1258
+ const listener = (...args)=> {
1259
+ const handler = useUnwrap ? accessor() : accessor
1260
+ if (typeof handler === 'function'){
1261
+ handler(...args)
1262
+ }
1263
+ }
1264
+ element.addEventListener(eventName, listener)
1265
+ if (currentOwner || getCurrentComputation()){
1266
+ registerCleanup(()=> {
1267
+ element.removeEventListener(eventName, listener)
1268
+ })
1269
+ }
1270
+ return
1271
+ }
1232
1272
  if (typeof accessor !== 'function' || isReactivePrimitive(accessor)){
1233
1273
  // Reactive primitives (signal/computed) also go through the effect path
1234
1274
  // so the prop stays in sync — handled in the else branch below.