@blueshed/railroad 0.2.6 → 0.3.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 +64 -59
- package/README.md +62 -4
- package/jsx.ts +7 -21
- package/logger.ts +12 -3
- package/package.json +1 -1
- package/routes.ts +43 -10
- package/signals.ts +23 -6
- package/.claude/skills/railroad/jsx.md +0 -325
- package/.claude/skills/railroad/routes.md +0 -157
- package/.claude/skills/railroad/shared.md +0 -94
- package/.claude/skills/railroad/signals.md +0 -218
|
@@ -1,86 +1,91 @@
|
|
|
1
1
|
# Railroad — Skill for Claude
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Micro reactive UI framework for Bun. ~900 lines, zero dependencies, real DOM.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
- Designed for Bun. TypeScript source files are the distribution (no transpile step).
|
|
9
|
-
- Four concerns: **signals** (state), **JSX** (DOM), **routes** (navigation), **shared** (dependency injection).
|
|
12
|
+
## Setup
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
```json
|
|
15
|
+
// tsconfig.json — automatic runtime (recommended)
|
|
16
|
+
{ "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
|
|
17
|
+
```
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
```ts
|
|
20
|
+
// server.ts
|
|
21
|
+
import home from "./index.html";
|
|
22
|
+
Bun.serve({ routes: { "/": home } });
|
|
23
|
+
```
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
## API Quick Reference
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
### Signals (`signals.ts`)
|
|
20
28
|
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
}
|
|
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
|
|
28
34
|
```
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
Signal methods: `.get()` `.set(v)` `.update(fn)` `.mutate(fn)` `.patch(partial)` `.peek()`
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
Dispose scopes: `pushDisposeScope()` `popDisposeScope(): Dispose` `trackDispose(fn)`
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
### JSX (`jsx.ts`)
|
|
41
|
+
|
|
42
|
+
Components are functions called **once**. Reactivity comes from signals, not re-rendering.
|
|
43
|
+
|
|
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
|
|
42
51
|
```
|
|
43
52
|
|
|
44
|
-
|
|
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.
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
SVG: `<svg>` children are auto-adopted into SVG namespace.
|
|
56
|
+
|
|
57
|
+
### Routes (`routes.ts`)
|
|
47
58
|
|
|
48
59
|
```ts
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
51
64
|
```
|
|
52
65
|
|
|
53
|
-
|
|
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`).
|
|
67
|
+
|
|
68
|
+
### Shared (`shared.ts`)
|
|
54
69
|
|
|
55
70
|
```ts
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
import { signal, computed, effect, batch } from "@blueshed/railroad/signals";
|
|
61
|
-
import { createElement, Fragment, text, when, list } from "@blueshed/railroad/jsx";
|
|
62
|
-
import { routes, navigate, route, matchRoute } from "@blueshed/railroad/routes";
|
|
63
|
-
import { key, provide, inject } from "@blueshed/railroad/shared";
|
|
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)
|
|
64
75
|
```
|
|
65
76
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
Detailed usage for each concern is in the sibling files. Read them before generating railroad code:
|
|
77
|
+
### Logger (`logger.ts`)
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
83
|
+
```
|
|
74
84
|
|
|
75
|
-
## Anti-Patterns
|
|
85
|
+
## Anti-Patterns
|
|
76
86
|
|
|
77
|
-
1. **No React
|
|
78
|
-
2. **No
|
|
79
|
-
3. **
|
|
80
|
-
4. **No JSX without
|
|
81
|
-
5. **
|
|
82
|
-
6. **Do not use `text()` for attributes.** `text()` creates a DOM text node — use `computed()` for reactive attribute values: `class={computed(() => active.get() ? "on" : "off")}`.
|
|
83
|
-
7. **Do not create signals inside components** unless you want fresh state on every mount. Module-level signals are shared state; component-level signals are local/ephemeral.
|
|
84
|
-
8. **Do not forget `batch()`** when setting multiple signals that feed the same effect — without it, the effect runs once per set.
|
|
85
|
-
9. **Do not rebuild JSX in effects without dispose scopes.** Any effect that does `container.innerHTML = ""; container.appendChild(<JSX/>)` **must** use the full dispose scope pattern — see `jsx.md` Dispose Scopes section. Both the re-run cleanup (`if (childDispose) childDispose()` at top) AND the effect cleanup return (`return () => { ... }`) are required. Without the return, child effects leak when the parent is torn down.
|
|
86
|
-
10. **Do not use `transition-all` in CSS** for elements near layout boundaries (cards, panels). Use specific properties like `transition-colors` or `transition-[width,border-color]` to avoid animating layout properties unintentionally.
|
|
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.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Signals, JSX, and routes — a micro UI framework for Bun.
|
|
4
4
|
|
|
5
|
-
~
|
|
5
|
+
~900 lines. Zero dependencies. Real DOM. No virtual DOM, no compiler, no build step.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -178,14 +178,20 @@ function Greeting() {
|
|
|
178
178
|
|
|
179
179
|
### Routes
|
|
180
180
|
|
|
181
|
-
Hash-based client router with automatic dispose scoping.
|
|
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.
|
|
182
182
|
|
|
183
183
|
```tsx
|
|
184
|
-
import { routes, navigate } from "@blueshed/railroad";
|
|
184
|
+
import { routes, navigate, effect, text } from "@blueshed/railroad";
|
|
185
185
|
|
|
186
186
|
const dispose = routes(app, {
|
|
187
187
|
"/": () => <Home />,
|
|
188
|
-
|
|
188
|
+
// Simple — destructure params as before:
|
|
189
|
+
"/about": () => <About />,
|
|
190
|
+
// Reactive — watch params$ for same-pattern navigation (/users/1 → /users/2):
|
|
191
|
+
"/users/:id": ({ id }, params$) => {
|
|
192
|
+
effect(() => fetchUser(params$.get().id));
|
|
193
|
+
return <h1>{text(() => `User ${params$.get().id}`)}</h1>;
|
|
194
|
+
},
|
|
189
195
|
"*": () => <NotFound />,
|
|
190
196
|
});
|
|
191
197
|
|
|
@@ -204,6 +210,26 @@ provide(STORE, createStore());
|
|
|
204
210
|
|
|
205
211
|
// anywhere:
|
|
206
212
|
const store = inject(STORE);
|
|
213
|
+
|
|
214
|
+
// Non-throwing variant:
|
|
215
|
+
const maybeStore = tryInject(STORE); // T | undefined
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Logger
|
|
219
|
+
|
|
220
|
+
Colored, timestamped, level-gated console output.
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { createLogger, setLogLevel, loggedRequest } from "@blueshed/railroad";
|
|
224
|
+
|
|
225
|
+
const log = createLogger("[server]");
|
|
226
|
+
log.info("listening on :3000");
|
|
227
|
+
log.debug("tick"); // only shown when level is "debug"
|
|
228
|
+
|
|
229
|
+
setLogLevel("debug"); // show everything
|
|
230
|
+
|
|
231
|
+
// Wrap a route handler with access logging:
|
|
232
|
+
const handler = loggedRequest("[api]", myHandler);
|
|
207
233
|
```
|
|
208
234
|
|
|
209
235
|
## Design
|
|
@@ -215,6 +241,38 @@ const store = inject(STORE);
|
|
|
215
241
|
|
|
216
242
|
No lifecycle methods. No hooks rules. No context providers. No `useCallback`. Just signals and the DOM.
|
|
217
243
|
|
|
244
|
+
## Progressive Adoption
|
|
245
|
+
|
|
246
|
+
Each module is independent — use as much or as little as you need.
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
signals.ts ← no deps Use signals anywhere: server, CLI, worker
|
|
250
|
+
shared.ts ← no deps Add typed DI when you need shared state
|
|
251
|
+
logger.ts ← no deps Add logging to your Bun server
|
|
252
|
+
jsx.ts ← signals Add reactive DOM when you need a UI
|
|
253
|
+
routes.ts ← signals Add client-side routing when you need pages
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Level 1 — Reactive state only** (no DOM, no tsconfig changes)
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { signal, computed, effect } from "@blueshed/railroad/signals";
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**Level 2 — Add JSX** (needs `tsconfig.json` JSX settings)
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { signal, createElement, when, list } from "@blueshed/railroad";
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Level 3 — Full app** (signals + JSX + routing + DI + logging)
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
import { signal, routes, inject, createLogger } from "@blueshed/railroad";
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Every import path (`/signals`, `/shared`, `/logger`, `/jsx`, `/routes`) works standalone. The barrel export (`@blueshed/railroad`) re-exports everything.
|
|
275
|
+
|
|
218
276
|
## Claude Code
|
|
219
277
|
|
|
220
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:
|
package/jsx.ts
CHANGED
|
@@ -23,32 +23,16 @@
|
|
|
23
23
|
* text(fn) — reactive text from computed expression
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { Signal, signal, effect, computed } from "./signals";
|
|
26
|
+
import { Signal, signal, effect, computed, pushDisposeScope, popDisposeScope, trackDispose } from "./signals";
|
|
27
27
|
import type { Dispose } from "./signals";
|
|
28
28
|
|
|
29
|
+
export { pushDisposeScope, popDisposeScope };
|
|
30
|
+
|
|
29
31
|
// === SVG namespace ===
|
|
30
32
|
|
|
31
33
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
32
34
|
const storedProps = new WeakMap<Element, Record<string, any>>();
|
|
33
35
|
|
|
34
|
-
// === Dispose scope management ===
|
|
35
|
-
|
|
36
|
-
const disposeStack: Dispose[][] = [];
|
|
37
|
-
|
|
38
|
-
export function pushDisposeScope(): void {
|
|
39
|
-
disposeStack.push([]);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function popDisposeScope(): Dispose {
|
|
43
|
-
const disposers = disposeStack.pop() || [];
|
|
44
|
-
return () => disposers.forEach((d) => d());
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function trackDispose(d: Dispose): void {
|
|
48
|
-
const scope = disposeStack[disposeStack.length - 1];
|
|
49
|
-
if (scope) scope.push(d);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
36
|
// === Fragment ===
|
|
53
37
|
|
|
54
38
|
export function Fragment(props: any): DocumentFragment {
|
|
@@ -84,8 +68,10 @@ function applyProps(el: Element, props: Record<string, any>): void {
|
|
|
84
68
|
} else {
|
|
85
69
|
(el as any)[key] = value;
|
|
86
70
|
}
|
|
87
|
-
} else if (key === "style" &&
|
|
88
|
-
Object.assign((el as
|
|
71
|
+
} else if (key === "style" && value instanceof Signal) {
|
|
72
|
+
trackDispose(effect(() => { Object.assign((el as HTMLElement).style, value.get()); }));
|
|
73
|
+
} else if (key === "style" && typeof value === "object") {
|
|
74
|
+
Object.assign((el as HTMLElement).style, value);
|
|
89
75
|
} else if (key.startsWith("on")) {
|
|
90
76
|
el.addEventListener(key.slice(2).toLowerCase(), value);
|
|
91
77
|
} else {
|
package/logger.ts
CHANGED
|
@@ -69,6 +69,14 @@ export function createLogger(tag: string) {
|
|
|
69
69
|
type Handler = (req: Request) => Response | Promise<Response>;
|
|
70
70
|
|
|
71
71
|
/** Wrap a route handler with access logging. */
|
|
72
|
+
function safePathname(url: string): string {
|
|
73
|
+
try {
|
|
74
|
+
return new URL(url).pathname;
|
|
75
|
+
} catch {
|
|
76
|
+
return url;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
export function loggedRequest(tag: string, handler: Handler): Handler {
|
|
73
81
|
const log = createLogger(tag);
|
|
74
82
|
return async (req: Request) => {
|
|
@@ -77,13 +85,14 @@ export function loggedRequest(tag: string, handler: Handler): Handler {
|
|
|
77
85
|
const res = await handler(req);
|
|
78
86
|
const ms = (performance.now() - start).toFixed(1);
|
|
79
87
|
log.info(
|
|
80
|
-
`${req.method} ${
|
|
88
|
+
`${req.method} ${safePathname(req.url)} → ${res.status} (${ms}ms)`,
|
|
81
89
|
);
|
|
82
90
|
return res;
|
|
83
|
-
} catch (err:
|
|
91
|
+
} catch (err: unknown) {
|
|
84
92
|
const ms = (performance.now() - start).toFixed(1);
|
|
93
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
94
|
log.error(
|
|
86
|
-
`${req.method} ${
|
|
95
|
+
`${req.method} ${safePathname(req.url)} threw (${ms}ms): ${msg}`,
|
|
87
96
|
);
|
|
88
97
|
throw err;
|
|
89
98
|
}
|
package/package.json
CHANGED
package/routes.ts
CHANGED
|
@@ -7,29 +7,46 @@
|
|
|
7
7
|
* navigate(path) — set location.hash programmatically
|
|
8
8
|
* matchRoute(pattern, path) — pure pattern matcher, returns params or null
|
|
9
9
|
*
|
|
10
|
-
* Handlers
|
|
10
|
+
* Handlers receive (params, params$) and return a Node.
|
|
11
|
+
* params — plain object for destructuring: ({ id }) => ...
|
|
12
|
+
* params$ — Signal that updates when params change within the same pattern
|
|
13
|
+
*
|
|
14
|
+
* The router manages cleanup automatically. When params change within the
|
|
15
|
+
* same pattern (e.g. /users/1 → /users/2), params$ updates — no teardown.
|
|
11
16
|
* routes(app, {
|
|
12
17
|
* "/": () => <Home />,
|
|
13
|
-
* "/site/:id": ({ id }) => <SiteDetail id={id} />,
|
|
18
|
+
* "/site/:id": ({ id }, params$) => <SiteDetail id={id} params$={params$} />,
|
|
14
19
|
* });
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
|
-
import { Signal, computed, effect } from "./signals";
|
|
22
|
+
import { Signal, signal, computed, effect, pushDisposeScope, popDisposeScope } from "./signals";
|
|
18
23
|
import type { Dispose } from "./signals";
|
|
19
|
-
import { pushDisposeScope, popDisposeScope } from "./jsx";
|
|
20
24
|
|
|
21
25
|
let hashSignal: Signal<string> | null = null;
|
|
26
|
+
let hashListenerCount = 0;
|
|
27
|
+
let hashListener: (() => void) | null = null;
|
|
22
28
|
|
|
23
29
|
function getHash(): Signal<string> {
|
|
24
30
|
if (!hashSignal) {
|
|
25
31
|
hashSignal = new Signal(location.hash.slice(1) || "/");
|
|
26
|
-
|
|
32
|
+
hashListener = () => {
|
|
27
33
|
hashSignal!.set(location.hash.slice(1) || "/");
|
|
28
|
-
}
|
|
34
|
+
};
|
|
35
|
+
window.addEventListener("hashchange", hashListener);
|
|
29
36
|
}
|
|
37
|
+
hashListenerCount++;
|
|
30
38
|
return hashSignal;
|
|
31
39
|
}
|
|
32
40
|
|
|
41
|
+
function releaseHash(): void {
|
|
42
|
+
hashListenerCount--;
|
|
43
|
+
if (hashListenerCount === 0 && hashListener) {
|
|
44
|
+
window.removeEventListener("hashchange", hashListener);
|
|
45
|
+
hashListener = null;
|
|
46
|
+
hashSignal = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
export function matchRoute(
|
|
34
51
|
pattern: string,
|
|
35
52
|
path: string,
|
|
@@ -61,7 +78,10 @@ export function navigate(path: string): void {
|
|
|
61
78
|
location.hash = path;
|
|
62
79
|
}
|
|
63
80
|
|
|
64
|
-
type RouteHandler = (
|
|
81
|
+
type RouteHandler = (
|
|
82
|
+
params: Record<string, string>,
|
|
83
|
+
params$: Signal<Record<string, string>>,
|
|
84
|
+
) => Node;
|
|
65
85
|
|
|
66
86
|
export function routes(
|
|
67
87
|
target: HTMLElement,
|
|
@@ -69,28 +89,35 @@ export function routes(
|
|
|
69
89
|
): Dispose {
|
|
70
90
|
const hash = getHash();
|
|
71
91
|
let activePattern: string | null = null;
|
|
92
|
+
let activeParams: Signal<Record<string, string>> | null = null;
|
|
72
93
|
let activeDispose: Dispose | null = null;
|
|
73
94
|
|
|
74
95
|
function teardown() {
|
|
75
96
|
if (activeDispose) activeDispose();
|
|
76
97
|
activeDispose = null;
|
|
77
98
|
activePattern = null;
|
|
99
|
+
activeParams = null;
|
|
78
100
|
target.replaceChildren();
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
function run(handler: RouteHandler, params: Record<string, string>) {
|
|
104
|
+
activeParams = signal(params);
|
|
82
105
|
pushDisposeScope();
|
|
83
|
-
const node = handler(params);
|
|
106
|
+
const node = handler(params, activeParams);
|
|
84
107
|
activeDispose = popDisposeScope();
|
|
85
108
|
target.appendChild(node);
|
|
86
109
|
}
|
|
87
110
|
|
|
88
|
-
|
|
111
|
+
const disposeEffect = effect(() => {
|
|
89
112
|
const path = hash.get();
|
|
90
113
|
for (const [pattern, handler] of Object.entries(table)) {
|
|
91
114
|
const params = matchRoute(pattern, path);
|
|
92
115
|
if (params) {
|
|
93
|
-
if (pattern === activePattern)
|
|
116
|
+
if (pattern === activePattern) {
|
|
117
|
+
// Same pattern, different params — update the signal
|
|
118
|
+
activeParams!.set(params);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
94
121
|
teardown();
|
|
95
122
|
activePattern = pattern;
|
|
96
123
|
run(handler, params);
|
|
@@ -107,4 +134,10 @@ export function routes(
|
|
|
107
134
|
}
|
|
108
135
|
teardown();
|
|
109
136
|
});
|
|
137
|
+
|
|
138
|
+
return () => {
|
|
139
|
+
disposeEffect();
|
|
140
|
+
teardown();
|
|
141
|
+
releaseHash();
|
|
142
|
+
};
|
|
110
143
|
}
|
package/signals.ts
CHANGED
|
@@ -88,7 +88,7 @@ export class Signal<T> {
|
|
|
88
88
|
}
|
|
89
89
|
effectDepth++;
|
|
90
90
|
try {
|
|
91
|
-
if (effectDepth
|
|
91
|
+
if (effectDepth >= MAX_EFFECT_DEPTH) {
|
|
92
92
|
throw new Error(
|
|
93
93
|
"Maximum effect depth exceeded — possible infinite loop",
|
|
94
94
|
);
|
|
@@ -142,11 +142,32 @@ export function effect(fn: () => void | (() => void)): () => void {
|
|
|
142
142
|
};
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// === Dispose type & scope management ===
|
|
146
|
+
|
|
147
|
+
export type Dispose = () => void;
|
|
148
|
+
|
|
149
|
+
const disposeStack: Dispose[][] = [];
|
|
150
|
+
|
|
151
|
+
export function pushDisposeScope(): void {
|
|
152
|
+
disposeStack.push([]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function popDisposeScope(): Dispose {
|
|
156
|
+
const disposers = disposeStack.pop() || [];
|
|
157
|
+
return () => disposers.forEach((d) => d());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function trackDispose(d: Dispose): void {
|
|
161
|
+
const scope = disposeStack[disposeStack.length - 1];
|
|
162
|
+
if (scope) scope.push(d);
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
// === computed() ===
|
|
146
166
|
|
|
147
167
|
export function computed<T>(fn: () => T): Signal<T> {
|
|
148
168
|
const s = new Signal<T>(fn());
|
|
149
|
-
effect(() => s.set(fn()));
|
|
169
|
+
const dispose = effect(() => s.set(fn()));
|
|
170
|
+
trackDispose(dispose);
|
|
150
171
|
return s;
|
|
151
172
|
}
|
|
152
173
|
|
|
@@ -180,7 +201,3 @@ export function batch(fn: () => void): void {
|
|
|
180
201
|
export function signal<T>(initialValue: T): Signal<T> {
|
|
181
202
|
return new Signal(initialValue);
|
|
182
203
|
}
|
|
183
|
-
|
|
184
|
-
// === Dispose type ===
|
|
185
|
-
|
|
186
|
-
export type Dispose = () => void;
|
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
# JSX — Real DOM Elements Backed by Signals
|
|
2
|
-
|
|
3
|
-
Railroad's JSX produces real DOM nodes, not a virtual DOM. Components are plain functions called once. Reactivity comes from signals, not re-rendering.
|
|
4
|
-
|
|
5
|
-
## JSX Setup
|
|
6
|
-
|
|
7
|
-
Railroad supports two JSX modes. Check the project's `tsconfig.json` to know which is in use.
|
|
8
|
-
|
|
9
|
-
### Automatic runtime (recommended)
|
|
10
|
-
|
|
11
|
-
```json
|
|
12
|
-
{ "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
No imports needed — the compiler auto-inserts the runtime. Just write JSX:
|
|
16
|
-
|
|
17
|
-
```tsx
|
|
18
|
-
import { signal } from "@blueshed/railroad";
|
|
19
|
-
const count = signal(0);
|
|
20
|
-
function App() {
|
|
21
|
-
return <div>{count}</div>; // works, no createElement import
|
|
22
|
-
}
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
### Classic runtime
|
|
26
|
-
|
|
27
|
-
```json
|
|
28
|
-
{ "jsx": "react", "jsxFactory": "createElement", "jsxFragmentFactory": "Fragment" }
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Every `.tsx` file must import `createElement` (and `Fragment` for `<>...</>`):
|
|
32
|
-
|
|
33
|
-
```tsx
|
|
34
|
-
import { createElement, Fragment } from "@blueshed/railroad";
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Components
|
|
38
|
-
|
|
39
|
-
A component is a function that receives props and returns a DOM Node. It runs **once** per mount.
|
|
40
|
-
|
|
41
|
-
```tsx
|
|
42
|
-
function Greeting({ name }: { name: string }) {
|
|
43
|
-
return <h1>Hello {name}</h1>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Usage:
|
|
47
|
-
<Greeting name="World" />
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### Children
|
|
51
|
-
|
|
52
|
-
Children are passed as part of props:
|
|
53
|
-
|
|
54
|
-
```tsx
|
|
55
|
-
function Card({ children }: { children: any[] }) {
|
|
56
|
-
return <div class="card">{children}</div>;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
<Card><p>Content</p></Card>
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### Components Run Once
|
|
63
|
-
|
|
64
|
-
This is the most important thing to understand. Unlike React, the component function body executes once. To make things reactive, use signals:
|
|
65
|
-
|
|
66
|
-
```tsx
|
|
67
|
-
// WRONG — static, never updates:
|
|
68
|
-
function Counter() {
|
|
69
|
-
let count = 0;
|
|
70
|
-
return <p>{count}</p>; // always shows 0
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// RIGHT — reactive via signal:
|
|
74
|
-
function Counter() {
|
|
75
|
-
const count = signal(0);
|
|
76
|
-
return (
|
|
77
|
-
<div>
|
|
78
|
-
<p>{count}</p> {/* auto-updates when count changes */}
|
|
79
|
-
<button onclick={() => count.update(n => n + 1)}>+</button>
|
|
80
|
-
</div>
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
## Props and Attributes
|
|
86
|
-
|
|
87
|
-
### Event Handlers
|
|
88
|
-
|
|
89
|
-
Lowercase `on` prefix, directly on the element. The handler name after `on` is lowercased and passed to `addEventListener`.
|
|
90
|
-
|
|
91
|
-
```tsx
|
|
92
|
-
<button onclick={() => doSomething()}>Click</button>
|
|
93
|
-
<input oninput={(e) => query.set(e.target.value)} />
|
|
94
|
-
<form onsubmit={(e) => { e.preventDefault(); save(); }}>
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
### Class
|
|
98
|
-
|
|
99
|
-
Use `class` or `className` — both work. Signals are supported:
|
|
100
|
-
|
|
101
|
-
```tsx
|
|
102
|
-
<div class="active">static</div>
|
|
103
|
-
<div class={activeClass}>reactive</div> {/* activeClass is a Signal<string> */}
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
For derived/conditional class values, use `computed()`:
|
|
107
|
-
|
|
108
|
-
```tsx
|
|
109
|
-
import { computed } from "@blueshed/railroad";
|
|
110
|
-
|
|
111
|
-
<button class={computed(() => `btn ${active.get() ? "active" : ""}`)}>
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
**Do NOT use `text()` for attributes** — `text()` creates a text DOM node, not a string. Use `computed()` for any reactive attribute value (class, style, data-*, etc.).
|
|
115
|
-
|
|
116
|
-
### Style
|
|
117
|
-
|
|
118
|
-
Pass a plain object (not a signal) to set inline styles:
|
|
119
|
-
|
|
120
|
-
```tsx
|
|
121
|
-
<div style={{ color: "red", fontSize: "16px" }}>styled</div>
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
### DOM Properties
|
|
125
|
-
|
|
126
|
-
These are set as element properties (not attributes) and support signals: `value`, `checked`, `disabled`, `selected`, `src`, `srcdoc`.
|
|
127
|
-
|
|
128
|
-
```tsx
|
|
129
|
-
const inputValue = signal("");
|
|
130
|
-
<input value={inputValue} oninput={(e) => inputValue.set(e.target.value)} />
|
|
131
|
-
|
|
132
|
-
const isDisabled = signal(false);
|
|
133
|
-
<button disabled={isDisabled}>Submit</button>
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### innerHTML
|
|
137
|
-
|
|
138
|
-
Set raw HTML (supports signals):
|
|
139
|
-
|
|
140
|
-
```tsx
|
|
141
|
-
<div innerHTML={htmlSignal} />
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
### Ref
|
|
145
|
-
|
|
146
|
-
Get a reference to the underlying DOM element:
|
|
147
|
-
|
|
148
|
-
```tsx
|
|
149
|
-
<input ref={(el) => el.focus()} />
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
The ref callback fires immediately after element creation.
|
|
153
|
-
|
|
154
|
-
### Regular Attributes
|
|
155
|
-
|
|
156
|
-
Anything not matching the above is set via `setAttribute`. Signals auto-update. `false` and `null`/`undefined` remove the attribute.
|
|
157
|
-
|
|
158
|
-
```tsx
|
|
159
|
-
<a href="/about">About</a>
|
|
160
|
-
<div data-id={itemId}>...</div> {/* itemId can be a Signal */}
|
|
161
|
-
<input aria-label={label} />
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
## Signals as Children
|
|
165
|
-
|
|
166
|
-
Pass a signal directly as a child to create a reactive text node:
|
|
167
|
-
|
|
168
|
-
```tsx
|
|
169
|
-
const count = signal(0);
|
|
170
|
-
<p>Count: {count}</p> // updates automatically
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
**Do NOT call `.get()` in JSX children** — this evaluates once and creates a static string:
|
|
174
|
-
|
|
175
|
-
```tsx
|
|
176
|
-
// WRONG — static text, never updates:
|
|
177
|
-
<p>Count: {count.get()}</p>
|
|
178
|
-
|
|
179
|
-
// RIGHT — reactive:
|
|
180
|
-
<p>Count: {count}</p>
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
## Null, Boolean, and Undefined Children
|
|
184
|
-
|
|
185
|
-
`null`, `undefined`, `true`, and `false` are ignored (not rendered). Use this for conditional inline content:
|
|
186
|
-
|
|
187
|
-
```tsx
|
|
188
|
-
<div>{showWarning.peek() && <span>Warning!</span>}</div>
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
But prefer `when()` for reactive conditionals — see below.
|
|
192
|
-
|
|
193
|
-
## `text(fn)` — Reactive Computed Text
|
|
194
|
-
|
|
195
|
-
For expressions more complex than a single signal, use `text()`:
|
|
196
|
-
|
|
197
|
-
```tsx
|
|
198
|
-
import { text } from "@blueshed/railroad";
|
|
199
|
-
|
|
200
|
-
<span>{text(() => count.get() > 5 ? "High" : "Low")}</span>
|
|
201
|
-
<p>{text(() => `${firstName.get()} ${lastName.get()}`)}</p>
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
`text()` creates a computed signal internally and keeps its text node updated.
|
|
205
|
-
|
|
206
|
-
## `when(condition, truthy, falsy?)` — Conditional Rendering
|
|
207
|
-
|
|
208
|
-
Swaps DOM nodes when the truthiness of the condition changes.
|
|
209
|
-
|
|
210
|
-
```tsx
|
|
211
|
-
import { when } from "@blueshed/railroad";
|
|
212
|
-
|
|
213
|
-
// With a signal:
|
|
214
|
-
{when(isLoggedIn, () => <Dashboard />, () => <Login />)}
|
|
215
|
-
|
|
216
|
-
// With a function (becomes a computed internally):
|
|
217
|
-
{when(() => user.get() !== null, () => <Profile />, () => <SignIn />)}
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
### Key Behavior
|
|
221
|
-
|
|
222
|
-
- Swaps **only on truthiness transitions** (falsy to truthy, or truthy to falsy).
|
|
223
|
-
- Value changes within the same branch (e.g., user changes from one user to another — both truthy) do **not** re-render the branch.
|
|
224
|
-
- Components inside each branch should read signals to react to value changes.
|
|
225
|
-
- Each branch gets its own dispose scope — effects created inside are cleaned up on swap.
|
|
226
|
-
|
|
227
|
-
## `list(items, keyFn?, render)` — Keyed List Rendering
|
|
228
|
-
|
|
229
|
-
Renders a reactive list with DOM diffing by key.
|
|
230
|
-
|
|
231
|
-
```tsx
|
|
232
|
-
import { list, text } from "@blueshed/railroad";
|
|
233
|
-
|
|
234
|
-
const todos = signal([
|
|
235
|
-
{ id: 1, text: "Buy milk" },
|
|
236
|
-
{ id: 2, text: "Walk dog" },
|
|
237
|
-
]);
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
### Keyed form (recommended)
|
|
241
|
-
|
|
242
|
-
The render function receives **`Signal<T>`** and **`Signal<number>`** — not raw values. When the array updates and an existing key's value changes, the item signal is updated in place, so effects inside the rendered DOM react automatically without recreating the node.
|
|
243
|
-
|
|
244
|
-
```tsx
|
|
245
|
-
{list(todos, (t) => t.id, (todo, idx) =>
|
|
246
|
-
<li class={computed(() => todo.get().done ? "done" : "")}>
|
|
247
|
-
{text(() => todo.get().text)}
|
|
248
|
-
</li>
|
|
249
|
-
)}
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
Use `.get()` inside `text()`, `computed()`, or `effect()` to subscribe to item changes. Use `.peek()` for one-off reads.
|
|
253
|
-
|
|
254
|
-
### Non-keyed form (index-based, raw values)
|
|
255
|
-
|
|
256
|
-
The render function receives the raw item value and index number. Items are recreated when the array changes.
|
|
257
|
-
|
|
258
|
-
```tsx
|
|
259
|
-
{list(todos, (t, i) => <li>{t.text}</li>)}
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
### How It Works
|
|
263
|
-
|
|
264
|
-
- New items: rendered and inserted.
|
|
265
|
-
- Removed items: disposed and removed from DOM.
|
|
266
|
-
- Reordered items: moved in the DOM (not re-created).
|
|
267
|
-
- **Keyed: changed items** update the existing item `Signal`, triggering reactive updates inside the node.
|
|
268
|
-
- Each item gets its own dispose scope.
|
|
269
|
-
|
|
270
|
-
## `Fragment` — Grouping Without a Wrapper
|
|
271
|
-
|
|
272
|
-
```tsx
|
|
273
|
-
import { createElement, Fragment } from "@blueshed/railroad";
|
|
274
|
-
|
|
275
|
-
function Columns() {
|
|
276
|
-
return (
|
|
277
|
-
<>
|
|
278
|
-
<td>First</td>
|
|
279
|
-
<td>Second</td>
|
|
280
|
-
</>
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
## Dispose Scopes
|
|
286
|
-
|
|
287
|
-
The JSX layer manages cleanup automatically via dispose scopes. When `routes()` or `when()` swap content, all effects created during that content's rendering are disposed. You can also manage scopes manually:
|
|
288
|
-
|
|
289
|
-
```tsx
|
|
290
|
-
import { pushDisposeScope, popDisposeScope } from "@blueshed/railroad";
|
|
291
|
-
|
|
292
|
-
pushDisposeScope();
|
|
293
|
-
// ... create elements, effects ...
|
|
294
|
-
const dispose = popDisposeScope();
|
|
295
|
-
|
|
296
|
-
// later, clean up everything:
|
|
297
|
-
dispose();
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
This is **required** when an effect creates JSX that may contain child effects, event handlers, or WebSocket listeners. Every effect that clears a container and appends new JSX **must** use this pattern:
|
|
301
|
-
|
|
302
|
-
```tsx
|
|
303
|
-
let childDispose: (() => void) | null = null;
|
|
304
|
-
|
|
305
|
-
effect(() => {
|
|
306
|
-
const data = mySignal.get();
|
|
307
|
-
if (childDispose) { childDispose(); childDispose = null; }
|
|
308
|
-
container.innerHTML = "";
|
|
309
|
-
pushDisposeScope();
|
|
310
|
-
container.appendChild(<ChildComponent data={data} /> as Node);
|
|
311
|
-
childDispose = popDisposeScope();
|
|
312
|
-
return () => { if (childDispose) { childDispose(); childDispose = null; } };
|
|
313
|
-
});
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
### Why the cleanup return matters
|
|
317
|
-
|
|
318
|
-
The effect handles two lifecycle events:
|
|
319
|
-
|
|
320
|
-
1. **Re-run** — when a tracked signal changes, the effect re-runs. The `if (childDispose)` call at the top cleans up the previous child scope before creating a new one.
|
|
321
|
-
2. **Dispose** — when the effect itself is stopped (parent scope torn down, route change, `when()` swap), the cleanup function returned from the effect disposes the child scope. **Without this return, child effects leak when the parent is removed.**
|
|
322
|
-
|
|
323
|
-
### Common mistake
|
|
324
|
-
|
|
325
|
-
The most frequent porting bug is forgetting dispose scopes in effects that rebuild DOM. Any effect that does `container.innerHTML = ""; container.appendChild(<SomeJSX /> as Node)` needs this pattern. Static JSX with no signals, effects, or event listeners inside does not need it, but when in doubt, wrap it.
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
# Routes — Hash-Based Client Router
|
|
2
|
-
|
|
3
|
-
Railroad uses hash-based routing (`#/path`). The router swaps content in a target element and automatically manages dispose scoping — when a route changes, all effects from the previous route are cleaned up.
|
|
4
|
-
|
|
5
|
-
## `routes(target, table)` — Declarative Router
|
|
6
|
-
|
|
7
|
-
The main entry point. Pass a DOM element and a route table. Returns a dispose function to stop the router.
|
|
8
|
-
|
|
9
|
-
```tsx
|
|
10
|
-
import { createElement, routes } from "@blueshed/railroad";
|
|
11
|
-
|
|
12
|
-
const app = document.getElementById("app")!;
|
|
13
|
-
|
|
14
|
-
const dispose = routes(app, {
|
|
15
|
-
"/": () => <Home />,
|
|
16
|
-
"/users/:id": ({ id }) => <UserDetail id={id} />,
|
|
17
|
-
"/settings": () => <Settings />,
|
|
18
|
-
"*": () => <NotFound />,
|
|
19
|
-
});
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
### Route Table
|
|
23
|
-
|
|
24
|
-
- Keys are URL patterns (without the `#`).
|
|
25
|
-
- Values are handler functions that receive matched params and return a Node.
|
|
26
|
-
- Patterns are matched in declaration order — first match wins.
|
|
27
|
-
- `*` is a catch-all, checked last.
|
|
28
|
-
|
|
29
|
-
### Pattern Syntax
|
|
30
|
-
|
|
31
|
-
- **Exact match:** `/about` matches only `#/about`
|
|
32
|
-
- **Params:** `/users/:id` matches `#/users/42` with `{ id: "42" }`
|
|
33
|
-
- **Multiple params:** `/users/:id/posts/:pid` matches `#/users/1/posts/99` with `{ id: "1", pid: "99" }`
|
|
34
|
-
- **Catch-all:** `*` matches anything not matched by other patterns
|
|
35
|
-
|
|
36
|
-
Segments must match exactly in count — `/users/:id` does not match `/users/42/extra`.
|
|
37
|
-
|
|
38
|
-
URI components are automatically decoded (`%20` becomes a space, etc.).
|
|
39
|
-
|
|
40
|
-
### Automatic Cleanup
|
|
41
|
-
|
|
42
|
-
When the hash changes to a new route:
|
|
43
|
-
|
|
44
|
-
1. The previous route's dispose scope is called (cleaning up all effects).
|
|
45
|
-
2. The target element is cleared (`replaceChildren()`).
|
|
46
|
-
3. A new dispose scope is pushed.
|
|
47
|
-
4. The new handler runs, creating DOM and effects.
|
|
48
|
-
5. The dispose scope is popped and stored for next cleanup.
|
|
49
|
-
|
|
50
|
-
If the hash changes but the **same pattern** matches (e.g., `/users/1` to `/users/2`), the route does **not** re-render. The component should use signals or `route()` to react to param changes.
|
|
51
|
-
|
|
52
|
-
## `navigate(path)` — Programmatic Navigation
|
|
53
|
-
|
|
54
|
-
```ts
|
|
55
|
-
import { navigate } from "@blueshed/railroad";
|
|
56
|
-
|
|
57
|
-
navigate("/users/42"); // sets location.hash = "/users/42"
|
|
58
|
-
navigate("/"); // go home
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
Use this in event handlers, after form submissions, etc. It triggers a `hashchange` event which the router picks up.
|
|
62
|
-
|
|
63
|
-
### Navigation Links
|
|
64
|
-
|
|
65
|
-
For `<a>` tags, use hash hrefs directly:
|
|
66
|
-
|
|
67
|
-
```tsx
|
|
68
|
-
<a href="#/users/42">View User</a>
|
|
69
|
-
<a href="#/settings">Settings</a>
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
Or use `navigate()` in an onclick:
|
|
73
|
-
|
|
74
|
-
```tsx
|
|
75
|
-
<button onclick={() => navigate(`/users/${id}`)}>View</button>
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## `route(pattern)` — Reactive Route Signal
|
|
79
|
-
|
|
80
|
-
Returns a `Signal<T | null>` that is non-null when the pattern matches the current hash, null otherwise. Useful for components that need to react to route changes without being inside the router.
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
import { route } from "@blueshed/railroad";
|
|
84
|
-
|
|
85
|
-
const userRoute = route<{ id: string }>("/users/:id");
|
|
86
|
-
|
|
87
|
-
effect(() => {
|
|
88
|
-
const params = userRoute.get();
|
|
89
|
-
if (params) {
|
|
90
|
-
console.log(`Viewing user ${params.id}`);
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
This is useful for:
|
|
96
|
-
- Highlighting the active nav link.
|
|
97
|
-
- Loading data when params change within the same route pattern.
|
|
98
|
-
- Reacting to route changes from anywhere in the app.
|
|
99
|
-
|
|
100
|
-
## `matchRoute(pattern, path)` — Pure Pattern Matcher
|
|
101
|
-
|
|
102
|
-
A utility function with no side effects. Returns params object on match, `null` on no match.
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
import { matchRoute } from "@blueshed/railroad";
|
|
106
|
-
|
|
107
|
-
matchRoute("/users/:id", "/users/42"); // { id: "42" }
|
|
108
|
-
matchRoute("/users/:id", "/users/42/extra"); // null (segment count mismatch)
|
|
109
|
-
matchRoute("/about", "/about"); // {}
|
|
110
|
-
matchRoute("/about", "/home"); // null
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
## Handling Param Changes Within a Route
|
|
114
|
-
|
|
115
|
-
When navigating from `/users/1` to `/users/2`, the router sees the same pattern (`/users/:id`) and does **not** re-render. The component must handle this itself:
|
|
116
|
-
|
|
117
|
-
```tsx
|
|
118
|
-
import { createElement, route, when, signal, effect } from "@blueshed/railroad";
|
|
119
|
-
|
|
120
|
-
function UserDetail({ id }: { id: string }) {
|
|
121
|
-
// Option 1: Use route() signal to track param changes
|
|
122
|
-
const userRoute = route<{ id: string }>("/users/:id");
|
|
123
|
-
const user = signal<User | null>(null);
|
|
124
|
-
|
|
125
|
-
effect(() => {
|
|
126
|
-
const params = userRoute.get();
|
|
127
|
-
if (params) {
|
|
128
|
-
fetchUser(params.id).then(u => user.set(u));
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
return when(user, () => <div>{user.get()!.name}</div>);
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
## Server-Side Pattern
|
|
137
|
-
|
|
138
|
-
Railroad's route tables mirror Bun.serve's route syntax. A typical app has both:
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
// server.ts — Bun serves the HTML shell
|
|
142
|
-
import home from "./index.html";
|
|
143
|
-
Bun.serve({
|
|
144
|
-
routes: {
|
|
145
|
-
"/": home,
|
|
146
|
-
"/api/users": () => Response.json(users),
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// app.tsx — client-side routing inside the shell
|
|
151
|
-
routes(document.getElementById("app")!, {
|
|
152
|
-
"/": () => <Home />,
|
|
153
|
-
"/users/:id": ({ id }) => <UserDetail id={id} />,
|
|
154
|
-
});
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
Resources and routes. Server and client. Same pattern.
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# Shared — Typed Dependency Injection
|
|
2
|
-
|
|
3
|
-
A minimal provide/inject system for sharing values across modules without prop threading. Uses typed symbol keys for safety.
|
|
4
|
-
|
|
5
|
-
## Creating a Key
|
|
6
|
-
|
|
7
|
-
```ts
|
|
8
|
-
import { key } from "@blueshed/railroad";
|
|
9
|
-
|
|
10
|
-
const STORE = key<AppStore>("store");
|
|
11
|
-
const THEME = key<Signal<string>>("theme");
|
|
12
|
-
const API = key<ApiClient>("api");
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
`key<T>(name)` creates a unique symbol branded with type `T`. The name is for debugging (shows in error messages).
|
|
16
|
-
|
|
17
|
-
## Providing a Value
|
|
18
|
-
|
|
19
|
-
```ts
|
|
20
|
-
import { provide } from "@blueshed/railroad";
|
|
21
|
-
|
|
22
|
-
provide(STORE, createStore());
|
|
23
|
-
provide(THEME, signal("light"));
|
|
24
|
-
provide(API, new ApiClient("/api"));
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Call `provide()` during initialization, before any component calls `inject()`. Values are stored in a global registry.
|
|
28
|
-
|
|
29
|
-
## Injecting a Value
|
|
30
|
-
|
|
31
|
-
```ts
|
|
32
|
-
import { inject } from "@blueshed/railroad";
|
|
33
|
-
|
|
34
|
-
const store = inject(STORE); // typed as AppStore
|
|
35
|
-
const theme = inject(THEME); // typed as Signal<string>
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
If no value has been provided for the key, `inject()` throws:
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
Error: No provider for store
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Typical Pattern
|
|
45
|
-
|
|
46
|
-
Define keys in a shared module, provide at app init, inject anywhere:
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
// keys.ts — shared key definitions
|
|
50
|
-
import { key } from "@blueshed/railroad";
|
|
51
|
-
import type { Signal } from "@blueshed/railroad";
|
|
52
|
-
|
|
53
|
-
export interface AppStore {
|
|
54
|
-
user: Signal<User | null>;
|
|
55
|
-
todos: Signal<Todo[]>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export const STORE = key<AppStore>("store");
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
```ts
|
|
62
|
-
// main.ts — provide at startup
|
|
63
|
-
import { provide, signal } from "@blueshed/railroad";
|
|
64
|
-
import { STORE } from "./keys";
|
|
65
|
-
|
|
66
|
-
provide(STORE, {
|
|
67
|
-
user: signal(null),
|
|
68
|
-
todos: signal([]),
|
|
69
|
-
});
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
```tsx
|
|
73
|
-
// any-component.tsx — inject where needed
|
|
74
|
-
import { createElement } from "@blueshed/railroad";
|
|
75
|
-
import { inject } from "@blueshed/railroad";
|
|
76
|
-
import { STORE } from "./keys";
|
|
77
|
-
|
|
78
|
-
function TodoList() {
|
|
79
|
-
const { todos } = inject(STORE);
|
|
80
|
-
return list(todos, (t) => t.id, (t) => <li>{t.text}</li>);
|
|
81
|
-
}
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
## When to Use Shared vs Props
|
|
85
|
-
|
|
86
|
-
- **Props** — for data that flows parent-to-child and varies per instance.
|
|
87
|
-
- **Shared** — for app-wide services, stores, or configuration that many components need. Avoids threading the same value through every intermediate component.
|
|
88
|
-
|
|
89
|
-
## Important Notes
|
|
90
|
-
|
|
91
|
-
- The registry is global and module-scoped. There is one registry per JavaScript realm.
|
|
92
|
-
- Keys are symbols, so two calls to `key("store")` produce **different** keys. Export and import a single key instance.
|
|
93
|
-
- `provide()` can be called again with the same key to replace the value.
|
|
94
|
-
- `inject()` is synchronous — the value must already be provided.
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
# Signals — Reactive State
|
|
2
|
-
|
|
3
|
-
Signals are the foundation of railroad. Everything reactive flows from them.
|
|
4
|
-
|
|
5
|
-
## Creating Signals
|
|
6
|
-
|
|
7
|
-
```ts
|
|
8
|
-
import { signal } from "@blueshed/railroad";
|
|
9
|
-
|
|
10
|
-
const count = signal(0); // Signal<number>
|
|
11
|
-
const name = signal("World"); // Signal<string>
|
|
12
|
-
const items = signal<string[]>([]); // Signal<string[]>
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Reading and Writing
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
count.get() // read (tracks dependency inside effect/computed)
|
|
19
|
-
count.set(5) // write (notifies listeners if value changed)
|
|
20
|
-
count.update(n => n + 1) // transform and write (caller must return a new value)
|
|
21
|
-
count.mutate(v => ...) // clone, mutate in place, notify (see below)
|
|
22
|
-
count.patch({ key: v }) // shallow merge for object signals (see below)
|
|
23
|
-
count.peek() // read WITHOUT tracking — use for one-off reads outside effects
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
### Equality Check
|
|
27
|
-
|
|
28
|
-
`set()` uses `Object.is()` to compare old and new values. If they are the same, no listeners are notified. This means:
|
|
29
|
-
|
|
30
|
-
- Primitives: setting the same number/string/boolean is a no-op.
|
|
31
|
-
- Objects/arrays: you must create a new reference to trigger updates.
|
|
32
|
-
|
|
33
|
-
```ts
|
|
34
|
-
const todos = signal([{ id: 1, text: "Buy milk" }]);
|
|
35
|
-
|
|
36
|
-
// WRONG — same array reference, no update:
|
|
37
|
-
todos.peek().push({ id: 2, text: "Walk dog" });
|
|
38
|
-
todos.set(todos.peek()); // Object.is says same reference, listeners NOT notified
|
|
39
|
-
|
|
40
|
-
// RIGHT — new array:
|
|
41
|
-
todos.update(arr => [...arr, { id: 2, text: "Walk dog" }]);
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
### `mutate(fn)` — In-Place Mutation
|
|
45
|
-
|
|
46
|
-
`mutate()` clones the current value with `structuredClone`, passes the clone to your function for in-place mutation, then notifies listeners. Use it when you want to modify objects or arrays naturally without manually creating a new reference.
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
const todos = signal([{ id: 1, text: "Buy milk" }]);
|
|
50
|
-
|
|
51
|
-
// Append:
|
|
52
|
-
todos.mutate(arr => arr.push({ id: 2, text: "Walk dog" }));
|
|
53
|
-
|
|
54
|
-
// Modify nested property:
|
|
55
|
-
const doc = signal({ title: "Draft", meta: { tags: ["a"] } });
|
|
56
|
-
doc.mutate(d => d.meta.tags.push("b"));
|
|
57
|
-
|
|
58
|
-
// Toggle in a Set:
|
|
59
|
-
const selected = signal(new Set([1, 2, 3]));
|
|
60
|
-
selected.mutate(s => s.has(4) ? s.delete(4) : s.add(4));
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
`mutate()` always notifies listeners (the clone guarantees a new reference). Use `update()` when you can return a new value cheaply; use `mutate()` when in-place mutation is more natural.
|
|
64
|
-
|
|
65
|
-
### `patch(partial)` — Shallow Merge
|
|
66
|
-
|
|
67
|
-
`patch()` does a shallow merge for object signals — equivalent to `set({ ...current, ...partial })`:
|
|
68
|
-
|
|
69
|
-
```ts
|
|
70
|
-
const filter = signal({ color: "red", size: 10, active: true });
|
|
71
|
-
|
|
72
|
-
filter.patch({ color: "blue" }); // { color: "blue", size: 10, active: true }
|
|
73
|
-
filter.patch({ size: 20, active: false }); // { color: "blue", size: 20, active: false }
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Like `set()`, `patch()` uses `Object.is` — since the spread always creates a new reference, listeners are always notified.
|
|
77
|
-
|
|
78
|
-
## Computed Signals
|
|
79
|
-
|
|
80
|
-
Derived read-only signals that auto-update when dependencies change.
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
import { computed } from "@blueshed/railroad";
|
|
84
|
-
|
|
85
|
-
const firstName = signal("John");
|
|
86
|
-
const lastName = signal("Doe");
|
|
87
|
-
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
|
|
88
|
-
|
|
89
|
-
fullName.get(); // "John Doe"
|
|
90
|
-
firstName.set("Jane");
|
|
91
|
-
fullName.get(); // "Jane Doe"
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Computed signals chain:
|
|
95
|
-
|
|
96
|
-
```ts
|
|
97
|
-
const a = signal(1);
|
|
98
|
-
const b = computed(() => a.get() + 1); // 2
|
|
99
|
-
const c = computed(() => b.get() * 10); // 20
|
|
100
|
-
a.set(3);
|
|
101
|
-
c.get(); // 40
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
## Effects
|
|
105
|
-
|
|
106
|
-
Run a function whenever its signal dependencies change. The function runs immediately on creation.
|
|
107
|
-
|
|
108
|
-
```ts
|
|
109
|
-
import { effect } from "@blueshed/railroad";
|
|
110
|
-
|
|
111
|
-
const count = signal(0);
|
|
112
|
-
|
|
113
|
-
const dispose = effect(() => {
|
|
114
|
-
console.log(`count is ${count.get()}`);
|
|
115
|
-
});
|
|
116
|
-
// logs: "count is 0"
|
|
117
|
-
|
|
118
|
-
count.set(1);
|
|
119
|
-
// logs: "count is 1"
|
|
120
|
-
|
|
121
|
-
dispose(); // stop listening
|
|
122
|
-
count.set(2); // nothing logged
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
### Cleanup Functions
|
|
126
|
-
|
|
127
|
-
An effect can return a cleanup function. It runs before each re-execution and on dispose.
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
effect(() => {
|
|
131
|
-
const id = setInterval(() => console.log(count.get()), 1000);
|
|
132
|
-
return () => clearInterval(id); // cleanup before re-run or on dispose
|
|
133
|
-
});
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### Automatic Dependency Tracking
|
|
137
|
-
|
|
138
|
-
Effects only track signals read during execution. If a branch is not taken, those signals are not tracked:
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
const showDetail = signal(true);
|
|
142
|
-
const summary = signal("short");
|
|
143
|
-
const detail = signal("long");
|
|
144
|
-
|
|
145
|
-
effect(() => {
|
|
146
|
-
if (showDetail.get()) {
|
|
147
|
-
console.log(detail.get()); // tracked
|
|
148
|
-
} else {
|
|
149
|
-
console.log(summary.get()); // tracked only when showDetail is false
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// When showDetail is true, changing summary does NOT re-run the effect.
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
### Infinite Loop Protection
|
|
157
|
-
|
|
158
|
-
Effects are guarded against infinite loops (max depth 100). If an effect sets a signal that triggers itself in a cycle, it throws:
|
|
159
|
-
|
|
160
|
-
```
|
|
161
|
-
Error: Maximum effect depth exceeded — possible infinite loop
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
## Batch
|
|
165
|
-
|
|
166
|
-
Group multiple signal writes so effects run only once at the end.
|
|
167
|
-
|
|
168
|
-
```ts
|
|
169
|
-
import { batch } from "@blueshed/railroad";
|
|
170
|
-
|
|
171
|
-
const a = signal(1);
|
|
172
|
-
const b = signal(2);
|
|
173
|
-
|
|
174
|
-
effect(() => {
|
|
175
|
-
console.log(a.get() + b.get());
|
|
176
|
-
});
|
|
177
|
-
// logs: 3
|
|
178
|
-
|
|
179
|
-
batch(() => {
|
|
180
|
-
a.set(10);
|
|
181
|
-
b.set(20);
|
|
182
|
-
});
|
|
183
|
-
// logs: 30 (once, not twice)
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
Batches nest safely — effects flush only when the outermost batch completes.
|
|
187
|
-
|
|
188
|
-
## Dispose Pattern
|
|
189
|
-
|
|
190
|
-
`effect()` returns a dispose function. Call it to stop the effect and run its cleanup.
|
|
191
|
-
|
|
192
|
-
The JSX layer manages dispose scopes automatically — effects created during component rendering are collected and disposed when the component is removed (e.g., by the router or `when()`). You rarely need to manage dispose manually unless you create effects outside of JSX rendering.
|
|
193
|
-
|
|
194
|
-
```ts
|
|
195
|
-
// Manual dispose (outside JSX):
|
|
196
|
-
const dispose = effect(() => { ... });
|
|
197
|
-
// later:
|
|
198
|
-
dispose();
|
|
199
|
-
|
|
200
|
-
// Inside JSX components, effects are auto-collected.
|
|
201
|
-
// The router or when() calls dispose when swapping content.
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
## Where to Declare Signals
|
|
205
|
-
|
|
206
|
-
- **Module level** — shared state, lives for the app lifetime. Good for stores, global UI state.
|
|
207
|
-
- **Inside a component** — local state, created fresh each time the component mounts. Disposed when the component is removed.
|
|
208
|
-
|
|
209
|
-
```ts
|
|
210
|
-
// Module level — shared, persistent
|
|
211
|
-
const currentUser = signal<User | null>(null);
|
|
212
|
-
|
|
213
|
-
// Component level — local, ephemeral
|
|
214
|
-
function SearchBox() {
|
|
215
|
-
const query = signal("");
|
|
216
|
-
return <input value={query} oninput={(e) => query.set(e.target.value)} />;
|
|
217
|
-
}
|
|
218
|
-
```
|