@blueshed/railroad 0.3.4 → 0.4.0
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/.claude/skills/railroad/SKILL.md +40 -74
- package/README.md +56 -20
- package/index.ts +1 -2
- package/jsx.ts +72 -52
- package/package.json +1 -1
- package/routes.ts +32 -14
- package/signals.ts +14 -6
|
@@ -1,94 +1,60 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
name: railroad
|
|
3
|
+
description: "Railroad — micro reactive UI framework for Bun. Use when writing JSX components with signals, routes, when(), list(), or importing @blueshed/railroad."
|
|
4
|
+
---
|
|
2
5
|
|
|
3
6
|
Micro reactive UI framework for Bun. ~900 lines, zero dependencies, real DOM.
|
|
4
7
|
|
|
5
|
-
**
|
|
6
|
-
|
|
7
|
-
- `jsx.ts` — createElement, Fragment, text, when, list, SVG adoption
|
|
8
|
-
- `routes.ts` — routes, route, navigate, matchRoute
|
|
9
|
-
- `shared.ts` — key, provide, inject, tryInject
|
|
10
|
-
- `logger.ts` — createLogger, setLogLevel, loggedRequest
|
|
8
|
+
**Read the source files for full API detail** — each has a JSDoc header:
|
|
9
|
+
`signals.ts` · `jsx.ts` · `routes.ts` · `shared.ts` · `logger.ts`
|
|
11
10
|
|
|
12
11
|
## Setup
|
|
13
12
|
|
|
14
13
|
```json
|
|
15
|
-
// tsconfig.json
|
|
14
|
+
// tsconfig.json
|
|
16
15
|
{ "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
|
|
17
16
|
```
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
// server.ts
|
|
21
|
-
import home from "./index.html";
|
|
22
|
-
Bun.serve({ routes: { "/": home } });
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
## API Quick Reference
|
|
26
|
-
|
|
27
|
-
### Signals (`signals.ts`)
|
|
28
|
-
|
|
29
|
-
```ts
|
|
30
|
-
signal<T>(value): Signal<T> // mutable reactive value
|
|
31
|
-
computed<T>(fn): Signal<T> // derived, auto-tracked, auto-disposed in scope
|
|
32
|
-
effect(fn): Dispose // side-effect, returns dispose function
|
|
33
|
-
batch(fn): void // group writes, flush once
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
Signal methods: `.get()` `.set(v)` `.update(fn)` `.mutate(fn)` `.patch(partial)` `.peek()`
|
|
37
|
-
|
|
38
|
-
Dispose scopes: `pushDisposeScope()` `popDisposeScope(): Dispose` `trackDispose(fn)`
|
|
39
|
-
|
|
40
|
-
### JSX (`jsx.ts`)
|
|
18
|
+
## Mental Model
|
|
41
19
|
|
|
42
|
-
Components
|
|
20
|
+
Components run **once**. They return real DOM nodes. Reactivity comes from signals — not re-rendering. Effects and computeds auto-dispose when their parent scope (component, route, `when`, `list`) tears down.
|
|
43
21
|
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
text(fn): Node // reactive computed text node
|
|
48
|
-
when(condition, truthy, falsy?): Node // conditional swap on truthiness transition
|
|
49
|
-
list(items, keyFn, render): Node // keyed list, render gets Signal<T>, Signal<number>
|
|
50
|
-
list(items, render): Node // index-based list, render gets raw T
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
Props: `class`/`className`, `style` (object or Signal), `value`/`checked`/`disabled`/`selected`/`src`/`srcdoc` (as DOM properties), `innerHTML`, `ref(el)`, `on*` events. All support Signal values for reactivity.
|
|
54
|
-
|
|
55
|
-
SVG: `<svg>` children are auto-adopted into SVG namespace.
|
|
56
|
-
|
|
57
|
-
### Routes (`routes.ts`)
|
|
22
|
+
```tsx
|
|
23
|
+
// Bare signal — auto-reactive
|
|
24
|
+
<span>{count}</span>
|
|
58
25
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
route<T>(pattern): Signal<T | null> // reactive route match
|
|
62
|
-
navigate(path): void // set location.hash
|
|
63
|
-
matchRoute(pattern, path): params | null
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Handlers receive `(params, params$)` — plain object + Signal. Destructure the first, watch the second for same-pattern param changes (e.g. `/users/1` → `/users/2`).
|
|
26
|
+
// Function child — auto-reactive expression
|
|
27
|
+
<span>{() => count.get() > 5 ? "High" : "Low"}</span>
|
|
67
28
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
key<T>(name): Key<T> // typed symbol key
|
|
72
|
-
provide<T>(key, value): void // register value
|
|
73
|
-
inject<T>(key): T // retrieve value (throws if missing)
|
|
74
|
-
tryInject<T>(key): T | undefined // retrieve value (returns undefined if missing)
|
|
29
|
+
// Signal.map() — derive a signal for attributes and list items
|
|
30
|
+
<input disabled={count.map(n => n > 10)} />
|
|
31
|
+
{list(todos, t => t.id, (todo$) => <li>{todo$.map(t => t.name)}</li>)}
|
|
75
32
|
```
|
|
76
33
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
34
|
+
## Key Patterns
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
// Reactive attributes — .map() or computed()
|
|
38
|
+
<div class={visible.map(v => v ? "show" : "hide")}>...</div>
|
|
39
|
+
|
|
40
|
+
// Keyed list — render gets Signal<T>, use .map() for content
|
|
41
|
+
{list(todos, t => t.id, (todo$, idx$) => (
|
|
42
|
+
<li class={idx$.map(i => i % 2 ? "odd" : "even")}>
|
|
43
|
+
{todo$.map(t => t.name)}
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
|
|
47
|
+
// Nested routes — wildcard keeps layout mounted, route() for sub-navigation
|
|
48
|
+
routes(app, { "/sites/*": () => <SitesLayout /> });
|
|
49
|
+
function SitesLayout() {
|
|
50
|
+
const detail = route<{ id: string }>("/sites/:id");
|
|
51
|
+
return when(() => detail.get(), () => <SiteDetail />, () => <SitesList />);
|
|
52
|
+
}
|
|
83
53
|
```
|
|
84
54
|
|
|
85
55
|
## Anti-Patterns
|
|
86
56
|
|
|
87
|
-
1. **No React.** No useState, useEffect, hooks, lifecycle methods,
|
|
88
|
-
2. **No `.get()` in JSX children.**
|
|
89
|
-
3. **No
|
|
90
|
-
4. **No
|
|
91
|
-
5. **No `transition-all` in CSS** near layout boundaries. Use specific properties.
|
|
92
|
-
6. **No bare nested `when()`.** `when()` returns a fragment — nesting fragments inside another `when()` breaks dispose scope tracking. Always wrap an inner `when()` in a real element: `<div>{when(...)}</div>`.
|
|
93
|
-
7. **No shared DOM nodes across `when()` branches.** Nodes must be created fresh inside each branch function. A node created outside and reused across branches will be torn out of the DOM when the other branch activates.
|
|
94
|
-
8. **Guard against null inside `when()` branches.** Signal cascade order is not guaranteed — an inner `when()` can fire before the outer `when()` swaps it away. Always null-check even inside a branch that "shouldn't" be reached (e.g. `text(() => item.get()?.name ?? "")`).
|
|
57
|
+
1. **No React.** No useState, useEffect, hooks, lifecycle methods, or react imports.
|
|
58
|
+
2. **No `.get()` in JSX children.** `{count}` or `{() => count.get() + 1}` — never `{count.get()}`.
|
|
59
|
+
3. **No shared DOM nodes across `when()` branches.** Create nodes fresh inside each branch.
|
|
60
|
+
4. **No `transition-all` in CSS** near layout boundaries. Use specific properties.
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ function Home() {
|
|
|
37
37
|
<div>
|
|
38
38
|
<h1>Hello World</h1>
|
|
39
39
|
<button onclick={() => count.update(n => n + 1)}>
|
|
40
|
-
Count: {count}
|
|
40
|
+
{() => `Count: ${count.get()}`}
|
|
41
41
|
</button>
|
|
42
42
|
</div>
|
|
43
43
|
);
|
|
@@ -74,7 +74,7 @@ function Home() {
|
|
|
74
74
|
<div>
|
|
75
75
|
<h1>Hello World</h1>
|
|
76
76
|
<button onclick={() => count.update(n => n + 1)}>
|
|
77
|
-
Count: {count}
|
|
77
|
+
{() => `Count: ${count.get()}`}
|
|
78
78
|
</button>
|
|
79
79
|
</div>
|
|
80
80
|
);
|
|
@@ -107,6 +107,7 @@ import { signal, computed, effect, batch } from "@blueshed/railroad";
|
|
|
107
107
|
|
|
108
108
|
const count = signal(0);
|
|
109
109
|
const doubled = computed(() => count.get() * 2);
|
|
110
|
+
const label = count.map(n => `Count: ${n}`); // derive a signal
|
|
110
111
|
|
|
111
112
|
const dispose = effect(() => {
|
|
112
113
|
console.log(`count is ${count.get()}`);
|
|
@@ -134,10 +135,10 @@ dispose(); // stop listening
|
|
|
134
135
|
|
|
135
136
|
### JSX
|
|
136
137
|
|
|
137
|
-
Components are functions that return DOM nodes.
|
|
138
|
+
Components are functions that run **once** and return DOM nodes. Reactivity comes from signals, not re-rendering.
|
|
138
139
|
|
|
139
140
|
```tsx
|
|
140
|
-
import {
|
|
141
|
+
import { signal, when, list } from "@blueshed/railroad";
|
|
141
142
|
|
|
142
143
|
const name = signal("World");
|
|
143
144
|
|
|
@@ -146,10 +147,18 @@ function Greeting() {
|
|
|
146
147
|
}
|
|
147
148
|
```
|
|
148
149
|
|
|
149
|
-
####
|
|
150
|
+
#### Reactive expressions — function children
|
|
150
151
|
|
|
151
152
|
```tsx
|
|
152
|
-
<span>{
|
|
153
|
+
<span>{() => count.get() > 5 ? "High" : "Low"}</span>
|
|
154
|
+
<p>{() => `${first.get()} ${last.get()}`}</p>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### Reactive attributes — `computed()` or `.map()`
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
<div class={visible.map(v => v ? "show" : "hide")}>...</div>
|
|
161
|
+
<input disabled={count.map(n => n > 10)} />
|
|
153
162
|
```
|
|
154
163
|
|
|
155
164
|
#### `when(condition, truthy, falsy?)` — conditional rendering
|
|
@@ -162,15 +171,17 @@ function Greeting() {
|
|
|
162
171
|
)}
|
|
163
172
|
```
|
|
164
173
|
|
|
174
|
+
Nestable — `when()` inside `when()` works without wrapper elements.
|
|
175
|
+
|
|
165
176
|
#### `list(items, keyFn?, render)` — keyed list rendering
|
|
166
177
|
|
|
167
178
|
```tsx
|
|
168
179
|
// Keyed — render receives Signal<T> and Signal<number>:
|
|
169
|
-
{list(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)}
|
|
180
|
+
{list(todos, t => t.id, (todo$, idx$) => (
|
|
181
|
+
<li class={idx$.map(i => i % 2 ? "odd" : "even")}>
|
|
182
|
+
{todo$.map(t => t.name)}
|
|
183
|
+
</li>
|
|
184
|
+
))}
|
|
174
185
|
|
|
175
186
|
// Non-keyed (index-based, raw values):
|
|
176
187
|
{list(items, (item, i) => <li>{item}</li>)}
|
|
@@ -178,19 +189,17 @@ function Greeting() {
|
|
|
178
189
|
|
|
179
190
|
### Routes
|
|
180
191
|
|
|
181
|
-
Hash-based client router
|
|
192
|
+
Hash-based client router. Handlers receive `(params, params$)` — destructure the first for convenience, watch the second for reactive param changes.
|
|
182
193
|
|
|
183
194
|
```tsx
|
|
184
|
-
import { routes, navigate,
|
|
195
|
+
import { routes, navigate, route, when, effect } from "@blueshed/railroad";
|
|
185
196
|
|
|
186
|
-
|
|
197
|
+
routes(app, {
|
|
187
198
|
"/": () => <Home />,
|
|
188
|
-
// Simple — destructure params as before:
|
|
189
199
|
"/about": () => <About />,
|
|
190
|
-
// Reactive — watch params$ for same-pattern navigation (/users/1 → /users/2):
|
|
191
200
|
"/users/:id": ({ id }, params$) => {
|
|
192
201
|
effect(() => fetchUser(params$.get().id));
|
|
193
|
-
return <h1>{
|
|
202
|
+
return <h1>{params$.map(p => `User ${p.id}`)}</h1>;
|
|
194
203
|
},
|
|
195
204
|
"*": () => <NotFound />,
|
|
196
205
|
});
|
|
@@ -198,6 +207,33 @@ const dispose = routes(app, {
|
|
|
198
207
|
navigate("/users/42");
|
|
199
208
|
```
|
|
200
209
|
|
|
210
|
+
#### Nested routes
|
|
211
|
+
|
|
212
|
+
Use wildcard patterns to keep a layout mounted while sub-views swap:
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
routes(app, {
|
|
216
|
+
"/": () => <Home />,
|
|
217
|
+
"/sites/*": () => <SitesLayout />,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
function SitesLayout() {
|
|
221
|
+
const detail = route<{ id: string }>("/sites/:id");
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div>
|
|
225
|
+
<SitesNav />
|
|
226
|
+
{when(() => detail.get(),
|
|
227
|
+
() => <SiteDetail params$={detail} />,
|
|
228
|
+
() => <SitesList />,
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Navigate `/sites` → `/sites/42` → `/sites/99`: `SitesLayout` stays mounted, only the inner content swaps. Navigate away from `/sites/*`: layout tears down cleanly.
|
|
236
|
+
|
|
201
237
|
### Shared
|
|
202
238
|
|
|
203
239
|
Typed dependency injection without prop threading.
|
|
@@ -235,9 +271,9 @@ const handler = loggedRequest("[api]", myHandler);
|
|
|
235
271
|
## Design
|
|
236
272
|
|
|
237
273
|
- **Signals hold state** — reactive primitives with automatic dependency tracking
|
|
238
|
-
- **Effects update the DOM** — run when dependencies change,
|
|
274
|
+
- **Effects update the DOM** — run when dependencies change, auto-cleanup in scope
|
|
239
275
|
- **JSX creates the DOM** — real elements, not virtual. Signal-aware props and children
|
|
240
|
-
- **Routes swap the DOM** — hash-based,
|
|
276
|
+
- **Routes swap the DOM** — hash-based, auto-scoped, nestable via wildcards
|
|
241
277
|
|
|
242
278
|
No lifecycle methods. No hooks rules. No context providers. No `useCallback`. Just signals and the DOM.
|
|
243
279
|
|
|
@@ -275,7 +311,7 @@ Every import path (`/signals`, `/shared`, `/logger`, `/jsx`, `/routes`) works st
|
|
|
275
311
|
|
|
276
312
|
## Claude Code
|
|
277
313
|
|
|
278
|
-
This package ships with a [Claude Code](https://claude.com/claude-code) skill in `.claude/skills/railroad/`. Copy it into your project so Claude generates correct railroad code
|
|
314
|
+
This package ships with a [Claude Code](https://claude.com/claude-code) skill in `.claude/skills/railroad/`. Copy it into your project so Claude generates correct railroad code:
|
|
279
315
|
|
|
280
316
|
```sh
|
|
281
317
|
cp -r node_modules/@blueshed/railroad/.claude/skills/railroad .claude/skills/
|
package/index.ts
CHANGED
package/jsx.ts
CHANGED
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
* - tag: string → creates HTML element (or SVG element inside <svg>)
|
|
6
6
|
* - tag: function → calls component function(props)
|
|
7
7
|
* - props: attributes, event handlers (onclick etc), ref
|
|
8
|
-
* - children: string, number, Node, Signal<T>, arrays, null/undefined
|
|
8
|
+
* - children: string, number, Node, Signal<T>, () => any, arrays, null/undefined
|
|
9
9
|
*
|
|
10
10
|
* When a Signal is used as a child, an effect auto-updates the text node.
|
|
11
|
+
* When a function is used as a child, it auto-tracks dependencies:
|
|
12
|
+
* <span>{() => count.get() > 5 ? "High" : "Low"}</span>
|
|
11
13
|
* When a Signal is used as a prop value, an effect auto-updates the attribute.
|
|
12
14
|
*
|
|
15
|
+
* Components are auto-scoped — effects/computeds inside are disposed when
|
|
16
|
+
* the parent scope (route, when, list) tears down. No manual dispose needed.
|
|
17
|
+
*
|
|
13
18
|
* SVG support:
|
|
14
19
|
* <svg> is created with the SVG namespace. Any HTML children appended to
|
|
15
20
|
* an SVG-namespaced parent are automatically adopted into the SVG namespace.
|
|
@@ -20,13 +25,12 @@
|
|
|
20
25
|
* when(signal, truthy, falsy?) — conditional rendering, swaps DOM nodes
|
|
21
26
|
* list(signal, keyFn, render) — keyed reactive list, render receives Signal<T>
|
|
22
27
|
* list(signal, render) — index-based reactive list, render receives raw T
|
|
23
|
-
* text(fn) — reactive text from computed expression
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
import { Signal, signal, effect, computed, pushDisposeScope, popDisposeScope, trackDispose } from "./signals";
|
|
27
31
|
import type { Dispose } from "./signals";
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
// pushDisposeScope / popDisposeScope are internal — used by createElement, when, list, routes
|
|
30
34
|
|
|
31
35
|
// === SVG namespace ===
|
|
32
36
|
|
|
@@ -52,35 +56,35 @@ function applyProps(el: Element, props: Record<string, any>): void {
|
|
|
52
56
|
if (typeof value === "function") value(el);
|
|
53
57
|
} else if (key === "innerHTML") {
|
|
54
58
|
if (value instanceof Signal) {
|
|
55
|
-
|
|
59
|
+
effect(() => { el.innerHTML = value.get(); });
|
|
56
60
|
} else {
|
|
57
61
|
el.innerHTML = value;
|
|
58
62
|
}
|
|
59
63
|
} else if (key === "className" || key === "class") {
|
|
60
64
|
if (value instanceof Signal) {
|
|
61
|
-
|
|
65
|
+
effect(() => { el.setAttribute("class", value.get()); });
|
|
62
66
|
} else {
|
|
63
67
|
el.setAttribute("class", value);
|
|
64
68
|
}
|
|
65
69
|
} else if (key === "value" || key === "checked" || key === "disabled" || key === "selected" || key === "srcdoc" || key === "src") {
|
|
66
70
|
if (value instanceof Signal) {
|
|
67
|
-
|
|
71
|
+
effect(() => { (el as any)[key] = value.get(); });
|
|
68
72
|
} else {
|
|
69
73
|
(el as any)[key] = value;
|
|
70
74
|
}
|
|
71
75
|
} else if (key === "style" && value instanceof Signal) {
|
|
72
|
-
|
|
76
|
+
effect(() => { Object.assign((el as HTMLElement).style, value.get()); });
|
|
73
77
|
} else if (key === "style" && typeof value === "object") {
|
|
74
78
|
Object.assign((el as HTMLElement).style, value);
|
|
75
79
|
} else if (key.startsWith("on")) {
|
|
76
80
|
el.addEventListener(key.slice(2).toLowerCase(), value);
|
|
77
81
|
} else {
|
|
78
82
|
if (value instanceof Signal) {
|
|
79
|
-
|
|
83
|
+
effect(() => {
|
|
80
84
|
const v = value.get();
|
|
81
85
|
if (v === false || v == null) el.removeAttribute(key);
|
|
82
86
|
else el.setAttribute(key, String(v));
|
|
83
|
-
})
|
|
87
|
+
});
|
|
84
88
|
} else if (value !== false && value != null) {
|
|
85
89
|
el.setAttribute(key, String(value));
|
|
86
90
|
}
|
|
@@ -96,8 +100,12 @@ export function createElement(
|
|
|
96
100
|
...children: any[]
|
|
97
101
|
): Node {
|
|
98
102
|
if (typeof tag === "function") {
|
|
103
|
+
pushDisposeScope();
|
|
99
104
|
const componentProps = { ...props, children };
|
|
100
|
-
|
|
105
|
+
const node = tag(componentProps);
|
|
106
|
+
const dispose = popDisposeScope();
|
|
107
|
+
trackDispose(dispose);
|
|
108
|
+
return node;
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
// SVG root element is always created with the SVG namespace.
|
|
@@ -160,10 +168,17 @@ function appendChildren(parent: Node, children: any[]): void {
|
|
|
160
168
|
|
|
161
169
|
if (child instanceof Signal) {
|
|
162
170
|
const text = document.createTextNode(String(child.peek()));
|
|
163
|
-
|
|
171
|
+
effect(() => {
|
|
164
172
|
text.textContent = String(child.get());
|
|
165
|
-
})
|
|
173
|
+
});
|
|
166
174
|
parent.appendChild(text);
|
|
175
|
+
} else if (typeof child === "function") {
|
|
176
|
+
const fn = child as () => any;
|
|
177
|
+
const textNode = document.createTextNode("");
|
|
178
|
+
effect(() => {
|
|
179
|
+
textNode.textContent = String(fn() ?? "");
|
|
180
|
+
});
|
|
181
|
+
parent.appendChild(textNode);
|
|
167
182
|
} else if (child instanceof Node) {
|
|
168
183
|
// Adopt HTML elements into SVG namespace when parent is SVG
|
|
169
184
|
if (isSvgParent &&
|
|
@@ -179,17 +194,6 @@ function appendChildren(parent: Node, children: any[]): void {
|
|
|
179
194
|
}
|
|
180
195
|
}
|
|
181
196
|
|
|
182
|
-
// === text() — reactive computed text node ===
|
|
183
|
-
// Use for expressions: text(() => count.get() > 5 ? "High" : "Low")
|
|
184
|
-
|
|
185
|
-
export function text(fn: () => string): Node {
|
|
186
|
-
const value = computed(fn);
|
|
187
|
-
const node = document.createTextNode(value.peek());
|
|
188
|
-
trackDispose(effect(() => {
|
|
189
|
-
node.textContent = value.get();
|
|
190
|
-
}));
|
|
191
|
-
return node;
|
|
192
|
-
}
|
|
193
197
|
|
|
194
198
|
// === when() — conditional rendering ===
|
|
195
199
|
// Swaps DOM nodes only when truthiness transitions (falsy↔truthy).
|
|
@@ -203,7 +207,7 @@ export function when(
|
|
|
203
207
|
falsy?: () => Node,
|
|
204
208
|
): Node {
|
|
205
209
|
const anchor = document.createComment("when");
|
|
206
|
-
let
|
|
210
|
+
let currentNodes: Node[] = [];
|
|
207
211
|
let currentDispose: Dispose | null = null;
|
|
208
212
|
let wasTruthy: boolean | undefined = undefined;
|
|
209
213
|
|
|
@@ -218,32 +222,33 @@ export function when(
|
|
|
218
222
|
wasTruthy = isTruthy;
|
|
219
223
|
|
|
220
224
|
if (currentDispose) currentDispose();
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
225
|
+
for (const n of currentNodes) n.parentNode?.removeChild(n);
|
|
226
|
+
currentNodes = [];
|
|
224
227
|
|
|
225
228
|
pushDisposeScope();
|
|
226
|
-
|
|
229
|
+
const result = isTruthy ? truthy() : (falsy ? falsy() : null);
|
|
227
230
|
currentDispose = popDisposeScope();
|
|
228
231
|
|
|
229
|
-
if (
|
|
230
|
-
|
|
232
|
+
if (result && anchor.parentNode) {
|
|
233
|
+
// Capture actual child nodes before fragment is emptied by insertBefore
|
|
234
|
+
currentNodes = result instanceof DocumentFragment
|
|
235
|
+
? [...result.childNodes]
|
|
236
|
+
: [result];
|
|
237
|
+
anchor.parentNode.insertBefore(result, anchor.nextSibling);
|
|
231
238
|
}
|
|
232
239
|
}
|
|
233
240
|
|
|
234
|
-
|
|
241
|
+
effect(() => {
|
|
235
242
|
sig.get(); // track
|
|
236
243
|
if (!anchor.parentNode) {
|
|
237
244
|
queueMicrotask(swap);
|
|
238
245
|
} else {
|
|
239
246
|
swap();
|
|
240
247
|
}
|
|
241
|
-
})
|
|
248
|
+
});
|
|
242
249
|
|
|
243
|
-
// Return a fragment: anchor + initial content
|
|
244
250
|
const frag = document.createDocumentFragment();
|
|
245
251
|
frag.appendChild(anchor);
|
|
246
|
-
if (current) frag.appendChild(current);
|
|
247
252
|
return frag;
|
|
248
253
|
}
|
|
249
254
|
|
|
@@ -252,11 +257,17 @@ export function when(
|
|
|
252
257
|
//
|
|
253
258
|
// Keyed form — render receives Signal<T> and Signal<number> so item
|
|
254
259
|
// updates flow into existing DOM without re-creating nodes:
|
|
255
|
-
// list(items, (t) => t.id, (item
|
|
260
|
+
// list(items, (t) => t.id, (item$) => <li>{item$.map(t => t.name)}</li>)
|
|
256
261
|
//
|
|
257
262
|
// Non-keyed form (index-based, raw values):
|
|
258
263
|
// list(items, (item, index) => <li>{item}</li>)
|
|
259
264
|
|
|
265
|
+
function collectNodes(result: Node): Node[] {
|
|
266
|
+
return result instanceof DocumentFragment
|
|
267
|
+
? [...result.childNodes]
|
|
268
|
+
: [result];
|
|
269
|
+
}
|
|
270
|
+
|
|
260
271
|
export function list<T>(
|
|
261
272
|
items: Signal<T[]>,
|
|
262
273
|
keyFnOrRender: ((item: T) => string | number) | ((item: T, index: number) => Node),
|
|
@@ -265,7 +276,7 @@ export function list<T>(
|
|
|
265
276
|
const hasKeyFn = maybeRender !== undefined;
|
|
266
277
|
const keyFn = hasKeyFn ? keyFnOrRender as (item: T) => string | number : null;
|
|
267
278
|
|
|
268
|
-
type Entry = {
|
|
279
|
+
type Entry = { nodes: Node[]; dispose: Dispose; item?: Signal<T>; index?: Signal<number> };
|
|
269
280
|
const anchor = document.createComment("list");
|
|
270
281
|
let entries: Map<string | number, Entry> = new Map();
|
|
271
282
|
let order: (string | number)[] = [];
|
|
@@ -274,7 +285,7 @@ export function list<T>(
|
|
|
274
285
|
const entry = entries.get(key);
|
|
275
286
|
if (entry) {
|
|
276
287
|
entry.dispose();
|
|
277
|
-
entry.
|
|
288
|
+
for (const n of entry.nodes) n.parentNode?.removeChild(n);
|
|
278
289
|
entries.delete(key);
|
|
279
290
|
}
|
|
280
291
|
}
|
|
@@ -282,7 +293,7 @@ export function list<T>(
|
|
|
282
293
|
function clearAll() {
|
|
283
294
|
for (const [, entry] of entries) {
|
|
284
295
|
entry.dispose();
|
|
285
|
-
entry.
|
|
296
|
+
for (const n of entry.nodes) n.parentNode?.removeChild(n);
|
|
286
297
|
}
|
|
287
298
|
entries = new Map();
|
|
288
299
|
order = [];
|
|
@@ -310,17 +321,17 @@ export function list<T>(
|
|
|
310
321
|
if (!entry) {
|
|
311
322
|
// New item — create
|
|
312
323
|
pushDisposeScope();
|
|
313
|
-
let
|
|
324
|
+
let result: Node;
|
|
314
325
|
if (hasKeyFn) {
|
|
315
326
|
const itemSig = signal(arr[i]!);
|
|
316
327
|
const indexSig = signal(i);
|
|
317
|
-
|
|
328
|
+
result = maybeRender!(itemSig, indexSig);
|
|
318
329
|
const dispose = popDisposeScope();
|
|
319
|
-
entry = {
|
|
330
|
+
entry = { nodes: collectNodes(result), dispose, item: itemSig, index: indexSig };
|
|
320
331
|
} else {
|
|
321
|
-
|
|
332
|
+
result = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
|
|
322
333
|
const dispose = popDisposeScope();
|
|
323
|
-
entry = {
|
|
334
|
+
entry = { nodes: collectNodes(result), dispose };
|
|
324
335
|
}
|
|
325
336
|
entries.set(key, entry);
|
|
326
337
|
} else if (hasKeyFn) {
|
|
@@ -329,34 +340,43 @@ export function list<T>(
|
|
|
329
340
|
entry.index!.set(i);
|
|
330
341
|
} else {
|
|
331
342
|
// Index-based — dispose old, recreate with new item
|
|
332
|
-
const
|
|
343
|
+
const oldNodes = entry.nodes;
|
|
333
344
|
entry.dispose();
|
|
334
345
|
pushDisposeScope();
|
|
335
|
-
const
|
|
346
|
+
const result = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
|
|
336
347
|
const dispose = popDisposeScope();
|
|
337
|
-
|
|
348
|
+
const nodes = collectNodes(result);
|
|
349
|
+
entry = { nodes, dispose };
|
|
338
350
|
entries.set(key, entry);
|
|
339
|
-
|
|
351
|
+
const ref = oldNodes[oldNodes.length - 1]?.nextSibling ?? null;
|
|
352
|
+
const oldParent = oldNodes[0]?.parentNode;
|
|
353
|
+
for (const n of oldNodes) n.parentNode?.removeChild(n);
|
|
354
|
+
if (oldParent) {
|
|
355
|
+
for (const n of nodes) oldParent.insertBefore(n, ref);
|
|
356
|
+
}
|
|
340
357
|
}
|
|
341
358
|
|
|
342
359
|
// Move or insert into correct position
|
|
343
|
-
|
|
344
|
-
|
|
360
|
+
const lastNode = entry.nodes[entry.nodes.length - 1];
|
|
361
|
+
if (lastNode?.nextSibling !== insertBefore) {
|
|
362
|
+
for (const n of entry.nodes) {
|
|
363
|
+
parent.insertBefore(n, insertBefore);
|
|
364
|
+
}
|
|
345
365
|
}
|
|
346
|
-
insertBefore = entry.
|
|
366
|
+
insertBefore = entry.nodes[0] ?? insertBefore;
|
|
347
367
|
}
|
|
348
368
|
|
|
349
369
|
order = newKeys;
|
|
350
370
|
}
|
|
351
371
|
|
|
352
|
-
|
|
372
|
+
effect(() => {
|
|
353
373
|
items.get(); // track
|
|
354
374
|
if (!anchor.parentNode) {
|
|
355
375
|
queueMicrotask(sync);
|
|
356
376
|
} else {
|
|
357
377
|
sync();
|
|
358
378
|
}
|
|
359
|
-
})
|
|
379
|
+
});
|
|
360
380
|
|
|
361
381
|
trackDispose(() => clearAll());
|
|
362
382
|
|
package/package.json
CHANGED
package/routes.ts
CHANGED
|
@@ -7,20 +7,31 @@
|
|
|
7
7
|
* navigate(path) — set location.hash programmatically
|
|
8
8
|
* matchRoute(pattern, path) — pure pattern matcher, returns params or null
|
|
9
9
|
*
|
|
10
|
+
* Patterns:
|
|
11
|
+
* "/users/:id" — named params, exact segment match
|
|
12
|
+
* "/sites/*" — wildcard, matches /sites and /sites/any/depth
|
|
13
|
+
* "/sites/:id/*" — params + wildcard, rest captured as params["*"]
|
|
14
|
+
*
|
|
10
15
|
* Handlers receive (params, params$) and return a Node (sync or async).
|
|
11
16
|
* params — plain object for destructuring: ({ id }) => ...
|
|
12
17
|
* params$ — Signal that updates when params change within the same pattern
|
|
13
18
|
*
|
|
14
19
|
* The router manages cleanup automatically. When params change within the
|
|
15
20
|
* same pattern (e.g. /users/1 → /users/2), params$ updates — no teardown.
|
|
21
|
+
*
|
|
22
|
+
* Nested routes — use wildcard to keep a layout mounted:
|
|
16
23
|
* routes(app, {
|
|
17
24
|
* "/": () => <Home />,
|
|
18
|
-
* "/
|
|
19
|
-
* "/status": async () => { const s = await api.get(); return <Status data={s} />; },
|
|
25
|
+
* "/sites/*": () => <SitesLayout />,
|
|
20
26
|
* });
|
|
27
|
+
* // Inside SitesLayout, use route() for sub-navigation:
|
|
28
|
+
* const detail = route("/sites/:id");
|
|
29
|
+
*
|
|
30
|
+
* Both routes() and route() auto-track in the parent dispose scope,
|
|
31
|
+
* so nested routing cleans up when the parent scope tears down.
|
|
21
32
|
*/
|
|
22
33
|
|
|
23
|
-
import { Signal, signal, computed, effect, pushDisposeScope, popDisposeScope } from "./signals";
|
|
34
|
+
import { Signal, signal, computed, effect, pushDisposeScope, popDisposeScope, trackDispose } from "./signals";
|
|
24
35
|
import type { Dispose } from "./signals";
|
|
25
36
|
|
|
26
37
|
let hashSignal: Signal<string> | null = null;
|
|
@@ -54,10 +65,22 @@ export function matchRoute(
|
|
|
54
65
|
): Record<string, string> | null {
|
|
55
66
|
const pp = pattern.split("/");
|
|
56
67
|
const hp = path.split("/");
|
|
57
|
-
|
|
68
|
+
const isWild = pp.length > 0 && pp[pp.length - 1] === "*";
|
|
69
|
+
|
|
70
|
+
if (isWild) {
|
|
71
|
+
if (hp.length < pp.length - 1) return null;
|
|
72
|
+
} else {
|
|
73
|
+
if (pp.length !== hp.length) return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
const params: Record<string, string> = {};
|
|
59
77
|
for (let i = 0; i < pp.length; i++) {
|
|
60
|
-
if (pp[i]
|
|
78
|
+
if (pp[i] === "*") {
|
|
79
|
+
params["*"] = hp.slice(i).map(s => {
|
|
80
|
+
try { return decodeURIComponent(s); } catch { return s; }
|
|
81
|
+
}).join("/");
|
|
82
|
+
return params;
|
|
83
|
+
} else if (pp[i]!.startsWith(":")) {
|
|
61
84
|
try {
|
|
62
85
|
params[pp[i]!.slice(1)] = decodeURIComponent(hp[i]!);
|
|
63
86
|
} catch {
|
|
@@ -72,6 +95,7 @@ export function route<
|
|
|
72
95
|
T extends Record<string, string> = Record<string, string>,
|
|
73
96
|
>(pattern: string): Signal<T | null> {
|
|
74
97
|
const hash = getHash();
|
|
98
|
+
trackDispose(() => releaseHash());
|
|
75
99
|
return computed(() => matchRoute(pattern, hash.get()) as T | null);
|
|
76
100
|
}
|
|
77
101
|
|
|
@@ -144,20 +168,14 @@ export function routes(
|
|
|
144
168
|
return;
|
|
145
169
|
}
|
|
146
170
|
}
|
|
147
|
-
if (table["*"]) {
|
|
148
|
-
if (activePattern !== "*") {
|
|
149
|
-
teardown();
|
|
150
|
-
activePattern = "*";
|
|
151
|
-
run(table["*"], {});
|
|
152
|
-
}
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
171
|
teardown();
|
|
156
172
|
});
|
|
157
173
|
|
|
158
|
-
|
|
174
|
+
const dispose = () => {
|
|
159
175
|
disposeEffect();
|
|
160
176
|
teardown();
|
|
161
177
|
releaseHash();
|
|
162
178
|
};
|
|
179
|
+
trackDispose(dispose);
|
|
180
|
+
return dispose;
|
|
163
181
|
}
|
package/signals.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* .mutate(fn) — structuredClone, mutate in place, notify: s.mutate(v => v.items.push(x))
|
|
17
17
|
* .patch(partial) — shallow merge for object signals: s.patch({ name: "new" })
|
|
18
18
|
* .peek() — read value without tracking
|
|
19
|
+
* .map(fn) — derive a new signal: s.map(v => v.name)
|
|
19
20
|
*
|
|
20
21
|
* Dependency tracking:
|
|
21
22
|
* Effects automatically track which signals are read during execution.
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
*
|
|
25
26
|
* Dispose pattern:
|
|
26
27
|
* effect() can return a cleanup function, called before each re-run and on dispose.
|
|
27
|
-
*
|
|
28
|
+
* effect() and computed() auto-track in the current dispose scope —
|
|
29
|
+
* no manual trackDispose() needed inside components.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
32
|
type Listener = () => void;
|
|
@@ -81,6 +83,10 @@ export class Signal<T> {
|
|
|
81
83
|
return this.value;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
map<U>(fn: (value: T) => U): Signal<U> {
|
|
87
|
+
return computed(() => fn(this.get()));
|
|
88
|
+
}
|
|
89
|
+
|
|
84
90
|
private notify(): void {
|
|
85
91
|
if (batchDepth > 0) {
|
|
86
92
|
for (const listener of this.listeners) pendingEffects.add(listener);
|
|
@@ -133,13 +139,16 @@ export function effect(fn: () => void | (() => void)): () => void {
|
|
|
133
139
|
deps = nextDeps;
|
|
134
140
|
};
|
|
135
141
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return () => {
|
|
142
|
+
const dispose = () => {
|
|
139
143
|
if (cleanup) cleanup();
|
|
140
144
|
for (const dep of deps) dep.unsubscribe(execute);
|
|
141
145
|
deps.clear();
|
|
142
146
|
};
|
|
147
|
+
|
|
148
|
+
trackDispose(dispose);
|
|
149
|
+
execute();
|
|
150
|
+
|
|
151
|
+
return dispose;
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
// === Dispose type & scope management ===
|
|
@@ -178,8 +187,7 @@ export function computed<T>(fn: () => T): Signal<T> {
|
|
|
178
187
|
currentDeps = prevDeps;
|
|
179
188
|
}
|
|
180
189
|
const s = new Signal<T>(initial);
|
|
181
|
-
|
|
182
|
-
trackDispose(dispose);
|
|
190
|
+
effect(() => s.set(fn()));
|
|
183
191
|
return s;
|
|
184
192
|
}
|
|
185
193
|
|