dalila 1.4.2 → 1.4.3
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/dist/context/auto-scope.d.ts +167 -0
- package/dist/context/auto-scope.js +381 -0
- package/dist/context/context.d.ts +111 -0
- package/dist/context/context.js +283 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/raw.d.ts +2 -0
- package/dist/context/raw.js +2 -0
- package/dist/core/dev.d.ts +7 -0
- package/dist/core/dev.js +14 -0
- package/dist/core/for.d.ts +42 -0
- package/dist/core/for.js +311 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +14 -0
- package/dist/core/key.d.ts +33 -0
- package/dist/core/key.js +83 -0
- package/dist/core/match.d.ts +22 -0
- package/dist/core/match.js +175 -0
- package/dist/core/mutation.d.ts +55 -0
- package/dist/core/mutation.js +128 -0
- package/dist/core/persist.d.ts +63 -0
- package/dist/core/persist.js +371 -0
- package/dist/core/query.d.ts +72 -0
- package/dist/core/query.js +184 -0
- package/dist/core/resource.d.ts +299 -0
- package/dist/core/resource.js +924 -0
- package/dist/core/scheduler.d.ts +111 -0
- package/dist/core/scheduler.js +243 -0
- package/dist/core/scope.d.ts +74 -0
- package/dist/core/scope.js +171 -0
- package/dist/core/signal.d.ts +88 -0
- package/dist/core/signal.js +451 -0
- package/dist/core/store.d.ts +130 -0
- package/dist/core/store.js +234 -0
- package/dist/core/virtual.d.ts +26 -0
- package/dist/core/virtual.js +277 -0
- package/dist/core/watch-testing.d.ts +13 -0
- package/dist/core/watch-testing.js +16 -0
- package/dist/core/watch.d.ts +81 -0
- package/dist/core/watch.js +353 -0
- package/dist/core/when.d.ts +23 -0
- package/dist/core/when.js +124 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/internal/watch-testing.d.ts +1 -0
- package/dist/internal/watch-testing.js +8 -0
- package/dist/router/index.d.ts +1 -0
- package/dist/router/index.js +1 -0
- package/dist/router/route.d.ts +23 -0
- package/dist/router/route.js +48 -0
- package/dist/router/router.d.ts +23 -0
- package/dist/router/router.js +169 -0
- package/dist/runtime/bind.d.ts +59 -0
- package/dist/runtime/bind.js +340 -0
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.js +9 -0
- package/dist/simple.d.ts +11 -0
- package/dist/simple.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { effect, effectAsync, signal } from './signal.js';
|
|
2
|
+
import { getCurrentScope, withScope } from './scope.js';
|
|
3
|
+
/**
|
|
4
|
+
* Global registry: Document -> WatchContext.
|
|
5
|
+
* WeakMap ensures cleanup when Document is garbage collected.
|
|
6
|
+
*/
|
|
7
|
+
const documentWatchers = new WeakMap();
|
|
8
|
+
/**
|
|
9
|
+
* One-time warning flags to avoid flooding console.
|
|
10
|
+
*/
|
|
11
|
+
let hasWarnedNoScope = false;
|
|
12
|
+
const warnedFunctions = new Set();
|
|
13
|
+
/**
|
|
14
|
+
* Reset warning flags (used by src/internal/watch-testing.ts for deterministic tests).
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export function __resetWarningsForTests() {
|
|
18
|
+
warnedFunctions.clear();
|
|
19
|
+
hasWarnedNoScope = false;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Walk a subtree recursively, calling visitor for each node.
|
|
23
|
+
* Supports any Node type (Element, Text, Comment, etc.) via childNodes.
|
|
24
|
+
*/
|
|
25
|
+
function walkSubtree(root, visitor) {
|
|
26
|
+
visitor(root);
|
|
27
|
+
if (root.childNodes && root.childNodes.length > 0) {
|
|
28
|
+
for (let i = 0; i < root.childNodes.length; i++) {
|
|
29
|
+
walkSubtree(root.childNodes[i], visitor);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Start a watch entry effect inside the entry's captured scope (if any).
|
|
35
|
+
*
|
|
36
|
+
* Why:
|
|
37
|
+
* - `effect()` captures getCurrentScope() at creation time.
|
|
38
|
+
* - watch entries can start later (when a node becomes connected), so we must re-enter
|
|
39
|
+
* the original scope to preserve cleanup semantics.
|
|
40
|
+
*/
|
|
41
|
+
function startEntryEffect(entry) {
|
|
42
|
+
if (entry.effectStarted || entry.cleanup)
|
|
43
|
+
return;
|
|
44
|
+
entry.effectStarted = true;
|
|
45
|
+
const create = () => effect(entry.fn);
|
|
46
|
+
// Ensure the *creation* of the effect happens inside the captured scope.
|
|
47
|
+
// effect() captures getCurrentScope() at creation time.
|
|
48
|
+
entry.cleanup = entry.scope ? withScope(entry.scope, create) : create();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Gets or creates a watch context for a document.
|
|
52
|
+
* Ensures only ONE MutationObserver per document.
|
|
53
|
+
*/
|
|
54
|
+
function getDocumentWatchContext(doc) {
|
|
55
|
+
let ctx = documentWatchers.get(doc);
|
|
56
|
+
if (!ctx) {
|
|
57
|
+
const watches = new WeakMap();
|
|
58
|
+
const observer = new MutationObserver((records) => {
|
|
59
|
+
const candidates = new Set();
|
|
60
|
+
// 1) removedNodes: collect candidates for cleanup (ONLY affected subtrees)
|
|
61
|
+
for (const record of records) {
|
|
62
|
+
for (let i = 0; i < record.removedNodes.length; i++) {
|
|
63
|
+
const removed = record.removedNodes[i];
|
|
64
|
+
walkSubtree(removed, (node) => {
|
|
65
|
+
const entries = watches.get(node);
|
|
66
|
+
if (entries && entries.size > 0)
|
|
67
|
+
candidates.add(node);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 2) addedNodes: mark connected and start effects (ONLY affected subtrees)
|
|
72
|
+
for (const record of records) {
|
|
73
|
+
for (let i = 0; i < record.addedNodes.length; i++) {
|
|
74
|
+
const added = record.addedNodes[i];
|
|
75
|
+
walkSubtree(added, (node) => {
|
|
76
|
+
const entries = watches.get(node);
|
|
77
|
+
if (!entries || entries.size === 0)
|
|
78
|
+
return;
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
entry.hasConnected = true;
|
|
81
|
+
// Start effect if not started yet and node is connected
|
|
82
|
+
if (!entry.effectStarted && node.isConnected) {
|
|
83
|
+
startEntryEffect(entry);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Handles "move": remove+add in same batch
|
|
87
|
+
candidates.delete(node);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// 3) Cleanup disconnected candidates in microtask (handles move correctly)
|
|
92
|
+
if (candidates.size > 0) {
|
|
93
|
+
queueMicrotask(() => {
|
|
94
|
+
for (const node of candidates) {
|
|
95
|
+
if (node.isConnected)
|
|
96
|
+
continue;
|
|
97
|
+
const entries = watches.get(node);
|
|
98
|
+
if (!entries || entries.size === 0)
|
|
99
|
+
continue;
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
// Only cleanup if it was connected at least once
|
|
102
|
+
if (entry.hasConnected && entry.cleanup) {
|
|
103
|
+
entry.cleanup();
|
|
104
|
+
entry.cleanup = null;
|
|
105
|
+
// Allow restart if node reconnects later
|
|
106
|
+
entry.effectStarted = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
observer.observe(doc, { childList: true, subtree: true });
|
|
114
|
+
ctx = { observer, watches, watchCount: 0 };
|
|
115
|
+
documentWatchers.set(doc, ctx);
|
|
116
|
+
}
|
|
117
|
+
return ctx;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* watch(node, fn)
|
|
121
|
+
*
|
|
122
|
+
* Runs `fn` inside a reactive effect while `node` is connected to the document.
|
|
123
|
+
* - Signal reads inside `fn` are tracked (because `fn` runs inside effect()).
|
|
124
|
+
* - When the node disconnects, the effect is disposed.
|
|
125
|
+
* - If the node reconnects later, the effect may start again (best-effort, based on DOM mutations).
|
|
126
|
+
*
|
|
127
|
+
* Implementation notes / fixes:
|
|
128
|
+
* 1) Scope capture: the scope at watch() time is stored on the entry so effects that start later
|
|
129
|
+
* still get created "inside" the original scope (fixes late-start effects created outside scope).
|
|
130
|
+
* 2) Memory safety: watches uses WeakMap<Node, ...> so detached nodes are not kept alive.
|
|
131
|
+
* 3) Observer lifecycle: we track ctx.watchCount (WeakMap has no .size) to know when to disconnect.
|
|
132
|
+
*/
|
|
133
|
+
export function watch(node, fn) {
|
|
134
|
+
const currentScope = getCurrentScope();
|
|
135
|
+
// Warn if no scope (one-time warning to avoid flooding)
|
|
136
|
+
if (!currentScope && !hasWarnedNoScope) {
|
|
137
|
+
hasWarnedNoScope = true;
|
|
138
|
+
console.warn('[Dalila] watch() called outside scope. ' +
|
|
139
|
+
'The watch will work but you must call the returned dispose() function manually to avoid memory leaks. ' +
|
|
140
|
+
'Consider using createScope() + withScope() for automatic cleanup.');
|
|
141
|
+
}
|
|
142
|
+
// Get document (default to global document if node.ownerDocument is null)
|
|
143
|
+
const doc = node.ownerDocument || document;
|
|
144
|
+
const ctx = getDocumentWatchContext(doc);
|
|
145
|
+
// Create watch entry (capture scope NOW)
|
|
146
|
+
const entry = {
|
|
147
|
+
fn,
|
|
148
|
+
cleanup: null,
|
|
149
|
+
hasConnected: node.isConnected,
|
|
150
|
+
effectStarted: false,
|
|
151
|
+
scope: currentScope ?? null
|
|
152
|
+
};
|
|
153
|
+
// Register in watches (WeakMap) and increment count
|
|
154
|
+
let set = ctx.watches.get(node);
|
|
155
|
+
if (!set) {
|
|
156
|
+
set = new Set();
|
|
157
|
+
ctx.watches.set(node, set);
|
|
158
|
+
}
|
|
159
|
+
set.add(entry);
|
|
160
|
+
ctx.watchCount++;
|
|
161
|
+
// Start effect immediately if node is already connected (inside captured scope)
|
|
162
|
+
if (node.isConnected) {
|
|
163
|
+
startEntryEffect(entry);
|
|
164
|
+
}
|
|
165
|
+
// Dispose function (idempotent)
|
|
166
|
+
let disposed = false;
|
|
167
|
+
const dispose = () => {
|
|
168
|
+
if (disposed)
|
|
169
|
+
return;
|
|
170
|
+
disposed = true;
|
|
171
|
+
// Stop effect if running
|
|
172
|
+
if (entry.cleanup) {
|
|
173
|
+
entry.cleanup();
|
|
174
|
+
entry.cleanup = null;
|
|
175
|
+
}
|
|
176
|
+
entry.effectStarted = false;
|
|
177
|
+
// Remove entry from set (if set still exists)
|
|
178
|
+
const entries = ctx.watches.get(node);
|
|
179
|
+
if (entries) {
|
|
180
|
+
entries.delete(entry);
|
|
181
|
+
// If empty, delete the entry from WeakMap to free the Set immediately
|
|
182
|
+
if (entries.size === 0)
|
|
183
|
+
ctx.watches.delete(node);
|
|
184
|
+
}
|
|
185
|
+
// Decrement active watch count and disconnect observer if none left
|
|
186
|
+
ctx.watchCount--;
|
|
187
|
+
if (ctx.watchCount <= 0) {
|
|
188
|
+
ctx.observer.disconnect();
|
|
189
|
+
documentWatchers.delete(doc);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
// Register cleanup in scope if available
|
|
193
|
+
if (currentScope) {
|
|
194
|
+
currentScope.onCleanup(dispose);
|
|
195
|
+
}
|
|
196
|
+
return dispose;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* onMount(fn)
|
|
200
|
+
*
|
|
201
|
+
* Executes fn immediately. Minimal semantic helper to document mount-time logic in DOM-first code.
|
|
202
|
+
* Unlike React, there's no deferred execution or batching — it runs synchronously.
|
|
203
|
+
*/
|
|
204
|
+
export function onMount(fn) {
|
|
205
|
+
fn();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* onCleanup(fn)
|
|
209
|
+
*
|
|
210
|
+
* Registers fn to run when the current scope is disposed.
|
|
211
|
+
* If called outside a scope, fn is never called (no-op).
|
|
212
|
+
*/
|
|
213
|
+
export function onCleanup(fn) {
|
|
214
|
+
const scope = getCurrentScope();
|
|
215
|
+
if (scope)
|
|
216
|
+
scope.onCleanup(fn);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* useEvent(target, type, handler, options?)
|
|
220
|
+
*
|
|
221
|
+
* Attaches an event listener and returns an idempotent dispose() function.
|
|
222
|
+
* - Inside a scope: listener is removed automatically on scope.dispose().
|
|
223
|
+
* - Outside a scope: you must call dispose() manually (warns once).
|
|
224
|
+
*/
|
|
225
|
+
export function useEvent(target, type, handler, options) {
|
|
226
|
+
const scope = getCurrentScope();
|
|
227
|
+
if (!scope && !warnedFunctions.has('useEvent')) {
|
|
228
|
+
warnedFunctions.add('useEvent');
|
|
229
|
+
console.warn('[Dalila] useEvent() called outside scope. ' +
|
|
230
|
+
'Event listener will not auto-cleanup. Call the returned dispose() function manually.');
|
|
231
|
+
}
|
|
232
|
+
target.addEventListener(type, handler, options);
|
|
233
|
+
let disposed = false;
|
|
234
|
+
const dispose = () => {
|
|
235
|
+
if (disposed)
|
|
236
|
+
return;
|
|
237
|
+
disposed = true;
|
|
238
|
+
target.removeEventListener(type, handler, options);
|
|
239
|
+
};
|
|
240
|
+
if (scope)
|
|
241
|
+
scope.onCleanup(dispose);
|
|
242
|
+
return dispose;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* useInterval(fn, ms)
|
|
246
|
+
*
|
|
247
|
+
* Starts an interval and returns an idempotent dispose() function.
|
|
248
|
+
* - Inside a scope: interval is cleared automatically on scope.dispose().
|
|
249
|
+
* - Outside a scope: you must call dispose() manually (warns once).
|
|
250
|
+
*/
|
|
251
|
+
export function useInterval(fn, ms) {
|
|
252
|
+
const scope = getCurrentScope();
|
|
253
|
+
if (!scope && !warnedFunctions.has('useInterval')) {
|
|
254
|
+
warnedFunctions.add('useInterval');
|
|
255
|
+
console.warn('[Dalila] useInterval() called outside scope. ' +
|
|
256
|
+
'Interval will not auto-cleanup. Call the returned dispose() function manually.');
|
|
257
|
+
}
|
|
258
|
+
const intervalId = setInterval(fn, ms);
|
|
259
|
+
let disposed = false;
|
|
260
|
+
const dispose = () => {
|
|
261
|
+
if (disposed)
|
|
262
|
+
return;
|
|
263
|
+
disposed = true;
|
|
264
|
+
clearInterval(intervalId);
|
|
265
|
+
};
|
|
266
|
+
if (scope)
|
|
267
|
+
scope.onCleanup(dispose);
|
|
268
|
+
return dispose;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* useTimeout(fn, ms)
|
|
272
|
+
*
|
|
273
|
+
* Starts a timeout and returns an idempotent dispose() function.
|
|
274
|
+
* - Inside a scope: timeout is cleared automatically on scope.dispose().
|
|
275
|
+
* - Outside a scope: you must call dispose() manually (warns once).
|
|
276
|
+
*/
|
|
277
|
+
export function useTimeout(fn, ms) {
|
|
278
|
+
const scope = getCurrentScope();
|
|
279
|
+
if (!scope && !warnedFunctions.has('useTimeout')) {
|
|
280
|
+
warnedFunctions.add('useTimeout');
|
|
281
|
+
console.warn('[Dalila] useTimeout() called outside scope. ' +
|
|
282
|
+
'Timeout will not auto-cleanup. Call the returned dispose() function manually.');
|
|
283
|
+
}
|
|
284
|
+
const timeoutId = setTimeout(fn, ms);
|
|
285
|
+
let disposed = false;
|
|
286
|
+
const dispose = () => {
|
|
287
|
+
if (disposed)
|
|
288
|
+
return;
|
|
289
|
+
disposed = true;
|
|
290
|
+
clearTimeout(timeoutId);
|
|
291
|
+
};
|
|
292
|
+
if (scope)
|
|
293
|
+
scope.onCleanup(dispose);
|
|
294
|
+
return dispose;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* useFetch(url, options)
|
|
298
|
+
*
|
|
299
|
+
* Small convenience for "fetch + reactive state + abort" built on effectAsync().
|
|
300
|
+
* Returns { data, loading, error, dispose }.
|
|
301
|
+
*
|
|
302
|
+
* Behavior:
|
|
303
|
+
* - Runs the fetch inside effectAsync, which provides an AbortSignal.
|
|
304
|
+
* - If url is a function, it tracks signal reads (reactive).
|
|
305
|
+
* - Calling dispose() aborts the in-flight request (via effectAsync's signal).
|
|
306
|
+
* - Inside a scope: auto-disposed on scope.dispose().
|
|
307
|
+
* - Outside a scope: you must call dispose() manually (warns once).
|
|
308
|
+
*
|
|
309
|
+
* Limitations:
|
|
310
|
+
* - No refresh(), no caching, no invalidation.
|
|
311
|
+
* - For those features, use createResource / query().
|
|
312
|
+
*/
|
|
313
|
+
export function useFetch(url, options) {
|
|
314
|
+
const scope = getCurrentScope();
|
|
315
|
+
if (!scope && !warnedFunctions.has('useFetch')) {
|
|
316
|
+
warnedFunctions.add('useFetch');
|
|
317
|
+
console.warn('[Dalila] useFetch() called outside scope. ' +
|
|
318
|
+
'Request will not auto-cleanup. Call the returned dispose() function manually.');
|
|
319
|
+
}
|
|
320
|
+
const data = signal(null);
|
|
321
|
+
const loading = signal(false);
|
|
322
|
+
const error = signal(null);
|
|
323
|
+
const dispose = effectAsync(async (signal) => {
|
|
324
|
+
try {
|
|
325
|
+
loading.set(true);
|
|
326
|
+
error.set(null);
|
|
327
|
+
const fetchUrl = typeof url === 'function' ? url() : url;
|
|
328
|
+
const response = await fetch(fetchUrl, {
|
|
329
|
+
...options,
|
|
330
|
+
signal
|
|
331
|
+
});
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
334
|
+
}
|
|
335
|
+
const result = (await response.json());
|
|
336
|
+
data.set(result);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (signal.aborted)
|
|
340
|
+
return;
|
|
341
|
+
error.set(err instanceof Error ? err : new Error(String(err)));
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
if (!signal.aborted) {
|
|
345
|
+
loading.set(false);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
// Match the other lifecycle helpers: if we are inside a scope, dispose automatically.
|
|
350
|
+
if (scope)
|
|
351
|
+
scope.onCleanup(dispose);
|
|
352
|
+
return { data, loading, error, dispose };
|
|
353
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conditional DOM rendering with per-branch lifetime.
|
|
3
|
+
*
|
|
4
|
+
* `when()` returns a DocumentFragment containing two stable comment markers:
|
|
5
|
+
* <!-- when:start --> ...branch nodes... <!-- when:end -->
|
|
6
|
+
*
|
|
7
|
+
* Why markers?
|
|
8
|
+
* - The markers act as a permanent insertion point, so the branch can be swapped
|
|
9
|
+
* without the caller managing placeholders or re-appending anything.
|
|
10
|
+
*
|
|
11
|
+
* Semantics:
|
|
12
|
+
* - Initial render is synchronous (prevents “flash”).
|
|
13
|
+
* - Updates track only `test()` (the condition), not the branch internals.
|
|
14
|
+
* - Branch swaps are coalesced in a microtask to:
|
|
15
|
+
* 1) merge rapid toggles into a single swap, and
|
|
16
|
+
* 2) run branch rendering outside reactive tracking (avoid accidental subscriptions).
|
|
17
|
+
*
|
|
18
|
+
* Lifetime:
|
|
19
|
+
* - Each mounted branch owns its own Scope.
|
|
20
|
+
* - Swapping branches disposes the previous scope, cleaning up effects/listeners/timers
|
|
21
|
+
* created inside that branch.
|
|
22
|
+
*/
|
|
23
|
+
export declare function when(test: () => any, thenFn: () => Node | Node[], elseFn?: () => Node | Node[]): DocumentFragment;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { effect } from './signal.js';
|
|
2
|
+
import { createScope, getCurrentScope, isScopeDisposed, withScope } from './scope.js';
|
|
3
|
+
import { scheduleMicrotask } from './scheduler.js';
|
|
4
|
+
/**
|
|
5
|
+
* Conditional DOM rendering with per-branch lifetime.
|
|
6
|
+
*
|
|
7
|
+
* `when()` returns a DocumentFragment containing two stable comment markers:
|
|
8
|
+
* <!-- when:start --> ...branch nodes... <!-- when:end -->
|
|
9
|
+
*
|
|
10
|
+
* Why markers?
|
|
11
|
+
* - The markers act as a permanent insertion point, so the branch can be swapped
|
|
12
|
+
* without the caller managing placeholders or re-appending anything.
|
|
13
|
+
*
|
|
14
|
+
* Semantics:
|
|
15
|
+
* - Initial render is synchronous (prevents “flash”).
|
|
16
|
+
* - Updates track only `test()` (the condition), not the branch internals.
|
|
17
|
+
* - Branch swaps are coalesced in a microtask to:
|
|
18
|
+
* 1) merge rapid toggles into a single swap, and
|
|
19
|
+
* 2) run branch rendering outside reactive tracking (avoid accidental subscriptions).
|
|
20
|
+
*
|
|
21
|
+
* Lifetime:
|
|
22
|
+
* - Each mounted branch owns its own Scope.
|
|
23
|
+
* - Swapping branches disposes the previous scope, cleaning up effects/listeners/timers
|
|
24
|
+
* created inside that branch.
|
|
25
|
+
*/
|
|
26
|
+
export function when(test, thenFn, elseFn) {
|
|
27
|
+
/** Stable DOM anchors delimiting the dynamic range. */
|
|
28
|
+
const start = document.createComment('when:start');
|
|
29
|
+
const end = document.createComment('when:end');
|
|
30
|
+
/** Nodes currently mounted between start/end. */
|
|
31
|
+
let currentNodes = [];
|
|
32
|
+
/** Scope owning resources created by the mounted branch. */
|
|
33
|
+
let branchScope = null;
|
|
34
|
+
/** Last resolved boolean (used to avoid unnecessary remounts). */
|
|
35
|
+
let lastCond = undefined;
|
|
36
|
+
/** Coalescing state: ensure at most one scheduled swap per tick. */
|
|
37
|
+
let swapScheduled = false;
|
|
38
|
+
let pendingCond = undefined;
|
|
39
|
+
/** Disposal guard to prevent orphan microtasks from touching dead DOM. */
|
|
40
|
+
let disposed = false;
|
|
41
|
+
/** Parent scope captured at creation time (if any). */
|
|
42
|
+
const parentScope = getCurrentScope();
|
|
43
|
+
const resolveParentScope = () => {
|
|
44
|
+
if (!parentScope)
|
|
45
|
+
return null;
|
|
46
|
+
return isScopeDisposed(parentScope) ? null : parentScope;
|
|
47
|
+
};
|
|
48
|
+
/** Removes mounted nodes and disposes their branch scope. */
|
|
49
|
+
const clear = () => {
|
|
50
|
+
branchScope?.dispose();
|
|
51
|
+
branchScope = null;
|
|
52
|
+
for (const n of currentNodes) {
|
|
53
|
+
n.parentNode?.removeChild(n);
|
|
54
|
+
}
|
|
55
|
+
currentNodes = [];
|
|
56
|
+
};
|
|
57
|
+
/** Inserts nodes before the end marker and records ownership. */
|
|
58
|
+
const mount = (nodes, scope) => {
|
|
59
|
+
currentNodes = nodes;
|
|
60
|
+
branchScope = scope;
|
|
61
|
+
end.before(...nodes);
|
|
62
|
+
};
|
|
63
|
+
/** Clears old branch and mounts the branch for `cond`. */
|
|
64
|
+
const swap = (cond) => {
|
|
65
|
+
clear();
|
|
66
|
+
const nextScope = createScope(resolveParentScope());
|
|
67
|
+
try {
|
|
68
|
+
// Render inside an isolated scope so branch resources are tied to that branch.
|
|
69
|
+
const result = withScope(nextScope, () => (cond ? thenFn() : elseFn?.()));
|
|
70
|
+
if (!result) {
|
|
71
|
+
// Allow “empty branch”: keep anchors, dispose the unused scope.
|
|
72
|
+
nextScope.dispose();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
76
|
+
mount(nodes, nextScope);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
// Never leak the newly created scope if branch rendering throws.
|
|
80
|
+
nextScope.dispose();
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
// Create the stable slot (anchors first, content inserted between them).
|
|
85
|
+
const frag = document.createDocumentFragment();
|
|
86
|
+
frag.append(start, end);
|
|
87
|
+
// Initial render is synchronous so the caller sees the correct branch immediately
|
|
88
|
+
// after appending the fragment to the DOM.
|
|
89
|
+
const initialCond = !!test();
|
|
90
|
+
lastCond = initialCond;
|
|
91
|
+
swap(initialCond);
|
|
92
|
+
/**
|
|
93
|
+
* Reactive driver:
|
|
94
|
+
* - tracks only `test()`
|
|
95
|
+
* - swaps only when the boolean changes
|
|
96
|
+
* - coalesces swaps via microtask (outside tracking)
|
|
97
|
+
*/
|
|
98
|
+
effect(() => {
|
|
99
|
+
const cond = !!test();
|
|
100
|
+
if (lastCond === cond)
|
|
101
|
+
return;
|
|
102
|
+
lastCond = cond;
|
|
103
|
+
pendingCond = cond;
|
|
104
|
+
if (swapScheduled)
|
|
105
|
+
return;
|
|
106
|
+
swapScheduled = true;
|
|
107
|
+
scheduleMicrotask(() => {
|
|
108
|
+
swapScheduled = false;
|
|
109
|
+
if (disposed)
|
|
110
|
+
return;
|
|
111
|
+
const next = pendingCond;
|
|
112
|
+
pendingCond = undefined;
|
|
113
|
+
swap(next);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
// If `when()` is created inside a parent scope, dispose branch resources with it.
|
|
117
|
+
if (parentScope) {
|
|
118
|
+
parentScope.onCleanup(() => {
|
|
119
|
+
disposed = true;
|
|
120
|
+
clear();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return frag;
|
|
124
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resetWarnings(): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./router.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./router.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RouteDef {
|
|
2
|
+
path: string;
|
|
3
|
+
view: () => Node | Node[];
|
|
4
|
+
children?: RouteDef[];
|
|
5
|
+
loader?: (route: RouteState) => Promise<any>;
|
|
6
|
+
beforeEnter?: (to: RouteState, from: RouteState) => boolean | Promise<boolean>;
|
|
7
|
+
afterLeave?: (from: RouteState, to: RouteState) => void | Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface RouteState {
|
|
10
|
+
path: string;
|
|
11
|
+
params: Record<string, string>;
|
|
12
|
+
query: URLSearchParams;
|
|
13
|
+
hash: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
export interface RouteMatch {
|
|
17
|
+
route: RouteDef;
|
|
18
|
+
params: Record<string, string>;
|
|
19
|
+
query: URLSearchParams;
|
|
20
|
+
hash: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function matchRoute(path: string, routeDef: RouteDef): RouteMatch | null;
|
|
23
|
+
export declare function findRoute(path: string, routes: RouteDef[]): RouteMatch | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function parsePath(path) {
|
|
2
|
+
const paramNames = [];
|
|
3
|
+
const pattern = path
|
|
4
|
+
.replace(/:([^\/]+)/g, (_, paramName) => {
|
|
5
|
+
paramNames.push(paramName);
|
|
6
|
+
return '([^/]+)';
|
|
7
|
+
})
|
|
8
|
+
.replace(/\*/g, '.*');
|
|
9
|
+
return {
|
|
10
|
+
pattern: new RegExp(`^${pattern}$`),
|
|
11
|
+
paramNames
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function matchRoute(path, routeDef) {
|
|
15
|
+
const { pattern, paramNames } = parsePath(routeDef.path);
|
|
16
|
+
const match = path.match(pattern);
|
|
17
|
+
if (!match) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const params = {};
|
|
21
|
+
paramNames.forEach((name, index) => {
|
|
22
|
+
params[name] = match[index + 1];
|
|
23
|
+
});
|
|
24
|
+
const url = new URL(path, 'http://localhost');
|
|
25
|
+
const query = url.searchParams;
|
|
26
|
+
const hash = url.hash.slice(1); // Remove the #
|
|
27
|
+
return {
|
|
28
|
+
route: routeDef,
|
|
29
|
+
params,
|
|
30
|
+
query,
|
|
31
|
+
hash
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function findRoute(path, routes) {
|
|
35
|
+
for (const route of routes) {
|
|
36
|
+
const match = matchRoute(path, route);
|
|
37
|
+
if (match) {
|
|
38
|
+
return match;
|
|
39
|
+
}
|
|
40
|
+
if (route.children) {
|
|
41
|
+
const childMatch = findRoute(path, route.children);
|
|
42
|
+
if (childMatch) {
|
|
43
|
+
return childMatch;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Signal } from '../core/signal.js';
|
|
2
|
+
import { RouteDef, RouteState } from './route.js';
|
|
3
|
+
export interface Router {
|
|
4
|
+
mount(outlet: Element): void;
|
|
5
|
+
push(path: string): void;
|
|
6
|
+
replace(path: string): void;
|
|
7
|
+
back(): void;
|
|
8
|
+
link(event: MouseEvent): void;
|
|
9
|
+
outlet(): Element;
|
|
10
|
+
route: Signal<RouteState>;
|
|
11
|
+
beforeEnter?: (to: RouteState, from: RouteState) => boolean | Promise<boolean>;
|
|
12
|
+
afterLeave?: (from: RouteState, to: RouteState) => void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface RouteDefWithLoader extends RouteDef {
|
|
15
|
+
loader?: (route: RouteState) => Promise<any>;
|
|
16
|
+
beforeEnter?: (to: RouteState, from: RouteState) => boolean | Promise<boolean>;
|
|
17
|
+
afterLeave?: (from: RouteState, to: RouteState) => void | Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface RouterConfig {
|
|
20
|
+
routes: RouteDef[];
|
|
21
|
+
}
|
|
22
|
+
export declare function getCurrentRouter(): Router | null;
|
|
23
|
+
export declare function createRouter(config: RouterConfig): Router;
|