@blueshed/railroad 0.2.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 +86 -0
- package/.claude/skills/railroad/jsx.md +325 -0
- package/.claude/skills/railroad/routes.md +157 -0
- package/.claude/skills/railroad/shared.md +94 -0
- package/.claude/skills/railroad/signals.md +218 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/index.ts +18 -0
- package/jsx-dev-runtime.ts +4 -0
- package/jsx-runtime.ts +35 -0
- package/jsx.ts +381 -0
- package/logger.ts +91 -0
- package/package.json +40 -0
- package/routes.ts +110 -0
- package/shared.ts +32 -0
- package/signals.ts +186 -0
package/signals.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signals — Lightweight reactive primitives
|
|
3
|
+
*
|
|
4
|
+
* A standalone reactive system with no framework or DOM dependencies.
|
|
5
|
+
*
|
|
6
|
+
* Core API:
|
|
7
|
+
* signal<T>(value) — create a mutable reactive value
|
|
8
|
+
* computed<T>(fn) — derive a read-only signal from other signals
|
|
9
|
+
* effect(fn) — run a side-effect whenever its dependencies change
|
|
10
|
+
* batch(fn) — group multiple updates into a single flush
|
|
11
|
+
*
|
|
12
|
+
* Signal<T> methods:
|
|
13
|
+
* .get() — read value (tracks dependency when inside effect/computed)
|
|
14
|
+
* .set(value) — write value (notifies listeners if changed via Object.is)
|
|
15
|
+
* .update(fn) — set via transform: s.update(v => v + 1)
|
|
16
|
+
* .mutate(fn) — structuredClone, mutate in place, notify: s.mutate(v => v.items.push(x))
|
|
17
|
+
* .patch(partial) — shallow merge for object signals: s.patch({ name: "new" })
|
|
18
|
+
* .peek() — read value without tracking
|
|
19
|
+
*
|
|
20
|
+
* Dependency tracking:
|
|
21
|
+
* Effects automatically track which signals are read during execution.
|
|
22
|
+
* On re-run, stale subscriptions are removed and new ones added.
|
|
23
|
+
* effect() returns a dispose function that unsubscribes from all deps.
|
|
24
|
+
*
|
|
25
|
+
* Dispose pattern:
|
|
26
|
+
* 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
|
+
*/
|
|
29
|
+
|
|
30
|
+
type Listener = () => void;
|
|
31
|
+
|
|
32
|
+
// Global tracking for effect dependencies
|
|
33
|
+
let currentListener: Listener | null = null;
|
|
34
|
+
let currentDeps: Set<Signal<any>> | null = null;
|
|
35
|
+
let batchDepth = 0;
|
|
36
|
+
const pendingEffects = new Set<Listener>();
|
|
37
|
+
|
|
38
|
+
// Infinite loop guard
|
|
39
|
+
let effectDepth = 0;
|
|
40
|
+
const MAX_EFFECT_DEPTH = 100;
|
|
41
|
+
|
|
42
|
+
// === Signal<T> ===
|
|
43
|
+
|
|
44
|
+
export class Signal<T> {
|
|
45
|
+
private value: T;
|
|
46
|
+
private listeners = new Set<Listener>();
|
|
47
|
+
|
|
48
|
+
constructor(initialValue: T) {
|
|
49
|
+
this.value = initialValue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get(): T {
|
|
53
|
+
if (currentListener) this.listeners.add(currentListener);
|
|
54
|
+
if (currentDeps) currentDeps.add(this);
|
|
55
|
+
return this.value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
set(newValue: T): void {
|
|
59
|
+
if (!Object.is(this.value, newValue)) {
|
|
60
|
+
this.value = newValue;
|
|
61
|
+
this.notify();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
update(fn: (current: T) => T): void {
|
|
66
|
+
this.set(fn(this.value));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
mutate(fn: (current: T) => void): void {
|
|
70
|
+
const copy = structuredClone(this.value);
|
|
71
|
+
fn(copy);
|
|
72
|
+
this.value = copy;
|
|
73
|
+
this.notify();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
patch(partial: Partial<T & Record<string, unknown>>): void {
|
|
77
|
+
this.set({ ...this.value, ...partial } as T);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
peek(): T {
|
|
81
|
+
return this.value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private notify(): void {
|
|
85
|
+
if (batchDepth > 0) {
|
|
86
|
+
for (const listener of this.listeners) pendingEffects.add(listener);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
effectDepth++;
|
|
90
|
+
try {
|
|
91
|
+
if (effectDepth > MAX_EFFECT_DEPTH) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
"Maximum effect depth exceeded — possible infinite loop",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
for (const listener of this.listeners) listener();
|
|
97
|
+
} finally {
|
|
98
|
+
effectDepth--;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
unsubscribe(listener: Listener): void {
|
|
103
|
+
this.listeners.delete(listener);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// === effect() ===
|
|
108
|
+
|
|
109
|
+
export function effect(fn: () => void | (() => void)): () => void {
|
|
110
|
+
let cleanup: (() => void) | void;
|
|
111
|
+
let deps = new Set<Signal<any>>();
|
|
112
|
+
|
|
113
|
+
const execute = () => {
|
|
114
|
+
if (cleanup) cleanup();
|
|
115
|
+
|
|
116
|
+
const prevListener = currentListener;
|
|
117
|
+
const prevDeps = currentDeps;
|
|
118
|
+
const nextDeps = new Set<Signal<any>>();
|
|
119
|
+
currentListener = execute;
|
|
120
|
+
currentDeps = nextDeps;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
cleanup = fn();
|
|
124
|
+
} finally {
|
|
125
|
+
currentListener = prevListener;
|
|
126
|
+
currentDeps = prevDeps;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Unsubscribe from signals no longer read
|
|
130
|
+
for (const dep of deps) {
|
|
131
|
+
if (!nextDeps.has(dep)) dep.unsubscribe(execute);
|
|
132
|
+
}
|
|
133
|
+
deps = nextDeps;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
execute();
|
|
137
|
+
|
|
138
|
+
return () => {
|
|
139
|
+
if (cleanup) cleanup();
|
|
140
|
+
for (const dep of deps) dep.unsubscribe(execute);
|
|
141
|
+
deps.clear();
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// === computed() ===
|
|
146
|
+
|
|
147
|
+
export function computed<T>(fn: () => T): Signal<T> {
|
|
148
|
+
const s = new Signal<T>(fn());
|
|
149
|
+
effect(() => s.set(fn()));
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// === batch() ===
|
|
154
|
+
|
|
155
|
+
let flushing = false;
|
|
156
|
+
|
|
157
|
+
export function batch(fn: () => void): void {
|
|
158
|
+
batchDepth++;
|
|
159
|
+
try {
|
|
160
|
+
fn();
|
|
161
|
+
} finally {
|
|
162
|
+
batchDepth--;
|
|
163
|
+
if (batchDepth === 0 && !flushing) {
|
|
164
|
+
flushing = true;
|
|
165
|
+
try {
|
|
166
|
+
while (pendingEffects.size > 0) {
|
|
167
|
+
const effects = [...pendingEffects];
|
|
168
|
+
pendingEffects.clear();
|
|
169
|
+
for (const e of effects) e();
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
flushing = false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// === Convenience factory ===
|
|
179
|
+
|
|
180
|
+
export function signal<T>(initialValue: T): Signal<T> {
|
|
181
|
+
return new Signal(initialValue);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// === Dispose type ===
|
|
185
|
+
|
|
186
|
+
export type Dispose = () => void;
|