@plastic-js/plastic 1.0.5 → 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 CHANGED
@@ -130,7 +130,15 @@ The Babel plugin rejects duplicate attribute names on the same JSX element at **
130
130
 
131
131
  ## JSX Compilation Paths
132
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.
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.
134
142
 
135
143
  ### 1. Template clone (pure static subtree)
136
144
 
@@ -222,6 +230,30 @@ count(1) // logs 1
222
230
 
223
231
  Passing an existing signal returns it unchanged — double-wrapping is a no-op.
224
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
+
225
257
  #### `createComputed(fn)`
226
258
 
227
259
  Creates a lazily-evaluated derived value. The computation re-runs only when its signal dependencies change.
@@ -318,14 +350,14 @@ toRaw(state) // { x: 1 }
318
350
 
319
351
  ### Installation
320
352
 
321
- 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 `babel-preset-plastic` rewrites those calls for fine-grained reactivity (control-flow lifting, `mergeProps`, etc.).
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.).
322
354
 
323
355
  ```bash
324
356
  npm install @plastic-js/plastic
325
357
  npm install --save-dev \
326
358
  @babel/core \
327
359
  @babel/preset-react \
328
- babel-preset-plastic \
360
+ @plastic-js/babel-preset-plastic \
329
361
  vite-plugin-babel
330
362
  ```
331
363
 
@@ -334,7 +366,7 @@ Then wire the presets up in `vite.config.js`:
334
366
  ```js
335
367
  import { defineConfig } from 'vite'
336
368
  import babel from 'vite-plugin-babel'
337
- import plasticJsx from 'babel-preset-plastic'
369
+ import plasticJsx from '@plastic-js/babel-preset-plastic'
338
370
 
339
371
  export default defineConfig({
340
372
  plugins: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plastic-js/plastic",
3
- "version": "1.0.5",
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.6",
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",
@@ -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
- const setup = ()=> {
755
- const bindings = []
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
- })
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
- for (const key of Reflect.ownKeys(props)){
765
- if (typeof key === 'symbol' || key === 'children' || key === 'key'){
766
- continue
767
- }
768
- if (key === 'classList'){
769
- throw new Error('classList prop is not supported. Use className instead.')
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
- bindings.push(()=> {
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
- bindings.push(bindReactiveEvent(element, props, key))
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
- bindings.push(bindReactiveProp(element, props, key, propsIsTracked))
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