@plastic-js/plastic 1.0.4 → 1.0.6
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 +111 -5
- package/package.json +3 -2
- package/src/jsx-runtime.js +97 -30
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
|
|
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
|
|
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,88 @@ 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 `@plastic-js/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
|
+
The plugin's decision cascade in one line:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
if (foldable) { if (ops == 0) cloneNode else IIFE } else if (jsxStatic) ... else generic
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Paths 1 and 2 share the same `foldable` branch in the source — they differ only by whether the template has dynamic holes (`plan.ops.length`). Paths 3 and 4 are the remaining `else if` / `else` arms.
|
|
142
|
+
|
|
143
|
+
### 1. Template clone (pure static subtree)
|
|
144
|
+
|
|
145
|
+
**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.
|
|
146
|
+
|
|
147
|
+
**Emitted code:**
|
|
148
|
+
```js
|
|
149
|
+
const _tmpl$ = /*#__PURE__*/ template('<div class="row"><span>hello</span></div>')
|
|
150
|
+
// per render site:
|
|
151
|
+
_tmpl$.cloneNode(true)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**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.
|
|
155
|
+
|
|
156
|
+
### 2. Template clone with holes
|
|
157
|
+
|
|
158
|
+
**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.
|
|
159
|
+
|
|
160
|
+
**Emitted code:**
|
|
161
|
+
```js
|
|
162
|
+
const _tmpl$ = template('<div class="row"><span></span></div>')
|
|
163
|
+
// per render site:
|
|
164
|
+
(() => {
|
|
165
|
+
const _el = _tmpl$.cloneNode(true)
|
|
166
|
+
const _span = _el.firstChild
|
|
167
|
+
insert(_span, () => count())
|
|
168
|
+
return _el
|
|
169
|
+
})()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**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.
|
|
173
|
+
|
|
174
|
+
### 3. `jsxStatic` (single element, all-literal attrs)
|
|
175
|
+
|
|
176
|
+
**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).
|
|
177
|
+
|
|
178
|
+
**Emitted code:**
|
|
179
|
+
```js
|
|
180
|
+
jsxStatic('div', { class: 'row' }, () => count())
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**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.
|
|
184
|
+
|
|
185
|
+
### 4. `jsx` / `mergeProps` (general path)
|
|
186
|
+
|
|
187
|
+
**Trigger:** everything else. In particular:
|
|
188
|
+
- Capitalized tag (`<Foo />`) → component invocation
|
|
189
|
+
- `JSXMemberExpression` (`<obj.Foo />`)
|
|
190
|
+
- `<Dynamic component={...} />`
|
|
191
|
+
- Fragment (`<>...</>`)
|
|
192
|
+
- Intrinsic tag with **any spread attribute**
|
|
193
|
+
- Intrinsic tag with no spread but at least one **non-literal attribute value** (and the subtree wasn't foldable for path 2)
|
|
194
|
+
|
|
195
|
+
**Emitted code:**
|
|
196
|
+
```js
|
|
197
|
+
jsx(Tag, mergeProps({ class: 'row' }, () => ({ id: id() }), { children: ... }))
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Runtime cost per instance:** full reactive dispatch — `mergeProps` proxy, per-prop `isReactive` / binding-effect wrapping, `applyProps` with `JSX_PROP_MAP` lookups, recursive child handling.
|
|
201
|
+
|
|
202
|
+
### Summary
|
|
203
|
+
|
|
204
|
+
| # | Path | Tag | Spread allowed | Attr values | Children | Per-instance work |
|
|
205
|
+
|---|---|---|---|---|---|---|
|
|
206
|
+
| 1 | template (pure) | intrinsic | no | all literal | all literal (recursive) | `cloneNode` |
|
|
207
|
+
| 2 | template + holes | intrinsic | no | any | any | `cloneNode` + hole ops |
|
|
208
|
+
| 3 | `jsxStatic` | intrinsic | no | all scalar literal | any | `createElement` + direct attr writes |
|
|
209
|
+
| 4 | `jsx` / `mergeProps` | any | yes | any | any | full reactive dispatch |
|
|
210
|
+
|
|
211
|
+
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.
|
|
212
|
+
|
|
131
213
|
## Reactivity
|
|
132
214
|
|
|
133
215
|
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`.
|
|
@@ -148,6 +230,30 @@ count(1) // logs 1
|
|
|
148
230
|
|
|
149
231
|
Passing an existing signal returns it unchanged — double-wrapping is a no-op.
|
|
150
232
|
|
|
233
|
+
##### Signal as a JSX child: prefer `{count}` over `{count()}`
|
|
234
|
+
|
|
235
|
+
When a signal is used directly as a JSX child, the two forms below are **behaviorally equivalent** — both produce a reactive child that re-renders when the signal updates — but the bare-identifier form is the **recommended** style:
|
|
236
|
+
|
|
237
|
+
```jsx
|
|
238
|
+
const count = createSignal(0)
|
|
239
|
+
|
|
240
|
+
<span>{count}</span> // ✅ Recommended — Identifier passed through as-is (signal is itself a function)
|
|
241
|
+
<span>{count()}</span> // ⚠️ Discouraged — CallExpression that Babel must wrap as `() => count()`
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Why they behave the same:
|
|
245
|
+
|
|
246
|
+
- The `@plastic-js/babel-preset-plastic` reactive transform classifies identifiers as *static* and emits them unchanged, while call expressions are not static and get wrapped in a thunk (`() => count()`).
|
|
247
|
+
- At runtime, `appendChild` detects any child whose `typeof === 'function'` and routes it through the reactive child path — creating a placeholder, subscribing to signal reads, and patching the DOM on change.
|
|
248
|
+
|
|
249
|
+
Why prefer `{count}`:
|
|
250
|
+
|
|
251
|
+
- One fewer function call per update (the `{count()}` form invokes the thunk, which then invokes the signal).
|
|
252
|
+
- Slightly smaller compiled output (no synthesized `() => ...` wrapper).
|
|
253
|
+
- Makes the "signal flows through as a reactive citizen" model explicit at the call site, instead of looking like an eager read.
|
|
254
|
+
|
|
255
|
+
> This equivalence relies on `count` being a signal (a callable function). For a plain variable (`const count = 2`), `{count}` is a static value and `{count()}` would throw — they are *not* interchangeable in that case.
|
|
256
|
+
|
|
151
257
|
#### `createComputed(fn)`
|
|
152
258
|
|
|
153
259
|
Creates a lazily-evaluated derived value. The computation re-runs only when its signal dependencies change.
|
|
@@ -244,14 +350,14 @@ toRaw(state) // { x: 1 }
|
|
|
244
350
|
|
|
245
351
|
### Installation
|
|
246
352
|
|
|
247
|
-
Install Plastic together with its Babel toolchain. Plastic's JSX compiles in two stages: `@babel/preset-react` turns JSX into `jsx(...)` calls against Plastic's runtime, then
|
|
353
|
+
Install Plastic together with its Babel toolchain. Plastic's JSX compiles in two stages: `@babel/preset-react` turns JSX into `jsx(...)` calls against Plastic's runtime, then `@plastic-js/babel-preset-plastic` rewrites those calls for fine-grained reactivity (control-flow lifting, `mergeProps`, etc.).
|
|
248
354
|
|
|
249
355
|
```bash
|
|
250
356
|
npm install @plastic-js/plastic
|
|
251
357
|
npm install --save-dev \
|
|
252
358
|
@babel/core \
|
|
253
359
|
@babel/preset-react \
|
|
254
|
-
babel-preset-plastic \
|
|
360
|
+
@plastic-js/babel-preset-plastic \
|
|
255
361
|
vite-plugin-babel
|
|
256
362
|
```
|
|
257
363
|
|
|
@@ -260,7 +366,7 @@ Then wire the presets up in `vite.config.js`:
|
|
|
260
366
|
```js
|
|
261
367
|
import { defineConfig } from 'vite'
|
|
262
368
|
import babel from 'vite-plugin-babel'
|
|
263
|
-
import plasticJsx from 'babel-preset-plastic'
|
|
369
|
+
import plasticJsx from '@plastic-js/babel-preset-plastic'
|
|
264
370
|
|
|
265
371
|
export default defineConfig({
|
|
266
372
|
plugins: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plastic-js/plastic",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"main": "src/index.js",
|
|
5
5
|
"access": "public",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"homepage": "https://github.com/plastic-js/plastic",
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@emotion/css": "^11.13.5",
|
|
54
55
|
"alien-signals": "^3.2.1"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"@testing-library/jest-dom": "^6.9.1",
|
|
60
61
|
"@vitejs/plugin-react": "^6.0.2",
|
|
61
62
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
62
|
-
"babel-preset-plastic": "^0.1.
|
|
63
|
+
"@plastic-js/babel-preset-plastic": "^0.1.7",
|
|
63
64
|
"eslint": "^9.39.2",
|
|
64
65
|
"eslint-config-janus": "^9.0.21",
|
|
65
66
|
"eslint-plugin-mocha": "^11.3.0",
|
package/src/jsx-runtime.js
CHANGED
|
@@ -624,15 +624,6 @@ const applyRefProp = (element, ref)=> {
|
|
|
624
624
|
}
|
|
625
625
|
}
|
|
626
626
|
|
|
627
|
-
const disposeBindings = (bindings)=> {
|
|
628
|
-
;[...bindings].reverse().forEach((dispose)=> {
|
|
629
|
-
if (typeof dispose === 'function'){
|
|
630
|
-
dispose()
|
|
631
|
-
}
|
|
632
|
-
})
|
|
633
|
-
bindings.length = 0
|
|
634
|
-
}
|
|
635
|
-
|
|
636
627
|
// Bind a single non-event, non-special prop key to the DOM element. Each
|
|
637
628
|
// individual prop runs inside its own binding effect so a signal change in
|
|
638
629
|
// one prop's getter only re-writes that one attribute. For plain (non-proxy)
|
|
@@ -751,41 +742,77 @@ const bindReactiveEvent = (element, props, key)=> {
|
|
|
751
742
|
// current key set.
|
|
752
743
|
const applyProps = (element, props = {})=> {
|
|
753
744
|
const propsIsTracked = isMergedProps(props) || isTree(props)
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
745
|
+
// Per-key disposer map. Re-runs of the outer key-tracking effect diff the
|
|
746
|
+
// previous and current key set so only added/removed keys touch the DOM;
|
|
747
|
+
// surviving keys keep their per-prop binding (which already short-circuits
|
|
748
|
+
// on prev===next inside setDomProp). Tearing down all bindings on every
|
|
749
|
+
// signal change was the cause of NO-OP attribute thrash on Zag widgets.
|
|
750
|
+
const bindings = new Map()
|
|
751
|
+
|
|
752
|
+
const createBindingForKey = (key)=> {
|
|
753
|
+
if (key === 'classList'){
|
|
754
|
+
throw new Error('classList prop is not supported. Use className instead.')
|
|
762
755
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
756
|
+
// Suppress the outer effect's computation context so inner
|
|
757
|
+
// registerCleanup calls (event listeners, ref nulling) attach to the
|
|
758
|
+
// owner rather than the outer effect's per-run cleanup list. Otherwise
|
|
759
|
+
// flushCleanups on the next outer re-run would tear down listeners for
|
|
760
|
+
// keys we intend to keep.
|
|
761
|
+
const prevComp = getCurrentComputation()
|
|
762
|
+
setCurrentComputation(null)
|
|
763
|
+
try {
|
|
771
764
|
if (key === 'ref'){
|
|
772
765
|
const ref = props[key]
|
|
773
766
|
applyRefProp(element, ref)
|
|
774
|
-
|
|
767
|
+
return ()=> {
|
|
775
768
|
if (typeof ref === 'function'){
|
|
776
769
|
ref(null)
|
|
777
770
|
}
|
|
778
|
-
}
|
|
779
|
-
continue
|
|
771
|
+
}
|
|
780
772
|
}
|
|
781
773
|
if (isEventProp(key)){
|
|
782
|
-
|
|
774
|
+
return bindReactiveEvent(element, props, key)
|
|
775
|
+
}
|
|
776
|
+
return bindReactiveProp(element, props, key, propsIsTracked)
|
|
777
|
+
} finally {
|
|
778
|
+
setCurrentComputation(prevComp)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const setup = ()=> {
|
|
783
|
+
const nextKeys = new Set()
|
|
784
|
+
for (const key of Reflect.ownKeys(props)){
|
|
785
|
+
if (typeof key === 'symbol' || key === 'children' || key === 'key'){
|
|
783
786
|
continue
|
|
784
787
|
}
|
|
785
|
-
|
|
788
|
+
nextKeys.add(key)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Dispose bindings for keys that disappeared. Surviving keys are left
|
|
792
|
+
// alone — their inner binding effects already react to value changes.
|
|
793
|
+
for (const [key, dispose] of bindings){
|
|
794
|
+
if (nextKeys.has(key)) continue
|
|
795
|
+
if (typeof dispose === 'function') dispose()
|
|
796
|
+
bindings.delete(key)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
for (const key of nextKeys){
|
|
800
|
+
if (bindings.has(key)) continue
|
|
801
|
+
bindings.set(key, createBindingForKey(key))
|
|
786
802
|
}
|
|
787
803
|
}
|
|
788
804
|
|
|
805
|
+
// Register full teardown at the owner level (not the outer effect's local
|
|
806
|
+
// cleanup list) so it fires only on real unmount.
|
|
807
|
+
if (currentOwner || getCurrentComputation()){
|
|
808
|
+
registerCleanup(()=> {
|
|
809
|
+
for (const dispose of bindings.values()){
|
|
810
|
+
if (typeof dispose === 'function') dispose()
|
|
811
|
+
}
|
|
812
|
+
bindings.clear()
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
789
816
|
// Only wrap in an outer binding effect when the proxy has dynamic keys (a
|
|
790
817
|
// function-typed spread source like `{...api()}`, or any tree proxy whose
|
|
791
818
|
// key set may grow/shrink at runtime). For static-key proxies (the common
|
|
@@ -1229,6 +1256,46 @@ const insert = (parent, accessor, marker = null)=> {
|
|
|
1229
1256
|
// change. Class and style go through the existing helpers so the
|
|
1230
1257
|
// merge/diff semantics stay identical to the JSX path.
|
|
1231
1258
|
const setProp = (element, key, accessor)=> {
|
|
1259
|
+
// Event handlers (onClick, etc.) — the babel transform emits them as
|
|
1260
|
+
// `() => handler` thunks. Resolve once to the handler and attach a single
|
|
1261
|
+
// listener that re-reads the current accessor at dispatch time (so a signal
|
|
1262
|
+
// returning different handlers stays live without re-attaching).
|
|
1263
|
+
if (isEventProp(key)){
|
|
1264
|
+
const eventName = key.slice(2).toLowerCase()
|
|
1265
|
+
if (!isSupportedEvent(element, eventName)){
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
// The babel transform wraps identifier accessors as `() => handler`
|
|
1269
|
+
// (a thunk that returns the real handler), but passes arrow/function
|
|
1270
|
+
// expressions through verbatim (those *are* the handler). Probe once
|
|
1271
|
+
// to figure out which shape we have, then dispatch accordingly.
|
|
1272
|
+
// Re-read each event so identifier-bound handlers stay live if the
|
|
1273
|
+
// outer binding swaps the reference.
|
|
1274
|
+
let useUnwrap = false
|
|
1275
|
+
if (typeof accessor === 'function'){
|
|
1276
|
+
try {
|
|
1277
|
+
const probe = accessor()
|
|
1278
|
+
if (typeof probe === 'function'){
|
|
1279
|
+
useUnwrap = true
|
|
1280
|
+
}
|
|
1281
|
+
} catch {
|
|
1282
|
+
// arrow that throws when called outside of an event — treat as handler
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
const listener = (...args)=> {
|
|
1286
|
+
const handler = useUnwrap ? accessor() : accessor
|
|
1287
|
+
if (typeof handler === 'function'){
|
|
1288
|
+
handler(...args)
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
element.addEventListener(eventName, listener)
|
|
1292
|
+
if (currentOwner || getCurrentComputation()){
|
|
1293
|
+
registerCleanup(()=> {
|
|
1294
|
+
element.removeEventListener(eventName, listener)
|
|
1295
|
+
})
|
|
1296
|
+
}
|
|
1297
|
+
return
|
|
1298
|
+
}
|
|
1232
1299
|
if (typeof accessor !== 'function' || isReactivePrimitive(accessor)){
|
|
1233
1300
|
// Reactive primitives (signal/computed) also go through the effect path
|
|
1234
1301
|
// so the prop stays in sync — handled in the else branch below.
|