@plastic-js/plastic 1.0.1
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/LICENSE +21 -0
- package/README.md +442 -0
- package/package.json +78 -0
- package/src/computation-context.js +11 -0
- package/src/control-flow.js +367 -0
- package/src/index.js +87 -0
- package/src/jsx-runtime.js +1058 -0
- package/src/merge-props.js +245 -0
- package/src/reactivity.js +408 -0
- package/src/router.js +919 -0
- package/src/split-props.js +42 -0
- package/src/utils.js +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 plas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# Plastic
|
|
2
|
+
|
|
3
|
+
A lightweight custom JSX runtime that works as a web front-end framework. Inspired by the principles of [Solid.js](https://www.solidjs.com/), Plastic skips the Virtual DOM entirely and instead creates real DOM nodes directly, with fine-grained reactivity driven by [alien-signals](https://github.com/stackblitz/alien-signals).
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
- **Client-side only (CSR)** — Plastic is designed for browser runtime usage and does **not** include or plan Server-Side Rendering (SSR) support.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **No Virtual DOM** — JSX compiles directly to DOM creation calls; no diffing, no reconciliation overhead.
|
|
12
|
+
- **Fine-grained reactivity** — powered by `alien-signals` (`signal`, `computed`, `effect`).
|
|
13
|
+
- **Familiar JSX syntax** — drop-in JSX transform compatible with Vite and Babel.
|
|
14
|
+
- **Event binding** — `onXxx` props map to `addEventListener` automatically.
|
|
15
|
+
- **Style objects** — pass a plain object to the `style` prop, including CSS custom properties.
|
|
16
|
+
- **Fragment support** — return multiple root nodes without a wrapper element.
|
|
17
|
+
- **`<Either>` conditional rendering** — lazily renders only the active branch (`<True>`/`<False>`) via a comment-node anchor; inactive branches are never evaluated until the condition flips.
|
|
18
|
+
- **`<Loop>` list rendering** — reconciles lists by object identity; reuses, moves, and disposes item rows with fine-grained owner tracking.
|
|
19
|
+
- **Client-side routing** — `<Router>`, `<Route>`, `<Link>`, `<NavLink>`, `navigate()`, `<Outlet>`, and `useRoute()` use the History API for nested routing with params and query awareness.
|
|
20
|
+
|
|
21
|
+
## Props Model and One-Way Data Flow
|
|
22
|
+
|
|
23
|
+
Plastic enforces a strict one-way data flow contract: data travels **downward** from parent to child through props, and upward only through explicit callbacks or shared reactive state. A child component must never mutate its own props.
|
|
24
|
+
|
|
25
|
+
### Read-Only Props Proxy
|
|
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:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const Child = (props) => {
|
|
31
|
+
props.label = 'override' // Error: mergeProps result is read-only
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This turns a common accidental mistake into an explicit runtime error instead of a silent data corruption.
|
|
36
|
+
|
|
37
|
+
### How Reactive Props Work
|
|
38
|
+
|
|
39
|
+
Dynamic attribute expressions are compiled into getter properties so that reading a prop inside a reactive binding effect automatically subscribes to its signal dependencies:
|
|
40
|
+
|
|
41
|
+
```jsx
|
|
42
|
+
// Source
|
|
43
|
+
<MyComp foo={2} bar={state.b}>{kid}</MyComp>
|
|
44
|
+
|
|
45
|
+
// Compiled
|
|
46
|
+
jsx(MyComp, mergeProps({
|
|
47
|
+
foo: 2,
|
|
48
|
+
get bar() { return state.b },
|
|
49
|
+
get children() { return kid },
|
|
50
|
+
}))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Reading `props.bar` inside an effect invokes the getter, which reads `state.b` and registers a subscription. When `state.b` changes, only the binding that reads `props.bar` re-executes — no component re-render, no diffing.
|
|
54
|
+
|
|
55
|
+
### Dynamic Spread Sources
|
|
56
|
+
|
|
57
|
+
Spread attributes whose value is a dynamic expression (a call, a member access, etc.) are wrapped in a thunk argument to `mergeProps` so that the spread source is re-evaluated lazily on each reactive read:
|
|
58
|
+
|
|
59
|
+
```jsx
|
|
60
|
+
// Source
|
|
61
|
+
<MyComp {...api()} foo={2} bar={state.b} />
|
|
62
|
+
|
|
63
|
+
// Compiled
|
|
64
|
+
jsx(MyComp, mergeProps(
|
|
65
|
+
() => api(),
|
|
66
|
+
{ foo: 2, get bar() { return state.b } },
|
|
67
|
+
))
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Signal reads inside `api()` are tracked by the same binding effect that reads the resulting prop, so changes inside `api()` automatically propagate to the DOM without any extra wiring.
|
|
71
|
+
|
|
72
|
+
### `mergeProps` vs. Solid's Implementation
|
|
73
|
+
|
|
74
|
+
Plastic's `mergeProps` shares the same surface API as Solid's but differs in three important ways:
|
|
75
|
+
|
|
76
|
+
- **Always a Proxy.** Solid returns a plain `{}` for static plain-object sources to avoid Proxy overhead. Plastic always returns a Proxy.
|
|
77
|
+
- **Thunk-based reactivity, not `createMemo`.** Solid wraps function sources in `createMemo` during initialisation so they are only re-executed when their signals change. Plastic calls the function on every property access; signal tracking still works, but there is no memoisation between reads.
|
|
78
|
+
- **Special-key merging.** Plastic adds merging semantics for four key families that Solid does not have:
|
|
79
|
+
|
|
80
|
+
| Key | Plastic behaviour |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `class` / `className` | treated as one family; all string values concatenated with a space |
|
|
83
|
+
| `style` | plain objects shallow-merged; strings joined with `; ` |
|
|
84
|
+
| `ref` | last source wins (matches Solid) |
|
|
85
|
+
| `onXxx` event handlers | last source wins (matches Solid) |
|
|
86
|
+
|
|
87
|
+
`class` and `style` receive additive merging so host components can contribute tokens and styles alongside the consumer's without clobbering. `ref` and `onXxx` follow Solid's last-wins rule.
|
|
88
|
+
|
|
89
|
+
#### `class` Merging: Plastic vs. Solid
|
|
90
|
+
|
|
91
|
+
Plastic and Solid diverge significantly on how `class` / `className` is resolved when multiple sources are present:
|
|
92
|
+
|
|
93
|
+
- **Solid** has two distinct modes selected at compile time:
|
|
94
|
+
- *Merging mode* (no spread present): static and dynamic class attributes are concatenated into a single space-separated string.
|
|
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.
|
|
97
|
+
|
|
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
|
+
|
|
100
|
+
### Duplicate Attribute Detection
|
|
101
|
+
|
|
102
|
+
The Babel plugin rejects duplicate attribute names on the same JSX element at **compile time**, so typos and accidental overrides surface as build errors rather than silent last-wins behaviour at runtime.
|
|
103
|
+
|
|
104
|
+
- **Scope is the element itself**, not a syntactic group. Detection spans every attribute on the opening tag regardless of `static` / dynamic / spread interleaving — a spread sitting between two same-named attributes does not hide the duplicate. Children are not part of the scope; nested elements have their own independent check.
|
|
105
|
+
- **Spreads do not contribute names.** Their contents are dynamic and resolved by `mergeProps` at runtime, so they cannot be statically diffed against named attributes.
|
|
106
|
+
- **Whitelist: `class`, `className`, `style`.** These keys have first-class additive merge semantics in `mergeProps` (see the table above), so repeating them is a legitimate composition pattern, not author error.
|
|
107
|
+
|
|
108
|
+
```jsx
|
|
109
|
+
<div id="a" id={dynamic} /> // ❌ compile error: duplicate "id"
|
|
110
|
+
<div id="a" {...rest} id={dynamic} /> // ❌ compile error: still duplicate
|
|
111
|
+
<div class="a" class={dynamic} /> // ✅ class is whitelisted
|
|
112
|
+
<div class="a" {...rest} class={dynamic} /> // ✅ both contributions are merged
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Lifecycle Semantics
|
|
116
|
+
|
|
117
|
+
- `onMount` callbacks run in **child-first** order for nested component trees.
|
|
118
|
+
- Component unmount also follows **child-first** order.
|
|
119
|
+
- During unmount, owner-scoped `effects` are stopped before owner cleanups are flushed, so reactive subscriptions and event bindings are disposed predictably.
|
|
120
|
+
|
|
121
|
+
## Current Runtime Coverage
|
|
122
|
+
|
|
123
|
+
- **Reactive DOM props**: signals, computed values, and getter sources can drive common DOM props (for example `value`, `title`, `disabled`, `placeholder`) through a shared binding path.
|
|
124
|
+
- **Reactive `className`**: class tokens are added and removed incrementally so stale class names are cleaned up when state changes.
|
|
125
|
+
- **Reactive `style` object**: style keys are diffed by key; removed keys are cleared from the element to avoid stale inline styles.
|
|
126
|
+
- **Event binding is one-time**: intrinsic `onXxx` props are bound as plain handlers when the node mounts. The reactive Babel transform intentionally does not wrap event expressions into thunks, so patterns like `onClick={flag() ? fnA : fnB}` do not rebind when `flag` changes.
|
|
127
|
+
- **JSX-to-DOM prop normalization**: camelCase JSX props like `autoFocus`, `autoComplete`, `autoPlay`, `encType`, and `hrefLang` are normalized to the browser-exposed DOM keys before apply.
|
|
128
|
+
- **Mount/dispose API**: `renderApp(container, node)` returns an idempotent disposer that unmounts DOM and disposes owner/effect scopes.
|
|
129
|
+
- **Lifecycle hooks**: `onMount` and cleanup registration (`onCleanup` wrapper) are available for component-level setup and teardown.
|
|
130
|
+
|
|
131
|
+
## Reactivity
|
|
132
|
+
|
|
133
|
+
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`.
|
|
134
|
+
|
|
135
|
+
### Primitives
|
|
136
|
+
|
|
137
|
+
#### `createSignal(initialValue)`
|
|
138
|
+
|
|
139
|
+
Creates a reactive container for a single value. Reading the signal inside an `effect` or `createComputed` subscribes to it; writing triggers updates.
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
import { createSignal, effect } from 'jsx'
|
|
143
|
+
|
|
144
|
+
const count = createSignal(0)
|
|
145
|
+
effect(() => console.log(count())) // logs 0
|
|
146
|
+
count(1) // logs 1
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Passing an existing signal returns it unchanged — double-wrapping is a no-op.
|
|
150
|
+
|
|
151
|
+
#### `createComputed(fn)`
|
|
152
|
+
|
|
153
|
+
Creates a lazily-evaluated derived value. The computation re-runs only when its signal dependencies change.
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { createSignal, createComputed } from 'jsx'
|
|
157
|
+
|
|
158
|
+
const firstName = createSignal('Jane')
|
|
159
|
+
const lastName = createSignal('Doe')
|
|
160
|
+
const fullName = createComputed(() => `${firstName()} ${lastName()}`)
|
|
161
|
+
|
|
162
|
+
fullName() // 'Jane Doe'
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### `createTree(obj)`
|
|
166
|
+
|
|
167
|
+
Wraps a plain object (or array) in a deep-reactive Proxy, equivalent to Vue 3's `reactive()`. Every property read subscribes to that property's signal; every write triggers only the affected property's subscribers.
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
import { createTree, effect } from 'jsx'
|
|
171
|
+
|
|
172
|
+
const state = createTree({ user: { name: 'Alice' }, count: 0 })
|
|
173
|
+
|
|
174
|
+
effect(() => console.log(state.user.name)) // logs 'Alice'
|
|
175
|
+
state.user.name = 'Bob' // logs 'Bob'
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Nested objects are wrapped on demand when accessed, so reactivity is deep without upfront cost. Calling `createTree` on an already-reactive tree is a no-op.
|
|
179
|
+
|
|
180
|
+
#### `effect(fn)`
|
|
181
|
+
|
|
182
|
+
Runs `fn` immediately and re-runs it whenever any signal read inside it changes.
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
import { createSignal, effect } from 'jsx'
|
|
186
|
+
|
|
187
|
+
const x = createSignal(1)
|
|
188
|
+
effect(() => console.log('x is', x()))
|
|
189
|
+
x(2) // logs 'x is 2'
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Effects are automatically disposed when their owner scope is cleaned up (for example, when a component unmounts).
|
|
193
|
+
|
|
194
|
+
#### `batch(fn)`
|
|
195
|
+
|
|
196
|
+
Defers all signal notifications until `fn` returns, so multiple writes trigger only one downstream update.
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
import { createSignal, createComputed, batch } from 'jsx'
|
|
200
|
+
|
|
201
|
+
const a = createSignal(1)
|
|
202
|
+
const b = createSignal(2)
|
|
203
|
+
const sum = createComputed(() => a() + b())
|
|
204
|
+
|
|
205
|
+
batch(() => {
|
|
206
|
+
a(10)
|
|
207
|
+
b(20)
|
|
208
|
+
})
|
|
209
|
+
// sum re-computes once, not twice
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Nesting Rules
|
|
213
|
+
|
|
214
|
+
| Combination | Allowed | Notes |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| `signal → primitive` | Yes | Normal usage |
|
|
217
|
+
| `signal → tree` | Yes | Signal controls which object; tree tracks its properties |
|
|
218
|
+
| `signal → function` | Yes | Signal controls which computation to use |
|
|
219
|
+
| `signal → signal` | No | Forbidden — `createSignal` returns the inner signal as-is |
|
|
220
|
+
| `signal → computed` | Discouraged | Triggers a runtime warning; use the computed directly |
|
|
221
|
+
|
|
222
|
+
### Utility Functions
|
|
223
|
+
|
|
224
|
+
- **`isSignal(value)`** — returns `true` if `value` is a signal.
|
|
225
|
+
- **`isComputed(value)`** — returns `true` if `value` is a computed.
|
|
226
|
+
- **`isTree(value)`** — returns `true` if `value` is a reactive tree proxy.
|
|
227
|
+
- **`toRaw(value)`** — unwraps a reactive tree proxy to its underlying plain object. Safe to call on non-proxy values (returns the value unchanged).
|
|
228
|
+
- **`runUntracked(fn)`** — runs `fn` without registering any signal subscriptions. Useful when reading reactive state for a one-off value without creating a dependency.
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
import { createTree, toRaw, isTree } from 'jsx'
|
|
232
|
+
|
|
233
|
+
const state = createTree({ x: 1 })
|
|
234
|
+
isTree(state) // true
|
|
235
|
+
toRaw(state) // { x: 1 }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Getting Started
|
|
239
|
+
|
|
240
|
+
### Prerequisites
|
|
241
|
+
|
|
242
|
+
- Node.js 18+
|
|
243
|
+
- npm 9+
|
|
244
|
+
|
|
245
|
+
### Installation
|
|
246
|
+
|
|
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 `babel-preset-plastic` rewrites those calls for fine-grained reactivity (control-flow lifting, `mergeProps`, etc.).
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
npm install @plastic-js/plastic
|
|
251
|
+
npm install --save-dev \
|
|
252
|
+
@babel/core \
|
|
253
|
+
@babel/preset-react \
|
|
254
|
+
babel-preset-plastic \
|
|
255
|
+
vite-plugin-babel
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Then wire the presets up in `vite.config.js`:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
import { defineConfig } from 'vite'
|
|
262
|
+
import babel from 'vite-plugin-babel'
|
|
263
|
+
import plasticJsx from 'babel-preset-plastic'
|
|
264
|
+
|
|
265
|
+
export default defineConfig({
|
|
266
|
+
plugins: [
|
|
267
|
+
babel({
|
|
268
|
+
babelConfig: {
|
|
269
|
+
presets: [
|
|
270
|
+
['@babel/preset-react', {
|
|
271
|
+
runtime: 'automatic',
|
|
272
|
+
importSource: '@plastic-js/plastic',
|
|
273
|
+
}],
|
|
274
|
+
plasticJsx,
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
],
|
|
279
|
+
})
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The `importSource: '@plastic-js/plastic'` option points the JSX runtime at Plastic instead of React, so `jsx`/`jsxs`/`Fragment` are imported from `@plastic-js/plastic/jsx-runtime`.
|
|
283
|
+
|
|
284
|
+
## Routing
|
|
285
|
+
|
|
286
|
+
Plastic ships with a lightweight client-side router built on top of the browser History API.
|
|
287
|
+
|
|
288
|
+
Hash-based routing is intentionally not supported at the moment. To keep the router implementation small and predictable, Plastic currently supports only the History API.
|
|
289
|
+
|
|
290
|
+
### Available Router APIs
|
|
291
|
+
|
|
292
|
+
- `<Router>` owns the current location signal and listens to browser navigation.
|
|
293
|
+
- `<Route path="...">` renders only the active branch.
|
|
294
|
+
- `<Link to="...">` renders a normal anchor and intercepts internal left-click navigation.
|
|
295
|
+
- `<NavLink to="...">` extends `<Link>` and adds an `active` class plus `aria-current="page"` when the target matches the current route.
|
|
296
|
+
- `useMatch(path)` returns a reactive matcher function for custom active-state UI without rendering `<NavLink>`.
|
|
297
|
+
- `navigate(to, options)` performs programmatic navigation.
|
|
298
|
+
- `<Outlet />` renders the currently matched child route inside a parent route component.
|
|
299
|
+
- `lazy(importFn, options?)` wraps a dynamic import as a code-split component for use with `<Route>`.
|
|
300
|
+
|
|
301
|
+
### Active Navigation Links
|
|
302
|
+
|
|
303
|
+
Use `<NavLink>` when a navigation item should reflect the current route automatically.
|
|
304
|
+
|
|
305
|
+
```jsx
|
|
306
|
+
import { NavLink, Route, Router } from 'jsx'
|
|
307
|
+
|
|
308
|
+
const App = ()=> (
|
|
309
|
+
<Router>
|
|
310
|
+
<nav>
|
|
311
|
+
<NavLink to='/'>Home</NavLink>
|
|
312
|
+
<NavLink to='/settings' className='nav-item'>Settings</NavLink>
|
|
313
|
+
</nav>
|
|
314
|
+
|
|
315
|
+
<Route path='/' component={HomePage} />
|
|
316
|
+
<Route path='/settings' component={SettingsPage} />
|
|
317
|
+
</Router>
|
|
318
|
+
)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
By default, `NavLink` treats nested URLs as active matches, so a link to `/settings` stays active on `/settings/profile`. Pass `end` to require an exact pathname match instead. Use `activeClass` to override the default `active` class name.
|
|
322
|
+
|
|
323
|
+
### Custom Match Hook
|
|
324
|
+
|
|
325
|
+
Use `useMatch(path)` when you want route-aware active styles on non-anchor UI (tabs, cards, badges, etc.).
|
|
326
|
+
|
|
327
|
+
```jsx
|
|
328
|
+
import { Router, Route, useMatch } from 'jsx'
|
|
329
|
+
|
|
330
|
+
const DashboardTabs = ()=> {
|
|
331
|
+
const isSettings = useMatch('/settings')
|
|
332
|
+
const isUser = useMatch('/users/:id')
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<div>
|
|
336
|
+
<p className={isSettings() ? 'on' : 'off'}>Settings</p>
|
|
337
|
+
<p className={isUser() ? 'on' : 'off'}>User</p>
|
|
338
|
+
</div>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const App = ()=> (
|
|
343
|
+
<Router>
|
|
344
|
+
<Route path='*' component={DashboardTabs} />
|
|
345
|
+
</Router>
|
|
346
|
+
)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`useMatch('/settings')` follows `NavLink` default active behavior (prefix match), while parameterized paths like `/users/:id` use exact segment-shape matching.
|
|
350
|
+
|
|
351
|
+
### Nested Routes
|
|
352
|
+
|
|
353
|
+
Nested routes are declared by placing child `<Route>` elements inside a parent `<Route>`. Parent route components render their active child branch through `<Outlet />`.
|
|
354
|
+
|
|
355
|
+
```jsx
|
|
356
|
+
import {
|
|
357
|
+
Link,
|
|
358
|
+
Outlet,
|
|
359
|
+
Route,
|
|
360
|
+
Router,
|
|
361
|
+
navigate,
|
|
362
|
+
} from 'jsx'
|
|
363
|
+
|
|
364
|
+
const Settings = ()=> (
|
|
365
|
+
<div>
|
|
366
|
+
<h2>Settings</h2>
|
|
367
|
+
<nav>
|
|
368
|
+
<Link to='/settings'>Overview</Link>
|
|
369
|
+
<Link to='/settings/profile'>Profile</Link>
|
|
370
|
+
<Link to='/settings/security'>Security</Link>
|
|
371
|
+
</nav>
|
|
372
|
+
<Outlet />
|
|
373
|
+
</div>
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
const SettingsOverview = ()=> <p>Overview page</p>
|
|
377
|
+
const SettingsProfile = ()=> <p>Profile page</p>
|
|
378
|
+
const SettingsSecurity = ()=> <p>Security page</p>
|
|
379
|
+
|
|
380
|
+
const App = ()=> (
|
|
381
|
+
<Router>
|
|
382
|
+
<Route component={Settings} path='/settings'>
|
|
383
|
+
<Route index component={SettingsOverview} />
|
|
384
|
+
<Route component={SettingsProfile} path='/profile' />
|
|
385
|
+
<Route component={SettingsSecurity} path='/security' />
|
|
386
|
+
</Route>
|
|
387
|
+
</Router>
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
navigate('/settings/profile')
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Matching Semantics
|
|
394
|
+
|
|
395
|
+
- Leaf routes use exact path-shape matching (including `:param` segments).
|
|
396
|
+
- Parent routes that declare child `<Route>` elements use prefix matching so they stay mounted while nested child routes switch underneath them.
|
|
397
|
+
- Nested child paths are resolved relative to their parent route. For example, a child `path='/profile'` inside a parent `path='/settings'` matches `/settings/profile`.
|
|
398
|
+
- `index` routes match the parent path itself and render through the parent component's `<Outlet />`.
|
|
399
|
+
- Query strings are exposed through `useRoute().query` and route props (`query`) without affecting path matching.
|
|
400
|
+
|
|
401
|
+
### Lazy Loading
|
|
402
|
+
|
|
403
|
+
`lazy(importFn, options?)` code-splits a route component via a dynamic `import()`. The module is fetched the first time the route is rendered; subsequent renders reuse the cached result synchronously. Because the resolved component is stored in a signal, the route automatically re-renders once the import settles — no manual wiring required.
|
|
404
|
+
|
|
405
|
+
```jsx
|
|
406
|
+
import { lazy, Route, Router } from 'jsx'
|
|
407
|
+
|
|
408
|
+
// Each call to lazy() creates an independent, deduplicated import.
|
|
409
|
+
const LazyDashboard = lazy(() => import('./pages/Dashboard.jsx'))
|
|
410
|
+
const LazySettings = lazy(() => import('./pages/Settings.jsx'))
|
|
411
|
+
|
|
412
|
+
const App = ()=> (
|
|
413
|
+
<Router>
|
|
414
|
+
<Route component={LazyDashboard} path='/dashboard' />
|
|
415
|
+
<Route component={LazySettings} path='/settings' />
|
|
416
|
+
</Router>
|
|
417
|
+
)
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**With a loading fallback**
|
|
421
|
+
|
|
422
|
+
Pass a `fallback` option to render a placeholder component while the import is in flight:
|
|
423
|
+
|
|
424
|
+
```jsx
|
|
425
|
+
const Spinner = ()=> <p>Loading…</p>
|
|
426
|
+
|
|
427
|
+
const LazyDashboard = lazy(
|
|
428
|
+
()=> import('./pages/Dashboard.jsx'),
|
|
429
|
+
{ fallback: Spinner },
|
|
430
|
+
)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
`fallback` can be a component function (called with no props) or a pre-created DOM node / `null`. When omitted, the route renders nothing during loading.
|
|
434
|
+
|
|
435
|
+
**API**
|
|
436
|
+
|
|
437
|
+
| Argument | Type | Description |
|
|
438
|
+
|---|---|---|
|
|
439
|
+
| `importFn` | `() => Promise<module>` | Zero-argument factory. The module's `default` export is used as the component. |
|
|
440
|
+
| `options.fallback` | `Component \| Node \| null` | Shown while the import is in flight. Defaults to `null`. |
|
|
441
|
+
|
|
442
|
+
> The returned `LazyComponent` function is a plain component and can also be used outside of routes — anywhere `h(LazyComponent, props)` is valid.
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plastic-js/plastic",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"main": "src/index.js",
|
|
5
|
+
"access": "public",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.js",
|
|
18
|
+
"./jsx-runtime": "./src/jsx-runtime.js",
|
|
19
|
+
"./jsx-dev-runtime": "./src/jsx-runtime.js",
|
|
20
|
+
"./router": "./src/router.js",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"dev": "vite",
|
|
28
|
+
"build": "vite build",
|
|
29
|
+
"preview": "vite preview",
|
|
30
|
+
"test": "vitest run --root .",
|
|
31
|
+
"bench": "vitest bench --root .",
|
|
32
|
+
"bench:e2e": "node bench-e2e/runner.mjs"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"jsx",
|
|
36
|
+
"reactive",
|
|
37
|
+
"reactivity",
|
|
38
|
+
"signals",
|
|
39
|
+
"fine-grained",
|
|
40
|
+
"framework",
|
|
41
|
+
"frontend",
|
|
42
|
+
"ui",
|
|
43
|
+
"alien-signals"
|
|
44
|
+
],
|
|
45
|
+
"author": "plas",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"description": "A lightweight reactive JSX framework with fine-grained reactivity powered by alien-signals.",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/plastic-js/plastic.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/plastic-js/plastic",
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"alien-signals": "^3.2.1"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@babel/core": "^7.29.0",
|
|
58
|
+
"@babel/preset-react": "^7.28.5",
|
|
59
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
60
|
+
"babel-preset-plastic": "^0.1.0",
|
|
61
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
62
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
63
|
+
"eslint": "^9.39.2",
|
|
64
|
+
"eslint-config-janus": "^9.0.21",
|
|
65
|
+
"eslint-plugin-mocha": "^11.3.0",
|
|
66
|
+
"eslint-plugin-react": "^7.37.5",
|
|
67
|
+
"jsdom": "^29.1.1",
|
|
68
|
+
"playwright": "^1.60.0",
|
|
69
|
+
"react": "^19.2.6",
|
|
70
|
+
"react-dom": "^19.2.6",
|
|
71
|
+
"solid-js": "^1.9.13",
|
|
72
|
+
"vite": "^8.0.13",
|
|
73
|
+
"vite-plugin-babel": "^1.7.3",
|
|
74
|
+
"vite-plugin-solid": "^2.11.12",
|
|
75
|
+
"vitest": "^4.0.17",
|
|
76
|
+
"vue": "^3.5.34"
|
|
77
|
+
}
|
|
78
|
+
}
|