@blueshed/railroad 0.3.3 → 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.
@@ -1,94 +1,60 @@
1
- # Railroad — Skill for Claude
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
- **Source files are the canonical reference.** Each has a JSDoc header with full API docs. Read the source when you need detail:
6
- - `signals.ts` signal, computed, effect, batch, dispose scopes
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 — automatic runtime (recommended)
14
+ // tsconfig.json
16
15
  { "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
17
16
  ```
18
17
 
19
- ```ts
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 are functions called **once**. Reactivity comes from signals, not re-rendering.
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
- ```ts
45
- createElement(tag, props, ...children): Node
46
- Fragment(props): DocumentFragment
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
- ```ts
60
- routes(target, table): Dispose // hash router, auto-disposes on swap
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
- ### Shared (`shared.ts`)
69
-
70
- ```ts
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
- ### Logger (`logger.ts`)
78
-
79
- ```ts
80
- createLogger(tag): { info, warn, error, debug }
81
- setLogLevel(level): void // "error" | "warn" | "info" | "debug"
82
- loggedRequest(tag, handler): Handler // wrap route handler with access logging
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, memo, or react imports.
88
- 2. **No `.get()` in JSX children.** `<p>{count}</p>` is reactive. `<p>{count.get()}</p>` is static.
89
- 3. **No `text()` for attributes.** `text()` creates a DOM node. Use `computed()` for reactive attributes.
90
- 4. **No JSX in effects without dispose scopes.** Any effect that rebuilds DOM must use `pushDisposeScope`/`popDisposeScope` and return a cleanup function. See `jsx.ts` source for the pattern.
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. Signals in children and props auto-update.
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 { createElement, text, when, list, signal } from "@blueshed/railroad";
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
- #### `text(fn)`reactive computed text
150
+ #### Reactive expressions function children
150
151
 
151
152
  ```tsx
152
- <span>{text(() => count.get() > 5 ? "High" : "Low")}</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
- todos,
171
- (t) => t.id,
172
- (todo, idx) => <li>{text(() => todo.get().name)}</li>,
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 with automatic dispose scoping. Handlers receive `(params, params$)` — destructure the first for convenience, watch the second for reactive param changes.
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, effect, text } from "@blueshed/railroad";
195
+ import { routes, navigate, route, when, effect } from "@blueshed/railroad";
185
196
 
186
- const dispose = routes(app, {
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>{text(() => `User ${params$.get().id}`)}</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, return cleanup
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, dispose-scoped, Bun.serve-style tables
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 — including API usage, patterns, and anti-patterns:
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
@@ -5,8 +5,7 @@ export type { Dispose } from "./signals";
5
5
 
6
6
  export {
7
7
  createElement, Fragment,
8
- text, when, list,
9
- pushDisposeScope, popDisposeScope,
8
+ when, list,
10
9
  } from "./jsx";
11
10
 
12
11
  export { routes, route, navigate, matchRoute } from "./routes";
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
- export { pushDisposeScope, popDisposeScope };
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
- trackDispose(effect(() => { el.innerHTML = value.get(); }));
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
- trackDispose(effect(() => { el.setAttribute("class", value.get()); }));
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
- trackDispose(effect(() => { (el as any)[key] = value.get(); }));
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
- trackDispose(effect(() => { Object.assign((el as HTMLElement).style, value.get()); }));
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
- trackDispose(effect(() => {
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
- return tag(componentProps);
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
- trackDispose(effect(() => {
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 current: Node | null = null;
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
- if (current && anchor.parentNode) {
222
- anchor.parentNode.removeChild(current);
223
- }
225
+ for (const n of currentNodes) n.parentNode?.removeChild(n);
226
+ currentNodes = [];
224
227
 
225
228
  pushDisposeScope();
226
- current = isTruthy ? truthy() : (falsy ? falsy() : null);
229
+ const result = isTruthy ? truthy() : (falsy ? falsy() : null);
227
230
  currentDispose = popDisposeScope();
228
231
 
229
- if (current && anchor.parentNode) {
230
- anchor.parentNode.insertBefore(current, anchor.nextSibling);
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
- trackDispose(effect(() => {
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, index) => <li>{text(() => item.get().name)}</li>)
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 = { node: Node; dispose: Dispose; item?: Signal<T>; index?: Signal<number> };
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.node.parentNode?.removeChild(entry.node);
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.node.parentNode?.removeChild(entry.node);
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 node: Node;
324
+ let result: Node;
314
325
  if (hasKeyFn) {
315
326
  const itemSig = signal(arr[i]!);
316
327
  const indexSig = signal(i);
317
- node = maybeRender!(itemSig, indexSig);
328
+ result = maybeRender!(itemSig, indexSig);
318
329
  const dispose = popDisposeScope();
319
- entry = { node, dispose, item: itemSig, index: indexSig };
330
+ entry = { nodes: collectNodes(result), dispose, item: itemSig, index: indexSig };
320
331
  } else {
321
- node = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
332
+ result = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
322
333
  const dispose = popDisposeScope();
323
- entry = { node, dispose };
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 oldNode = entry.node;
343
+ const oldNodes = entry.nodes;
333
344
  entry.dispose();
334
345
  pushDisposeScope();
335
- const node = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
346
+ const result = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
336
347
  const dispose = popDisposeScope();
337
- entry = { node, dispose };
348
+ const nodes = collectNodes(result);
349
+ entry = { nodes, dispose };
338
350
  entries.set(key, entry);
339
- if (oldNode.parentNode) oldNode.parentNode.replaceChild(node, oldNode);
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
- if (entry.node.nextSibling !== insertBefore) {
344
- parent.insertBefore(entry.node, insertBefore);
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.node;
366
+ insertBefore = entry.nodes[0] ?? insertBefore;
347
367
  }
348
368
 
349
369
  order = newKeys;
350
370
  }
351
371
 
352
- trackDispose(effect(() => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueshed/railroad",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Signals, JSX, and routes — a micro UI framework for Bun",
5
5
  "type": "module",
6
6
  "main": "index.ts",
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
- * "/site/:id": ({ id }, params$) => <SiteDetail id={id} params$={params$} />,
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
- if (pp.length !== hp.length) return null;
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]!.startsWith(":")) {
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
- return () => {
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
- * Components should collect dispose functions and return a combined Dispose.
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
- execute();
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 ===
@@ -165,9 +174,20 @@ export function trackDispose(d: Dispose): void {
165
174
  // === computed() ===
166
175
 
167
176
  export function computed<T>(fn: () => T): Signal<T> {
168
- const s = new Signal<T>(fn());
169
- const dispose = effect(() => s.set(fn()));
170
- trackDispose(dispose);
177
+ // Suspend outer tracking during initial evaluation so computed()
178
+ // inside list() render functions doesn't leak subscriptions to the
179
+ // list effect's listener — which causes infinite sync re-entry.
180
+ const prevListener = currentListener;
181
+ const prevDeps = currentDeps;
182
+ currentListener = null;
183
+ currentDeps = null;
184
+ let initial: T;
185
+ try { initial = fn(); } finally {
186
+ currentListener = prevListener;
187
+ currentDeps = prevDeps;
188
+ }
189
+ const s = new Signal<T>(initial);
190
+ effect(() => s.set(fn()));
171
191
  return s;
172
192
  }
173
193