@sigil-dev/runtime 0.4.0 → 0.5.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/boundary.ts +44 -44
- package/index.ts +444 -444
- package/package.json +1 -1
package/boundary.ts
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Error boundary for Sigil components.
|
|
3
|
-
*
|
|
4
|
-
* Wraps children so that errors during rendering are caught
|
|
5
|
-
* and handled instead of crashing the app.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* <Boundary
|
|
9
|
-
* fallbackError={({ error, reset }) => <button onClick={reset}>Retry</button>}
|
|
10
|
-
* onError={(e) => reportToSentry(e)}
|
|
11
|
-
* >
|
|
12
|
-
* <FlakyComponent />
|
|
13
|
-
* </Boundary>
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createSignal } from "./index.js";
|
|
17
|
-
|
|
18
|
-
interface BoundaryProps {
|
|
19
|
-
children: any;
|
|
20
|
-
fallbackError?: (ctx: { error: Error; reset: () => void }) => any;
|
|
21
|
-
onError?: (error: Error) => void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function Boundary(props: BoundaryProps): any {
|
|
25
|
-
const error = createSignal<Error | null>(null);
|
|
26
|
-
|
|
27
|
-
const reset = () => error.set(null);
|
|
28
|
-
|
|
29
|
-
if (error()) {
|
|
30
|
-
return props.fallbackError
|
|
31
|
-
? props.fallbackError({ error: error()!, reset })
|
|
32
|
-
: null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
return props.children;
|
|
37
|
-
} catch (e) {
|
|
38
|
-
props.onError?.(e as Error);
|
|
39
|
-
error.set(e as Error);
|
|
40
|
-
return props.fallbackError
|
|
41
|
-
? props.fallbackError({ error: e as Error, reset })
|
|
42
|
-
: null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Error boundary for Sigil components.
|
|
3
|
+
*
|
|
4
|
+
* Wraps children so that errors during rendering are caught
|
|
5
|
+
* and handled instead of crashing the app.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <Boundary
|
|
9
|
+
* fallbackError={({ error, reset }) => <button onClick={reset}>Retry</button>}
|
|
10
|
+
* onError={(e) => reportToSentry(e)}
|
|
11
|
+
* >
|
|
12
|
+
* <FlakyComponent />
|
|
13
|
+
* </Boundary>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createSignal } from "./index.js";
|
|
17
|
+
|
|
18
|
+
interface BoundaryProps {
|
|
19
|
+
children: any;
|
|
20
|
+
fallbackError?: (ctx: { error: Error; reset: () => void }) => any;
|
|
21
|
+
onError?: (error: Error) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Boundary(props: BoundaryProps): any {
|
|
25
|
+
const error = createSignal<Error | null>(null);
|
|
26
|
+
|
|
27
|
+
const reset = () => error.set(null);
|
|
28
|
+
|
|
29
|
+
if (error()) {
|
|
30
|
+
return props.fallbackError
|
|
31
|
+
? props.fallbackError({ error: error()!, reset })
|
|
32
|
+
: null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return props.children;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
props.onError?.(e as Error);
|
|
39
|
+
error.set(e as Error);
|
|
40
|
+
return props.fallbackError
|
|
41
|
+
? props.fallbackError({ error: e as Error, reset })
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,444 +1,444 @@
|
|
|
1
|
-
export const SIGIL_SAFE = Symbol.for("sigil.safe");
|
|
2
|
-
export class SafeHtml {
|
|
3
|
-
[SIGIL_SAFE] = true;
|
|
4
|
-
constructor(public v: string) { }
|
|
5
|
-
toString() {
|
|
6
|
-
return this.v;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
let currentEffect: (() => void) | null = null;
|
|
11
|
-
let currentDeps: Set<Set<() => void>> | null = null;
|
|
12
|
-
|
|
13
|
-
let batchDepth = 0;
|
|
14
|
-
const pendingEffects = new Set<() => void>();
|
|
15
|
-
|
|
16
|
-
const targetDepsMap = new WeakMap<
|
|
17
|
-
object,
|
|
18
|
-
Map<string | symbol, Set<() => void>>
|
|
19
|
-
>();
|
|
20
|
-
|
|
21
|
-
function getDeps(target: object, key: string | symbol): Set<() => void> {
|
|
22
|
-
let deps = targetDepsMap.get(target);
|
|
23
|
-
if (!deps) {
|
|
24
|
-
deps = new Map();
|
|
25
|
-
targetDepsMap.set(target, deps);
|
|
26
|
-
}
|
|
27
|
-
let set = deps.get(key);
|
|
28
|
-
if (!set) {
|
|
29
|
-
set = new Set();
|
|
30
|
-
deps.set(key, set);
|
|
31
|
-
}
|
|
32
|
-
return set;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function track(target: object, key: string | symbol) {
|
|
36
|
-
if (currentEffect && currentDeps) {
|
|
37
|
-
const deps = getDeps(target, key);
|
|
38
|
-
deps.add(currentEffect);
|
|
39
|
-
currentDeps.add(deps); // Set, deduplicates automatically
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function trigger(target: object, key: string | symbol) {
|
|
44
|
-
const deps = targetDepsMap.get(target);
|
|
45
|
-
if (!deps) return;
|
|
46
|
-
const set = deps.get(key);
|
|
47
|
-
if (!set) return;
|
|
48
|
-
for (const fn of [...set]) {
|
|
49
|
-
if (batchDepth > 0) pendingEffects.add(fn);
|
|
50
|
-
else fn();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const proxyCache = new WeakMap<object, object>();
|
|
55
|
-
const reactiveTargets = new WeakSet<object>();
|
|
56
|
-
|
|
57
|
-
function createReactiveProxy<T extends object>(target: T): T {
|
|
58
|
-
const existing = proxyCache.get(target);
|
|
59
|
-
if (existing) return existing as T;
|
|
60
|
-
|
|
61
|
-
const proxy = new Proxy(target, {
|
|
62
|
-
get(target, key, receiver) {
|
|
63
|
-
if (key === "set") {
|
|
64
|
-
return (newValue: T) => {
|
|
65
|
-
const oldKeys = new Set(Object.keys(target));
|
|
66
|
-
const newKeys = new Set(Object.keys(newValue));
|
|
67
|
-
for (const k of oldKeys) {
|
|
68
|
-
if (!newKeys.has(k)) {
|
|
69
|
-
delete (target as any)[k];
|
|
70
|
-
trigger(target, k);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
for (const k of newKeys) {
|
|
74
|
-
(target as any)[k] = (newValue as any)[k];
|
|
75
|
-
trigger(target, k);
|
|
76
|
-
}
|
|
77
|
-
if (Array.isArray(target)) {
|
|
78
|
-
trigger(target, "length");
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (key === "peek") {
|
|
84
|
-
return () => target;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (typeof key === "symbol") {
|
|
88
|
-
return Reflect.get(target, key, receiver);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
track(target, key);
|
|
92
|
-
const value = target[key as keyof T];
|
|
93
|
-
|
|
94
|
-
if (typeof value === "object" && value !== null) {
|
|
95
|
-
return createReactiveProxy(value);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return value;
|
|
99
|
-
},
|
|
100
|
-
|
|
101
|
-
set(target, key, newValue, receiver) {
|
|
102
|
-
const oldValue = target[key as keyof T];
|
|
103
|
-
const result = Reflect.set(target, key, newValue, receiver);
|
|
104
|
-
if (oldValue !== newValue) {
|
|
105
|
-
trigger(target, key as string | symbol);
|
|
106
|
-
if (
|
|
107
|
-
Array.isArray(target) &&
|
|
108
|
-
typeof key === "string" &&
|
|
109
|
-
!Number.isNaN(Number(key))
|
|
110
|
-
) {
|
|
111
|
-
trigger(target, "length");
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return result;
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
deleteProperty(target, key) {
|
|
118
|
-
const result = Reflect.deleteProperty(target, key);
|
|
119
|
-
trigger(target, key as string | symbol);
|
|
120
|
-
return result;
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
proxyCache.set(target, proxy);
|
|
125
|
-
reactiveTargets.add(target);
|
|
126
|
-
return proxy as T;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ─── createSignal (branches on type) ───
|
|
130
|
-
|
|
131
|
-
export type Signal<T> = {
|
|
132
|
-
(): T;
|
|
133
|
-
set: (next: T) => void;
|
|
134
|
-
peek: () => T;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
export function batch(fn: () => void) {
|
|
138
|
-
batchDepth++;
|
|
139
|
-
fn();
|
|
140
|
-
batchDepth--;
|
|
141
|
-
if (batchDepth === 0) {
|
|
142
|
-
// biome-ignore lint/suspicious/useIterableCallbackReturn: effects returing cleanup is intended
|
|
143
|
-
pendingEffects.forEach((e) => e());
|
|
144
|
-
pendingEffects.clear();
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function createSignal<T>(initial: T): Signal<T> {
|
|
149
|
-
if (typeof initial === "object" && initial !== null) {
|
|
150
|
-
const proxy = createReactiveProxy(initial);
|
|
151
|
-
const getter = () => proxy;
|
|
152
|
-
getter.set = (next: T) => (proxy as any).set(next);
|
|
153
|
-
getter.peek = () => initial;
|
|
154
|
-
return getter;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const subs = new Set<() => void>();
|
|
158
|
-
let value = initial;
|
|
159
|
-
|
|
160
|
-
const getter = () => {
|
|
161
|
-
if (currentEffect && currentDeps) {
|
|
162
|
-
subs.add(currentEffect);
|
|
163
|
-
currentDeps.add(subs);
|
|
164
|
-
}
|
|
165
|
-
return value;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
getter.set = (next: T) => {
|
|
169
|
-
value = next;
|
|
170
|
-
for (const fn of [...subs]) {
|
|
171
|
-
if (batchDepth > 0) pendingEffects.add(fn);
|
|
172
|
-
else fn();
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
getter.peek = () => value;
|
|
177
|
-
|
|
178
|
-
return getter;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// biome-ignore lint/suspicious/noConfusingVoidType: please shut up its a weird type
|
|
182
|
-
export function createEffect(fn: () => void | (() => void)): () => void {
|
|
183
|
-
let cleanup: (() => void) | void;
|
|
184
|
-
const ownDeps = new Set<Set<() => void>>();
|
|
185
|
-
|
|
186
|
-
const effect = () => {
|
|
187
|
-
if (cleanup) cleanup();
|
|
188
|
-
for (const depSet of ownDeps) depSet.delete(effect);
|
|
189
|
-
ownDeps.clear();
|
|
190
|
-
|
|
191
|
-
const prevEffect = currentEffect;
|
|
192
|
-
const prevDeps = currentDeps;
|
|
193
|
-
currentEffect = effect;
|
|
194
|
-
currentDeps = ownDeps;
|
|
195
|
-
try {
|
|
196
|
-
cleanup = fn();
|
|
197
|
-
} finally {
|
|
198
|
-
currentEffect = prevEffect;
|
|
199
|
-
currentDeps = prevDeps;
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
effect();
|
|
204
|
-
|
|
205
|
-
const dispose = () => {
|
|
206
|
-
for (const depSet of ownDeps) depSet.delete(effect);
|
|
207
|
-
ownDeps.clear();
|
|
208
|
-
if (cleanup) cleanup();
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
((globalThis as any).__sigilEffectScope as (() => void)[] | undefined)?.push(dispose);
|
|
212
|
-
return dispose;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Run fn() and return a dispose function that tears down every effect
|
|
217
|
-
* created during that call (including effects inside child createMemo calls).
|
|
218
|
-
* Supports nesting: inner scopes don't leak to outer ones.
|
|
219
|
-
*/
|
|
220
|
-
export function withEffectScope(fn: () => void): () => void {
|
|
221
|
-
const prev = (globalThis as any).__sigilEffectScope;
|
|
222
|
-
const disposers: (() => void)[] = [];
|
|
223
|
-
(globalThis as any).__sigilEffectScope = disposers;
|
|
224
|
-
try {
|
|
225
|
-
fn();
|
|
226
|
-
} finally {
|
|
227
|
-
(globalThis as any).__sigilEffectScope = prev;
|
|
228
|
-
}
|
|
229
|
-
return () => {
|
|
230
|
-
for (const d of disposers) d();
|
|
231
|
-
disposers.length = 0;
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function createMemo<T>(fn: () => T): Signal<T> {
|
|
236
|
-
const inner = createSignal<T>(undefined as T);
|
|
237
|
-
createEffect(() => inner.set(fn()));
|
|
238
|
-
return inner;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Claim an existing element from a hydration pool by tag name, or create a new one.
|
|
243
|
-
* Removes the claimed node from the pool array.
|
|
244
|
-
* When falling back to createElement, appends to parent if provided (SPA mode).
|
|
245
|
-
*/
|
|
246
|
-
export function claim(
|
|
247
|
-
nodes: ChildNode[],
|
|
248
|
-
tag: string,
|
|
249
|
-
parent?: Element,
|
|
250
|
-
): Element {
|
|
251
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
252
|
-
const node = nodes[i];
|
|
253
|
-
if (
|
|
254
|
-
node instanceof Element &&
|
|
255
|
-
node.tagName.toLowerCase() === tag.toLowerCase()
|
|
256
|
-
) {
|
|
257
|
-
nodes.splice(i, 1);
|
|
258
|
-
return node;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
const el = document.createElement(tag);
|
|
262
|
-
if (parent) parent.appendChild(el);
|
|
263
|
-
return el;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Claim an existing Text node from a hydration pool, or create a new empty one.
|
|
268
|
-
* Removes the claimed node from the pool array.
|
|
269
|
-
* When falling back to createTextNode, appends to parent if provided (SPA mode).
|
|
270
|
-
*/
|
|
271
|
-
export function claimText(nodes: ChildNode[], parent?: Element): Text {
|
|
272
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
273
|
-
const node = nodes[i];
|
|
274
|
-
if (node instanceof Text) {
|
|
275
|
-
nodes.splice(i, 1);
|
|
276
|
-
return node;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
const text = document.createTextNode("");
|
|
280
|
-
if (parent) parent.appendChild(text);
|
|
281
|
-
return text;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Claim an existing Comment node from a hydration pool, or create a new one.
|
|
286
|
-
* Removes the claimed node from the pool array.
|
|
287
|
-
* When falling back to createComment, appends to parent if provided (SPA mode).
|
|
288
|
-
*/
|
|
289
|
-
export function claimComment(
|
|
290
|
-
nodes: ChildNode[],
|
|
291
|
-
text: string = "",
|
|
292
|
-
parent?: Element,
|
|
293
|
-
): Comment {
|
|
294
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
295
|
-
const node = nodes[i];
|
|
296
|
-
if (node instanceof Comment && node.data === text) {
|
|
297
|
-
nodes.splice(i, 1);
|
|
298
|
-
return node;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
const comment = document.createComment(text);
|
|
302
|
-
if (parent) parent.appendChild(comment);
|
|
303
|
-
return comment;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Reconcile a list of keyed DOM nodes.
|
|
308
|
-
* anchor.before(node) handles both insert and move natively.
|
|
309
|
-
* @param claimNode - Optional hydration callback. Called with (element, key) for
|
|
310
|
-
* server-rendered elements removed from the DOM. Returns the element to use,
|
|
311
|
-
* or null to discard.
|
|
312
|
-
*/
|
|
313
|
-
export function reconcile<T>(
|
|
314
|
-
anchor: Partial<Comment>,
|
|
315
|
-
keyMap: Map<unknown, Node>,
|
|
316
|
-
newItems: T[],
|
|
317
|
-
getKey: (item: T) => unknown,
|
|
318
|
-
createNode: (item: T) => Node,
|
|
319
|
-
// is this meant to be unused? claimNode?: (element: Element, key: unknown) => Element | null,
|
|
320
|
-
): void {
|
|
321
|
-
const newKeys = new Set(newItems.map(getKey));
|
|
322
|
-
|
|
323
|
-
// Remove nodes no longer in list (or claim them for hydration)
|
|
324
|
-
for (const [key, node] of keyMap) {
|
|
325
|
-
if (!newKeys.has(key)) {
|
|
326
|
-
node.parentNode?.removeChild(node);
|
|
327
|
-
keyMap.delete(key);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Insert/move in order — anchor.before() handles both cases
|
|
332
|
-
for (const item of newItems) {
|
|
333
|
-
const key = getKey(item);
|
|
334
|
-
let node = keyMap.get(key);
|
|
335
|
-
if (!node) {
|
|
336
|
-
node = createNode(item);
|
|
337
|
-
keyMap.set(key, node);
|
|
338
|
-
}
|
|
339
|
-
anchor.before?.(node);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Hydrate a keyed list: claim the comment anchor, walk its sibling elements
|
|
345
|
-
* to build a keyMap, then reconcile with signal data.
|
|
346
|
-
*
|
|
347
|
-
* SSR renders items as flat siblings. Hydration claims them by position,
|
|
348
|
-
* builds the keyMap, removes from DOM, then reconcile re-inserts in order.
|
|
349
|
-
*/
|
|
350
|
-
export function hydrateKeyedList<T>(
|
|
351
|
-
nodesVar: ChildNode[],
|
|
352
|
-
signalItems: T[],
|
|
353
|
-
getKey: (item: T) => unknown,
|
|
354
|
-
createNode: (item: T) => Node,
|
|
355
|
-
parent?: Element,
|
|
356
|
-
): { anchor: Comment; keyMap: Map<unknown, Node> } {
|
|
357
|
-
// 1. Claim the <!--g--> opening delimiter as anchor
|
|
358
|
-
const anchor = claimComment(nodesVar, "g", parent);
|
|
359
|
-
|
|
360
|
-
// 2. Walk siblings after anchor, collect elements up to <!--/g-->, build keyMap
|
|
361
|
-
const keyMap = new Map<unknown, Node>();
|
|
362
|
-
const anchorParent = anchor.parentNode;
|
|
363
|
-
if (anchorParent && signalItems.length > 0) {
|
|
364
|
-
const claimed: Element[] = [];
|
|
365
|
-
let next = anchor.nextSibling;
|
|
366
|
-
while (next) {
|
|
367
|
-
const current = next;
|
|
368
|
-
next = next.nextSibling;
|
|
369
|
-
// Stop at closing <!--/g--> delimiter and consume it
|
|
370
|
-
if (current instanceof Comment && current.data === "/g") {
|
|
371
|
-
current.remove();
|
|
372
|
-
break;
|
|
373
|
-
}
|
|
374
|
-
if (current instanceof Element) {
|
|
375
|
-
current.remove();
|
|
376
|
-
claimed.push(current);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
// Map claimed elements to keys by index
|
|
380
|
-
for (let i = 0; i < claimed.length && i < signalItems.length; i++) {
|
|
381
|
-
//@ts-expect-error i is always in bounds
|
|
382
|
-
const key = getKey(signalItems[i]);
|
|
383
|
-
//@ts-expect-error i is always in bounds
|
|
384
|
-
keyMap.set(key, claimed[i]);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// 3. Reconcile — keyMap is pre-populated with claimed nodes
|
|
389
|
-
reconcile(anchor, keyMap, signalItems, getKey, createNode);
|
|
390
|
-
return { anchor, keyMap };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
export function insert(parent: Element, value: unknown): void {
|
|
394
|
-
if (value === false || value === null || value === undefined) return;
|
|
395
|
-
if (Array.isArray(value)) {
|
|
396
|
-
for (const item of value) insert(parent, item);
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
if (value instanceof Node) {
|
|
400
|
-
parent.appendChild(value);
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
parent.append(String(value));
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Apply a use: directive to an element.
|
|
408
|
-
* The directive function receives (element, params) and may return a cleanup function.
|
|
409
|
-
* Cleanup runs when the enclosing $effect re-runs or the component is destroyed.
|
|
410
|
-
*/
|
|
411
|
-
export type Directive = (node: Element, params: unknown) => void | (() => void);
|
|
412
|
-
|
|
413
|
-
export function applyDirective(node: Element, directive: Directive | [Directive, unknown]): void {
|
|
414
|
-
const [fn, params] = Array.isArray(directive) ? directive : [directive, undefined];
|
|
415
|
-
const cleanup = fn(node, params);
|
|
416
|
-
if (typeof cleanup === "function") {
|
|
417
|
-
createEffect(() => () => cleanup());
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
export type ContextKey<T> = symbol & { __type: T };
|
|
422
|
-
|
|
423
|
-
const clientContextStore = new Map<symbol, unknown>();
|
|
424
|
-
|
|
425
|
-
let storeProvider: (() => Map<symbol, unknown>) | null = null;
|
|
426
|
-
export function __setStoreProvider(fn: () => Map<symbol, unknown>) {
|
|
427
|
-
storeProvider = fn;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function getStore(): Map<symbol, unknown> {
|
|
431
|
-
return storeProvider?.() ?? clientContextStore;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
export function createContext<T>(): ContextKey<T> {
|
|
435
|
-
return Symbol() as ContextKey<T>;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
export function setContext<T>(key: ContextKey<T>, value: T): void {
|
|
439
|
-
getStore().set(key as unknown as symbol, value);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
export function getContext<T>(key: ContextKey<T>): T {
|
|
443
|
-
return getStore().get(key as unknown as symbol) as T;
|
|
444
|
-
}
|
|
1
|
+
export const SIGIL_SAFE = Symbol.for("sigil.safe");
|
|
2
|
+
export class SafeHtml {
|
|
3
|
+
[SIGIL_SAFE] = true;
|
|
4
|
+
constructor(public v: string) { }
|
|
5
|
+
toString() {
|
|
6
|
+
return this.v;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let currentEffect: (() => void) | null = null;
|
|
11
|
+
let currentDeps: Set<Set<() => void>> | null = null;
|
|
12
|
+
|
|
13
|
+
let batchDepth = 0;
|
|
14
|
+
const pendingEffects = new Set<() => void>();
|
|
15
|
+
|
|
16
|
+
const targetDepsMap = new WeakMap<
|
|
17
|
+
object,
|
|
18
|
+
Map<string | symbol, Set<() => void>>
|
|
19
|
+
>();
|
|
20
|
+
|
|
21
|
+
function getDeps(target: object, key: string | symbol): Set<() => void> {
|
|
22
|
+
let deps = targetDepsMap.get(target);
|
|
23
|
+
if (!deps) {
|
|
24
|
+
deps = new Map();
|
|
25
|
+
targetDepsMap.set(target, deps);
|
|
26
|
+
}
|
|
27
|
+
let set = deps.get(key);
|
|
28
|
+
if (!set) {
|
|
29
|
+
set = new Set();
|
|
30
|
+
deps.set(key, set);
|
|
31
|
+
}
|
|
32
|
+
return set;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function track(target: object, key: string | symbol) {
|
|
36
|
+
if (currentEffect && currentDeps) {
|
|
37
|
+
const deps = getDeps(target, key);
|
|
38
|
+
deps.add(currentEffect);
|
|
39
|
+
currentDeps.add(deps); // Set, deduplicates automatically
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function trigger(target: object, key: string | symbol) {
|
|
44
|
+
const deps = targetDepsMap.get(target);
|
|
45
|
+
if (!deps) return;
|
|
46
|
+
const set = deps.get(key);
|
|
47
|
+
if (!set) return;
|
|
48
|
+
for (const fn of [...set]) {
|
|
49
|
+
if (batchDepth > 0) pendingEffects.add(fn);
|
|
50
|
+
else fn();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const proxyCache = new WeakMap<object, object>();
|
|
55
|
+
const reactiveTargets = new WeakSet<object>();
|
|
56
|
+
|
|
57
|
+
function createReactiveProxy<T extends object>(target: T): T {
|
|
58
|
+
const existing = proxyCache.get(target);
|
|
59
|
+
if (existing) return existing as T;
|
|
60
|
+
|
|
61
|
+
const proxy = new Proxy(target, {
|
|
62
|
+
get(target, key, receiver) {
|
|
63
|
+
if (key === "set") {
|
|
64
|
+
return (newValue: T) => {
|
|
65
|
+
const oldKeys = new Set(Object.keys(target));
|
|
66
|
+
const newKeys = new Set(Object.keys(newValue));
|
|
67
|
+
for (const k of oldKeys) {
|
|
68
|
+
if (!newKeys.has(k)) {
|
|
69
|
+
delete (target as any)[k];
|
|
70
|
+
trigger(target, k);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const k of newKeys) {
|
|
74
|
+
(target as any)[k] = (newValue as any)[k];
|
|
75
|
+
trigger(target, k);
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(target)) {
|
|
78
|
+
trigger(target, "length");
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (key === "peek") {
|
|
84
|
+
return () => target;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof key === "symbol") {
|
|
88
|
+
return Reflect.get(target, key, receiver);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
track(target, key);
|
|
92
|
+
const value = target[key as keyof T];
|
|
93
|
+
|
|
94
|
+
if (typeof value === "object" && value !== null) {
|
|
95
|
+
return createReactiveProxy(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return value;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
set(target, key, newValue, receiver) {
|
|
102
|
+
const oldValue = target[key as keyof T];
|
|
103
|
+
const result = Reflect.set(target, key, newValue, receiver);
|
|
104
|
+
if (oldValue !== newValue) {
|
|
105
|
+
trigger(target, key as string | symbol);
|
|
106
|
+
if (
|
|
107
|
+
Array.isArray(target) &&
|
|
108
|
+
typeof key === "string" &&
|
|
109
|
+
!Number.isNaN(Number(key))
|
|
110
|
+
) {
|
|
111
|
+
trigger(target, "length");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
deleteProperty(target, key) {
|
|
118
|
+
const result = Reflect.deleteProperty(target, key);
|
|
119
|
+
trigger(target, key as string | symbol);
|
|
120
|
+
return result;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
proxyCache.set(target, proxy);
|
|
125
|
+
reactiveTargets.add(target);
|
|
126
|
+
return proxy as T;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── createSignal (branches on type) ───
|
|
130
|
+
|
|
131
|
+
export type Signal<T> = {
|
|
132
|
+
(): T;
|
|
133
|
+
set: (next: T) => void;
|
|
134
|
+
peek: () => T;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export function batch(fn: () => void) {
|
|
138
|
+
batchDepth++;
|
|
139
|
+
fn();
|
|
140
|
+
batchDepth--;
|
|
141
|
+
if (batchDepth === 0) {
|
|
142
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: effects returing cleanup is intended
|
|
143
|
+
pendingEffects.forEach((e) => e());
|
|
144
|
+
pendingEffects.clear();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createSignal<T>(initial: T): Signal<T> {
|
|
149
|
+
if (typeof initial === "object" && initial !== null) {
|
|
150
|
+
const proxy = createReactiveProxy(initial);
|
|
151
|
+
const getter = () => proxy;
|
|
152
|
+
getter.set = (next: T) => (proxy as any).set(next);
|
|
153
|
+
getter.peek = () => initial;
|
|
154
|
+
return getter;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const subs = new Set<() => void>();
|
|
158
|
+
let value = initial;
|
|
159
|
+
|
|
160
|
+
const getter = () => {
|
|
161
|
+
if (currentEffect && currentDeps) {
|
|
162
|
+
subs.add(currentEffect);
|
|
163
|
+
currentDeps.add(subs);
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
getter.set = (next: T) => {
|
|
169
|
+
value = next;
|
|
170
|
+
for (const fn of [...subs]) {
|
|
171
|
+
if (batchDepth > 0) pendingEffects.add(fn);
|
|
172
|
+
else fn();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
getter.peek = () => value;
|
|
177
|
+
|
|
178
|
+
return getter;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: please shut up its a weird type
|
|
182
|
+
export function createEffect(fn: () => void | (() => void)): () => void {
|
|
183
|
+
let cleanup: (() => void) | void;
|
|
184
|
+
const ownDeps = new Set<Set<() => void>>();
|
|
185
|
+
|
|
186
|
+
const effect = () => {
|
|
187
|
+
if (cleanup) cleanup();
|
|
188
|
+
for (const depSet of ownDeps) depSet.delete(effect);
|
|
189
|
+
ownDeps.clear();
|
|
190
|
+
|
|
191
|
+
const prevEffect = currentEffect;
|
|
192
|
+
const prevDeps = currentDeps;
|
|
193
|
+
currentEffect = effect;
|
|
194
|
+
currentDeps = ownDeps;
|
|
195
|
+
try {
|
|
196
|
+
cleanup = fn();
|
|
197
|
+
} finally {
|
|
198
|
+
currentEffect = prevEffect;
|
|
199
|
+
currentDeps = prevDeps;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
effect();
|
|
204
|
+
|
|
205
|
+
const dispose = () => {
|
|
206
|
+
for (const depSet of ownDeps) depSet.delete(effect);
|
|
207
|
+
ownDeps.clear();
|
|
208
|
+
if (cleanup) cleanup();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
((globalThis as any).__sigilEffectScope as (() => void)[] | undefined)?.push(dispose);
|
|
212
|
+
return dispose;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Run fn() and return a dispose function that tears down every effect
|
|
217
|
+
* created during that call (including effects inside child createMemo calls).
|
|
218
|
+
* Supports nesting: inner scopes don't leak to outer ones.
|
|
219
|
+
*/
|
|
220
|
+
export function withEffectScope(fn: () => void): () => void {
|
|
221
|
+
const prev = (globalThis as any).__sigilEffectScope;
|
|
222
|
+
const disposers: (() => void)[] = [];
|
|
223
|
+
(globalThis as any).__sigilEffectScope = disposers;
|
|
224
|
+
try {
|
|
225
|
+
fn();
|
|
226
|
+
} finally {
|
|
227
|
+
(globalThis as any).__sigilEffectScope = prev;
|
|
228
|
+
}
|
|
229
|
+
return () => {
|
|
230
|
+
for (const d of disposers) d();
|
|
231
|
+
disposers.length = 0;
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function createMemo<T>(fn: () => T): Signal<T> {
|
|
236
|
+
const inner = createSignal<T>(undefined as T);
|
|
237
|
+
createEffect(() => inner.set(fn()));
|
|
238
|
+
return inner;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Claim an existing element from a hydration pool by tag name, or create a new one.
|
|
243
|
+
* Removes the claimed node from the pool array.
|
|
244
|
+
* When falling back to createElement, appends to parent if provided (SPA mode).
|
|
245
|
+
*/
|
|
246
|
+
export function claim(
|
|
247
|
+
nodes: ChildNode[],
|
|
248
|
+
tag: string,
|
|
249
|
+
parent?: Element,
|
|
250
|
+
): Element {
|
|
251
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
252
|
+
const node = nodes[i];
|
|
253
|
+
if (
|
|
254
|
+
node instanceof Element &&
|
|
255
|
+
node.tagName.toLowerCase() === tag.toLowerCase()
|
|
256
|
+
) {
|
|
257
|
+
nodes.splice(i, 1);
|
|
258
|
+
return node;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const el = document.createElement(tag);
|
|
262
|
+
if (parent) parent.appendChild(el);
|
|
263
|
+
return el;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Claim an existing Text node from a hydration pool, or create a new empty one.
|
|
268
|
+
* Removes the claimed node from the pool array.
|
|
269
|
+
* When falling back to createTextNode, appends to parent if provided (SPA mode).
|
|
270
|
+
*/
|
|
271
|
+
export function claimText(nodes: ChildNode[], parent?: Element): Text {
|
|
272
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
273
|
+
const node = nodes[i];
|
|
274
|
+
if (node instanceof Text) {
|
|
275
|
+
nodes.splice(i, 1);
|
|
276
|
+
return node;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const text = document.createTextNode("");
|
|
280
|
+
if (parent) parent.appendChild(text);
|
|
281
|
+
return text;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Claim an existing Comment node from a hydration pool, or create a new one.
|
|
286
|
+
* Removes the claimed node from the pool array.
|
|
287
|
+
* When falling back to createComment, appends to parent if provided (SPA mode).
|
|
288
|
+
*/
|
|
289
|
+
export function claimComment(
|
|
290
|
+
nodes: ChildNode[],
|
|
291
|
+
text: string = "",
|
|
292
|
+
parent?: Element,
|
|
293
|
+
): Comment {
|
|
294
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
295
|
+
const node = nodes[i];
|
|
296
|
+
if (node instanceof Comment && node.data === text) {
|
|
297
|
+
nodes.splice(i, 1);
|
|
298
|
+
return node;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const comment = document.createComment(text);
|
|
302
|
+
if (parent) parent.appendChild(comment);
|
|
303
|
+
return comment;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Reconcile a list of keyed DOM nodes.
|
|
308
|
+
* anchor.before(node) handles both insert and move natively.
|
|
309
|
+
* @param claimNode - Optional hydration callback. Called with (element, key) for
|
|
310
|
+
* server-rendered elements removed from the DOM. Returns the element to use,
|
|
311
|
+
* or null to discard.
|
|
312
|
+
*/
|
|
313
|
+
export function reconcile<T>(
|
|
314
|
+
anchor: Partial<Comment>,
|
|
315
|
+
keyMap: Map<unknown, Node>,
|
|
316
|
+
newItems: T[],
|
|
317
|
+
getKey: (item: T) => unknown,
|
|
318
|
+
createNode: (item: T) => Node,
|
|
319
|
+
// is this meant to be unused? claimNode?: (element: Element, key: unknown) => Element | null,
|
|
320
|
+
): void {
|
|
321
|
+
const newKeys = new Set(newItems.map(getKey));
|
|
322
|
+
|
|
323
|
+
// Remove nodes no longer in list (or claim them for hydration)
|
|
324
|
+
for (const [key, node] of keyMap) {
|
|
325
|
+
if (!newKeys.has(key)) {
|
|
326
|
+
node.parentNode?.removeChild(node);
|
|
327
|
+
keyMap.delete(key);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Insert/move in order — anchor.before() handles both cases
|
|
332
|
+
for (const item of newItems) {
|
|
333
|
+
const key = getKey(item);
|
|
334
|
+
let node = keyMap.get(key);
|
|
335
|
+
if (!node) {
|
|
336
|
+
node = createNode(item);
|
|
337
|
+
keyMap.set(key, node);
|
|
338
|
+
}
|
|
339
|
+
anchor.before?.(node);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Hydrate a keyed list: claim the comment anchor, walk its sibling elements
|
|
345
|
+
* to build a keyMap, then reconcile with signal data.
|
|
346
|
+
*
|
|
347
|
+
* SSR renders items as flat siblings. Hydration claims them by position,
|
|
348
|
+
* builds the keyMap, removes from DOM, then reconcile re-inserts in order.
|
|
349
|
+
*/
|
|
350
|
+
export function hydrateKeyedList<T>(
|
|
351
|
+
nodesVar: ChildNode[],
|
|
352
|
+
signalItems: T[],
|
|
353
|
+
getKey: (item: T) => unknown,
|
|
354
|
+
createNode: (item: T) => Node,
|
|
355
|
+
parent?: Element,
|
|
356
|
+
): { anchor: Comment; keyMap: Map<unknown, Node> } {
|
|
357
|
+
// 1. Claim the <!--g--> opening delimiter as anchor
|
|
358
|
+
const anchor = claimComment(nodesVar, "g", parent);
|
|
359
|
+
|
|
360
|
+
// 2. Walk siblings after anchor, collect elements up to <!--/g-->, build keyMap
|
|
361
|
+
const keyMap = new Map<unknown, Node>();
|
|
362
|
+
const anchorParent = anchor.parentNode;
|
|
363
|
+
if (anchorParent && signalItems.length > 0) {
|
|
364
|
+
const claimed: Element[] = [];
|
|
365
|
+
let next = anchor.nextSibling;
|
|
366
|
+
while (next) {
|
|
367
|
+
const current = next;
|
|
368
|
+
next = next.nextSibling;
|
|
369
|
+
// Stop at closing <!--/g--> delimiter and consume it
|
|
370
|
+
if (current instanceof Comment && current.data === "/g") {
|
|
371
|
+
current.remove();
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
if (current instanceof Element) {
|
|
375
|
+
current.remove();
|
|
376
|
+
claimed.push(current);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Map claimed elements to keys by index
|
|
380
|
+
for (let i = 0; i < claimed.length && i < signalItems.length; i++) {
|
|
381
|
+
//@ts-expect-error i is always in bounds
|
|
382
|
+
const key = getKey(signalItems[i]);
|
|
383
|
+
//@ts-expect-error i is always in bounds
|
|
384
|
+
keyMap.set(key, claimed[i]);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 3. Reconcile — keyMap is pre-populated with claimed nodes
|
|
389
|
+
reconcile(anchor, keyMap, signalItems, getKey, createNode);
|
|
390
|
+
return { anchor, keyMap };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function insert(parent: Element, value: unknown): void {
|
|
394
|
+
if (value === false || value === null || value === undefined) return;
|
|
395
|
+
if (Array.isArray(value)) {
|
|
396
|
+
for (const item of value) insert(parent, item);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (value instanceof Node) {
|
|
400
|
+
parent.appendChild(value);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
parent.append(String(value));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Apply a use: directive to an element.
|
|
408
|
+
* The directive function receives (element, params) and may return a cleanup function.
|
|
409
|
+
* Cleanup runs when the enclosing $effect re-runs or the component is destroyed.
|
|
410
|
+
*/
|
|
411
|
+
export type Directive = (node: Element, params: unknown) => void | (() => void);
|
|
412
|
+
|
|
413
|
+
export function applyDirective(node: Element, directive: Directive | [Directive, unknown]): void {
|
|
414
|
+
const [fn, params] = Array.isArray(directive) ? directive : [directive, undefined];
|
|
415
|
+
const cleanup = fn(node, params);
|
|
416
|
+
if (typeof cleanup === "function") {
|
|
417
|
+
createEffect(() => () => cleanup());
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export type ContextKey<T> = symbol & { __type: T };
|
|
422
|
+
|
|
423
|
+
const clientContextStore = new Map<symbol, unknown>();
|
|
424
|
+
|
|
425
|
+
let storeProvider: (() => Map<symbol, unknown>) | null = null;
|
|
426
|
+
export function __setStoreProvider(fn: () => Map<symbol, unknown>) {
|
|
427
|
+
storeProvider = fn;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function getStore(): Map<symbol, unknown> {
|
|
431
|
+
return storeProvider?.() ?? clientContextStore;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function createContext<T>(): ContextKey<T> {
|
|
435
|
+
return Symbol() as ContextKey<T>;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function setContext<T>(key: ContextKey<T>, value: T): void {
|
|
439
|
+
getStore().set(key as unknown as symbol, value);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function getContext<T>(key: ContextKey<T>): T {
|
|
443
|
+
return getStore().get(key as unknown as symbol) as T;
|
|
444
|
+
}
|