@adukiorg/anza 0.2.0 → 0.2.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/CHANGELOG.md +90 -4
- package/README.md +97 -133
- package/bin/anza/anza-linux-arm64 +0 -0
- package/bin/anza/anza-linux-x64 +0 -0
- package/bin/anza/anza-macos-arm64 +0 -0
- package/bin/anza/anza-macos-x64 +0 -0
- package/bin/anza/anza-windows-x64.exe +0 -0
- package/bin/anza/find.js +35 -0
- package/bin/anza/index.js +34 -0
- package/bin/anza/launch.js +19 -0
- package/bin/common/index.js +7 -0
- package/bin/common/logs.js +62 -0
- package/bin/create/copy.js +18 -0
- package/bin/create/index.js +45 -0
- package/bin/create/run.js +210 -0
- package/bin/create/write.js +19 -0
- package/importmap.json +4 -0
- package/package.json +16 -10
- package/src/core/offline/{usage.md → notes/usage.md} +11 -1
- package/src/core/router/boot.js +82 -0
- package/src/core/router/cascade.js +76 -0
- package/src/core/router/container.js +63 -72
- package/src/core/router/graph.js +144 -0
- package/src/core/router/index.js +12 -2
- package/src/core/router/intercept.js +26 -7
- package/src/core/router/lca.js +58 -0
- package/src/core/router/match.js +49 -36
- package/src/core/router/notes/audit-old.md +887 -0
- package/src/core/router/notes/audti.md +773 -0
- package/src/core/router/notes/tasks.md +473 -0
- package/src/core/router/{usage.md → notes/usage.md} +57 -35
- package/src/core/router/sync/tab.js +6 -4
- package/src/core/router/transitions.js +35 -8
- package/src/core/router/trie.js +130 -0
- package/src/core/security/{usage.md → notes/usage.md} +1 -2
- package/src/core/storage/{usage.md → notes/usage.md} +6 -6
- package/src/core/theme/index.js +78 -0
- package/src/core/ui/define/index.js +2 -1
- package/src/core/ui/define/orchestrator.js +10 -4
- package/src/core/ui/defs/dock.js +134 -0
- package/src/core/ui/defs/index.js +20 -0
- package/src/core/ui/defs/page.js +89 -0
- package/src/core/ui/defs/part.js +28 -0
- package/src/core/ui/defs/spec.js +96 -0
- package/src/core/ui/defs/view.js +23 -0
- package/src/core/ui/index.js +16 -3
- package/src/core/ui/notes/definations.md +979 -0
- package/src/tokens/index.css +1 -0
- package/src/tokens/semantic/contrast.css +18 -0
- package/src/tokens/semantic/transitions.css +32 -0
- package/types/core/platform/index.d.ts +39 -10
- package/types/core/router/index.d.ts +9 -0
- package/types/core/theme/index.d.ts +18 -0
- package/types/core/ui/index.d.ts +11 -0
- package/types/index.d.ts +1 -0
- package/bin/anza.js +0 -63
- package/bin/create.js +0 -150
- package/src/core/api/plan.md +0 -209
- package/src/core/events/missing.md +0 -103
- package/src/core/events/plan.md +0 -177
- package/src/core/offline/missing.md +0 -89
- package/src/core/offline/plan.md +0 -143
- package/src/core/platform/missing.md +0 -119
- package/src/core/platform/platform.d.ts +0 -88
- package/src/core/router/missing.md +0 -716
- package/src/core/router/outlet.js +0 -139
- package/src/core/router/plan.md +0 -370
- package/src/core/security/missing.md +0 -97
- package/src/core/state/missing.md +0 -165
- package/src/core/storage/missing.md +0 -165
- package/src/core/storage/plan.md +0 -69
- package/src/core/ui/implementation.md +0 -170
- package/src/core/ui/plan.md +0 -510
- package/src/core/ui/ui.types.md +0 -890
- /package/src/core/animations/{usage.md → notes/usage.md} +0 -0
- /package/src/core/api/{usage.md → notes/usage.md} +0 -0
- /package/src/core/events/{usage.md → notes/usage.md} +0 -0
- /package/src/core/platform/{usage.md → notes/usage.md} +0 -0
- /package/src/core/state/{usage.md → notes/usage.md} +0 -0
- /package/src/core/ui/{usage.md → notes/usage.md} +0 -0
- /package/src/core/ui/{watch.md → notes/watch.md} +0 -0
- /package/src/core/workers/{plan.md → notes/plan.md} +0 -0
- /package/src/core/workers/{usage.md → notes/usage.md} +0 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
# Router Architecture Audit
|
|
2
|
+
|
|
3
|
+
Companion: see `definitions.md` for the unified `page` / `dock` / `view` API that supersedes the split `ui.element` + `router.register` pattern referenced throughout this document.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Critical Analysis
|
|
8
|
+
|
|
9
|
+
### 1.1 Hard Refresh Failure
|
|
10
|
+
|
|
11
|
+
**Bug.**
|
|
12
|
+
`setup()` in `intercept.js` fires its initial route match via a single microtask:
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
// intercept.js
|
|
16
|
+
Promise.resolve().then(async () => {
|
|
17
|
+
const url = window.navigation.currentEntry?.url || window.location.href;
|
|
18
|
+
const found = await match(url); // routes[] is empty on cold boot
|
|
19
|
+
...
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This races against three parallel processes:
|
|
24
|
+
|
|
25
|
+
1. **Route registration** — ES module graph has not yet evaluated any `page(...)` calls.
|
|
26
|
+
2. **Container availability** — Custom Elements have not fired `connectedCallback`.
|
|
27
|
+
3. **Orchestrator attachment** — listener may not yet exist.
|
|
28
|
+
|
|
29
|
+
The microtask fires *before* `DOMContentLoaded`, before Custom Element `connectedCallback`, and before downstream modules finish. Result: `match()` returns `null` or emits `found` to an orchestrator that calls `getContainer()` on a node that has not mounted.
|
|
30
|
+
|
|
31
|
+
**Edge cases.**
|
|
32
|
+
|
|
33
|
+
- `<script type="module">` is deferred by spec — route registration happens in a subsequent microtask *after* `setup()` already fired.
|
|
34
|
+
- `<script type="module" async>` — execution order is non-deterministic.
|
|
35
|
+
- Service Worker serving stale HTML while JS bundles update — container tags may mismatch registered names.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### 1.2 Global Initialization Timing
|
|
40
|
+
|
|
41
|
+
**Bug.**
|
|
42
|
+
The auto-bootstrap block in `router/index.js` runs at module evaluation time:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
if (typeof window !== 'undefined') {
|
|
46
|
+
registerNavigator(navigate);
|
|
47
|
+
setup();
|
|
48
|
+
setupTabSync(router);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `setup()` attaches the Navigation API listener *before* any route is registered.
|
|
53
|
+
- The initial match fires in the same microtask queue tick as module evaluation.
|
|
54
|
+
- No `window.router` global exists — users must import the object, but the pattern implies global availability.
|
|
55
|
+
|
|
56
|
+
**Edge cases.**
|
|
57
|
+
|
|
58
|
+
- Two entry points both importing `router/index.js` — `setup()` is idempotent, but route registration order is undefined.
|
|
59
|
+
- Dynamic `import()` after page load — `setup()` fires, emits initial match, but `DOMContentLoaded` already passed.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
### 1.3 Flat Container Registry
|
|
64
|
+
|
|
65
|
+
**Bug.**
|
|
66
|
+
`container.js` stores `Map<string, WeakRef<HTMLElement>>` — a flat key-value store. It has no concept of:
|
|
67
|
+
|
|
68
|
+
- Parent/child relationships
|
|
69
|
+
- Depth or nesting level
|
|
70
|
+
- Sibling adjacency
|
|
71
|
+
- Path from root to a given container
|
|
72
|
+
|
|
73
|
+
The `route.meta.parent` field in `match.js` chains *route patterns*, not *DOM containers*. A route declaring `container: 'sidebar'` carries no information about whether `sidebar` lives inside `main` which lives inside `body`.
|
|
74
|
+
|
|
75
|
+
**Consequence.**
|
|
76
|
+
Cross-branch navigation — from a view in container A to a view in container B — requires understanding tree shape. Without a graph the router cannot:
|
|
77
|
+
|
|
78
|
+
1. Determine which containers to unmount (everything below the divergence point)
|
|
79
|
+
2. Determine which containers to mount (everything on the new branch)
|
|
80
|
+
3. Identify the Lowest Common Ancestor (LCA) to minimise DOM churn
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
### 1.4 Cross-Branch Reconciliation Is Absent
|
|
85
|
+
|
|
86
|
+
**Bug.**
|
|
87
|
+
Resolution logic in `intercept.js` is binary:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
if (routeMatch?.route?.meta?.container) {
|
|
91
|
+
const name = routeMatch.route.meta.container;
|
|
92
|
+
if (!getContainer(name)) {
|
|
93
|
+
throw new Error(...); // hard failure — no recovery
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
On failure it throws. It makes no attempt to:
|
|
99
|
+
|
|
100
|
+
1. Walk up to a common ancestor that *is* in the DOM
|
|
101
|
+
2. Sequentially mount intermediate containers downward
|
|
102
|
+
3. Await their `connectedCallback` before proceeding
|
|
103
|
+
|
|
104
|
+
**Example.**
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
Target: /settings/profile → container: 'settings-panel'
|
|
108
|
+
Chain: body → main → sidebar → settings-panel
|
|
109
|
+
Current: /dashboard → only 'main' is mounted
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Navigation fails because `settings-panel` doesn't exist — it would only exist after `sidebar` is mounted inside `main`.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
### 1.5 Route-to-Container Mapping Is One-Dimensional
|
|
117
|
+
|
|
118
|
+
**Bug.**
|
|
119
|
+
Each route declares a single `container` string with no way to express the mount chain:
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
router.register('/settings/profile', 'page-profile', { container: 'settings-panel' });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
There is no mechanism to say "to reach `settings-panel`, first ensure `sidebar` is in `main`." The `meta.parent` field chains *route patterns*, not *DOM ancestry*. These are orthogonal trees.
|
|
126
|
+
|
|
127
|
+
**Mismatch.**
|
|
128
|
+
Route hierarchy ≠ container hierarchy. A deeply nested route (`/a/b/c`) might render in a top-level container. A shallow route (`/settings`) might need three nested containers. The current architecture conflates them.
|
|
129
|
+
|
|
130
|
+
**Resolution.**
|
|
131
|
+
The `page(path, { via: [...chain] })` definition in the companion `definitions.md` solves this directly — the ordered array is the container chain root-to-leaf, with the last entry as the render target.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
### 1.6 Auto-Bootstrap Has No Window Bridge
|
|
136
|
+
|
|
137
|
+
**Bug.**
|
|
138
|
+
The router initialises at module evaluation but never attaches to `window`. Any code that runs before the consumer's `import` statement cannot access the router instance. The documentation suggests `window.navigate(...)` but no bridge creates it.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### 1.7 No Cycle Detection in Route Chain
|
|
143
|
+
|
|
144
|
+
**Bug.**
|
|
145
|
+
`match.js` builds the parent chain without cycle detection:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
while (currentRoute) {
|
|
149
|
+
const parentPattern = currentRoute.meta?.parent;
|
|
150
|
+
if (parentPattern) {
|
|
151
|
+
const parentRoute = routes.find(r => r.patternStr === parentPattern);
|
|
152
|
+
currentRoute = parentRoute;
|
|
153
|
+
} else {
|
|
154
|
+
currentRoute = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Route A declaring parent B and route B declaring parent A loops forever.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 2. Proposed Fixes
|
|
164
|
+
|
|
165
|
+
### 2.1 Deferred Boot Gate
|
|
166
|
+
|
|
167
|
+
**Problem.** Initial match fires before routes exist or containers mount.
|
|
168
|
+
|
|
169
|
+
**Fix.** Replace the microtask emit with a gated sequence that waits for explicit readiness signals.
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
// boot.js
|
|
173
|
+
const gates = [];
|
|
174
|
+
let booted = false;
|
|
175
|
+
|
|
176
|
+
export function gate(promise) {
|
|
177
|
+
if (booted) return;
|
|
178
|
+
gates.push(promise);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function boot(emit) {
|
|
182
|
+
if (document.readyState === 'loading') {
|
|
183
|
+
await new Promise(r => document.addEventListener('DOMContentLoaded', r, { once: true }));
|
|
184
|
+
}
|
|
185
|
+
await Promise.all(gates);
|
|
186
|
+
gates.length = 0;
|
|
187
|
+
booted = true;
|
|
188
|
+
emit();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function ready() {
|
|
192
|
+
return booted;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Integration in `intercept.js`:**
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
import { boot, ready } from './boot.js';
|
|
200
|
+
|
|
201
|
+
export function setup() {
|
|
202
|
+
if (ready()) return;
|
|
203
|
+
// attach Navigation API listener ...
|
|
204
|
+
|
|
205
|
+
boot(async () => {
|
|
206
|
+
const url = window.navigation.currentEntry?.url || location.href;
|
|
207
|
+
const found = await match(url);
|
|
208
|
+
if (found) emit('found', { ...found, direction: 'load' });
|
|
209
|
+
else emit('notfound', { url });
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Route files gate on their element's CE registration:**
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
// called internally by page() in defs/page.js
|
|
218
|
+
import { gate } from '../router/boot.js';
|
|
219
|
+
gate(customElements.whenDefined(tag));
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Why native.**
|
|
223
|
+
`document.readyState` — synchronous, zero-cost check.
|
|
224
|
+
`DOMContentLoaded` — fires once, no polling.
|
|
225
|
+
`Promise.all` — microtask, no timers.
|
|
226
|
+
`customElements.whenDefined` — native CE API, no polling.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
### 2.2 Window Bridge
|
|
231
|
+
|
|
232
|
+
**Fix.** Attach a non-configurable global during auto-bootstrap.
|
|
233
|
+
|
|
234
|
+
```js
|
|
235
|
+
// router/index.js
|
|
236
|
+
if (typeof window !== 'undefined') {
|
|
237
|
+
Object.defineProperty(window, 'router', {
|
|
238
|
+
value: router,
|
|
239
|
+
writable: false,
|
|
240
|
+
enumerable: false,
|
|
241
|
+
configurable: false
|
|
242
|
+
});
|
|
243
|
+
registerNavigator(navigate);
|
|
244
|
+
setup();
|
|
245
|
+
setupTabSync(router);
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`writable: false` prevents accidental overwrite. `enumerable: false` keeps it out of `for...in`. `configurable: false` makes it permanent. Import-style usage still works identically.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
### 2.3 Hierarchical Container Graph
|
|
254
|
+
|
|
255
|
+
**Fix.** Replace the flat `Map<string, WeakRef>` with a tree that stores parent-child relationships and depth.
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
// graph.js
|
|
259
|
+
class Node {
|
|
260
|
+
constructor(name, ref, parent = null) {
|
|
261
|
+
this.name = name;
|
|
262
|
+
this.ref = ref; // WeakRef<HTMLElement>
|
|
263
|
+
this.parent = parent; // Node | null
|
|
264
|
+
this.children = new Set(); // Set<Node>
|
|
265
|
+
this.depth = parent ? parent.depth + 1 : 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
alive() {
|
|
269
|
+
return this.ref.deref() !== undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const nodes = new Map();
|
|
274
|
+
const root = new Node('body', null, null);
|
|
275
|
+
nodes.set('body', root);
|
|
276
|
+
|
|
277
|
+
export function add(name, el, parent = 'body') {
|
|
278
|
+
const ref = new WeakRef(el);
|
|
279
|
+
const parentNode = nodes.get(parent) ?? root;
|
|
280
|
+
const node = new Node(name, ref, parentNode);
|
|
281
|
+
parentNode.children.add(node);
|
|
282
|
+
nodes.set(name, node);
|
|
283
|
+
return node;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function remove(name) {
|
|
287
|
+
const node = nodes.get(name);
|
|
288
|
+
if (!node) return;
|
|
289
|
+
node.parent?.children.delete(node);
|
|
290
|
+
for (const child of node.children) {
|
|
291
|
+
child.parent = node.parent;
|
|
292
|
+
child.depth = child.parent ? child.parent.depth + 1 : 0;
|
|
293
|
+
node.parent?.children.add(child);
|
|
294
|
+
}
|
|
295
|
+
nodes.delete(name);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function get(name) { return nodes.get(name) ?? null; }
|
|
299
|
+
export function element(name) { return nodes.get(name)?.ref.deref() ?? null; }
|
|
300
|
+
export function clear() { nodes.clear(); root.children.clear(); nodes.set('body', root); }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Containers declare their parent when defined via `dock(name, { parent })` in `definitions.md`.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
### 2.4 Lowest Common Ancestor
|
|
308
|
+
|
|
309
|
+
**Problem.** Navigate from container A to container B — which containers unmount and which mount?
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// lca.js
|
|
313
|
+
import { get } from './graph.js';
|
|
314
|
+
|
|
315
|
+
export function lca(a, b) {
|
|
316
|
+
let na = get(a);
|
|
317
|
+
let nb = get(b);
|
|
318
|
+
if (!na || !nb) return null;
|
|
319
|
+
|
|
320
|
+
while (na.depth > nb.depth) na = na.parent;
|
|
321
|
+
while (nb.depth > na.depth) nb = nb.parent;
|
|
322
|
+
while (na !== nb) { na = na.parent; nb = nb.parent; }
|
|
323
|
+
|
|
324
|
+
return na;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function path(from, to) {
|
|
328
|
+
const ancestor = lca(from, to);
|
|
329
|
+
if (!ancestor) return null;
|
|
330
|
+
|
|
331
|
+
const segments = [];
|
|
332
|
+
let current = get(to);
|
|
333
|
+
while (current && current !== ancestor) {
|
|
334
|
+
segments.unshift(current);
|
|
335
|
+
current = current.parent;
|
|
336
|
+
}
|
|
337
|
+
segments.unshift(ancestor);
|
|
338
|
+
return segments;
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Complexity.** O(d) where d = maximum tree depth. For typical UIs (depth 3–6) this is effectively O(1). No allocations on the hot path.
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
### 2.5 Cascade Mount Sequence
|
|
347
|
+
|
|
348
|
+
**Problem.** Target container not in the DOM. Must render containers root-to-leaf sequentially.
|
|
349
|
+
|
|
350
|
+
```js
|
|
351
|
+
// cascade.js
|
|
352
|
+
import { get, element as resolve } from './graph.js';
|
|
353
|
+
import { path } from './lca.js';
|
|
354
|
+
|
|
355
|
+
export async function ensure(target, current) {
|
|
356
|
+
const segments = path(current ?? 'body', target);
|
|
357
|
+
if (!segments) throw new Error(`No path: '${current}' → '${target}'`);
|
|
358
|
+
|
|
359
|
+
// Find deepest mounted node on the path
|
|
360
|
+
let mounted = null;
|
|
361
|
+
for (const node of segments) {
|
|
362
|
+
const el = node.ref?.deref();
|
|
363
|
+
if (el?.isConnected) mounted = node;
|
|
364
|
+
else break;
|
|
365
|
+
}
|
|
366
|
+
if (!mounted) mounted = get('body');
|
|
367
|
+
|
|
368
|
+
// Mount sequentially from first unmounted downward
|
|
369
|
+
const start = segments.indexOf(mounted) + 1;
|
|
370
|
+
for (let i = start; i < segments.length; i++) {
|
|
371
|
+
const node = segments[i];
|
|
372
|
+
const parentEl = node.parent.ref?.deref();
|
|
373
|
+
if (!parentEl?.isConnected) throw new Error(`Parent '${node.parent.name}' disconnected`);
|
|
374
|
+
|
|
375
|
+
const tag = node.name;
|
|
376
|
+
if (tag.includes('-') && !customElements.get(tag)) {
|
|
377
|
+
await customElements.whenDefined(tag);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const el = document.createElement(tag);
|
|
381
|
+
if (typeof parentEl.swap === 'function') {
|
|
382
|
+
await parentEl.swap(el, { direction: 'push' });
|
|
383
|
+
} else {
|
|
384
|
+
parentEl.replaceChildren(el);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Yield for connectedCallback
|
|
388
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
389
|
+
|
|
390
|
+
if (!resolve(node.name)) throw new Error(`Container '${node.name}' failed to register`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return resolve(target);
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**Why native.**
|
|
398
|
+
`customElements.whenDefined` — awaits CE registration, no polling.
|
|
399
|
+
`document.createElement` — zero-cost node factory.
|
|
400
|
+
`requestAnimationFrame` — yields to layout, guarantees `connectedCallback` fires.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
### 2.6 Route Container Chain
|
|
405
|
+
|
|
406
|
+
**Fix.** Routes declare an ordered array of containers root-to-leaf. The last entry is the render target.
|
|
407
|
+
|
|
408
|
+
Using the new `page()` definition from `definations.md`:
|
|
409
|
+
|
|
410
|
+
```js
|
|
411
|
+
page('/settings/profile', {
|
|
412
|
+
tag: 'page-profile',
|
|
413
|
+
via: ['main', 'sidebar', 'settings-panel'],
|
|
414
|
+
template: {
|
|
415
|
+
html: './template.html',
|
|
416
|
+
css: './style.css'
|
|
417
|
+
}
|
|
418
|
+
}, import.meta.url)
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
During navigation:
|
|
422
|
+
|
|
423
|
+
```js
|
|
424
|
+
// intercept.js handler
|
|
425
|
+
const chain = meta.via ?? (meta.container ? [meta.container] : []);
|
|
426
|
+
const target = chain.at(-1);
|
|
427
|
+
|
|
428
|
+
for (let i = 0; i < chain.length; i++) {
|
|
429
|
+
if (!resolve(chain[i])) {
|
|
430
|
+
await ensure(chain[i], chain[i - 1] ?? 'body');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const container = resolve(target);
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**Backward compatibility.** A single `container` string still works — treated as `via: [container]`.
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
### 2.7 Cycle Detection in Route Chain
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
// match.js
|
|
445
|
+
const visited = new Set();
|
|
446
|
+
while (currentRoute) {
|
|
447
|
+
if (visited.has(currentRoute.patternStr)) break;
|
|
448
|
+
visited.add(currentRoute.patternStr);
|
|
449
|
+
const parentPattern = currentRoute.meta?.parent;
|
|
450
|
+
currentRoute = parentPattern
|
|
451
|
+
? routes.find(r => r.patternStr === parentPattern)
|
|
452
|
+
: null;
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
## 3. Data Structures & Algorithms
|
|
459
|
+
|
|
460
|
+
### 3.1 Container Tree Node
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
Node {
|
|
464
|
+
name: string // 'main' | 'sidebar' | 'settings-panel'
|
|
465
|
+
ref: WeakRef<Element> // GC-safe DOM reference
|
|
466
|
+
parent: Node | null // pointer up the tree
|
|
467
|
+
children: Set<Node> // immediate children
|
|
468
|
+
depth: number // distance from root (body = 0)
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
| Operation | Complexity | Method |
|
|
473
|
+
|-----------|------------------|----------------------|
|
|
474
|
+
| Add | O(1) | `add(name, el, parent)` |
|
|
475
|
+
| Remove | O(c) c=children | `remove(name)` |
|
|
476
|
+
| Lookup | O(1) | `get(name)` via Map |
|
|
477
|
+
| LCA | O(d) d=max depth | `lca(a, b)` |
|
|
478
|
+
| Path | O(d) | `path(from, to)` |
|
|
479
|
+
| Cascade | O(d) × await | `ensure(target)` |
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
### 3.2 Radix Trie Route Matcher
|
|
484
|
+
|
|
485
|
+
Linear scan through sorted routes is O(n) per navigation. A Radix Trie achieves O(k) where k = URL segment count.
|
|
486
|
+
|
|
487
|
+
```js
|
|
488
|
+
// trie.js
|
|
489
|
+
class Segment {
|
|
490
|
+
constructor() {
|
|
491
|
+
this.static = new Map(); // literal → Segment
|
|
492
|
+
this.param = null; // Segment (for :named)
|
|
493
|
+
this.wild = null; // Segment (for *)
|
|
494
|
+
this.route = null; // matched route entry
|
|
495
|
+
this.key = null; // param name
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const trie = new Segment();
|
|
500
|
+
|
|
501
|
+
export function insert(pattern, route) {
|
|
502
|
+
const parts = pattern.split('/').filter(Boolean);
|
|
503
|
+
let node = trie;
|
|
504
|
+
|
|
505
|
+
for (const part of parts) {
|
|
506
|
+
if (part.startsWith(':')) {
|
|
507
|
+
if (!node.param) { node.param = new Segment(); node.param.key = part.slice(1); }
|
|
508
|
+
node = node.param;
|
|
509
|
+
} else if (part === '*') {
|
|
510
|
+
if (!node.wild) node.wild = new Segment();
|
|
511
|
+
node = node.wild;
|
|
512
|
+
} else {
|
|
513
|
+
if (!node.static.has(part)) node.static.set(part, new Segment());
|
|
514
|
+
node = node.static.get(part);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
node.route = route;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function find(pathname) {
|
|
522
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
523
|
+
const params = {};
|
|
524
|
+
const result = walk(trie, parts, 0, params);
|
|
525
|
+
return result ? { route: result, params } : null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function walk(node, parts, i, params) {
|
|
529
|
+
if (i === parts.length) return node.route;
|
|
530
|
+
|
|
531
|
+
const seg = parts[i];
|
|
532
|
+
|
|
533
|
+
if (node.static.has(seg)) {
|
|
534
|
+
const result = walk(node.static.get(seg), parts, i + 1, params);
|
|
535
|
+
if (result) return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (node.param) {
|
|
539
|
+
params[node.param.key] = seg;
|
|
540
|
+
const result = walk(node.param, parts, i + 1, params);
|
|
541
|
+
if (result) return result;
|
|
542
|
+
delete params[node.param.key];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (node.wild) {
|
|
546
|
+
params['*'] = parts.slice(i).join('/');
|
|
547
|
+
return node.wild.route;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
| Approach | Time (n routes, k segments) | Memory |
|
|
555
|
+
|------------------------|-----------------------------|----------------------|
|
|
556
|
+
| Linear scan (current) | O(n) | O(n) route objects |
|
|
557
|
+
| Sorted + binary search | O(k · log n) | O(n) |
|
|
558
|
+
| Radix trie | O(k) | O(total segments) |
|
|
559
|
+
|
|
560
|
+
URLPattern remains available as a fallback for patterns the trie cannot express (regex groups, modifiers).
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
### 3.3 LCA Pseudocode
|
|
565
|
+
|
|
566
|
+
```
|
|
567
|
+
FUNCTION lca(a, b):
|
|
568
|
+
na ← graph.get(a)
|
|
569
|
+
nb ← graph.get(b)
|
|
570
|
+
IF na is null OR nb is null → RETURN null
|
|
571
|
+
|
|
572
|
+
WHILE na.depth > nb.depth → na ← na.parent
|
|
573
|
+
WHILE nb.depth > na.depth → nb ← nb.parent
|
|
574
|
+
WHILE na ≠ nb → na ← na.parent; nb ← nb.parent
|
|
575
|
+
|
|
576
|
+
RETURN na
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
**Proof.** After depth equalisation both pointers share depth. Since the tree is finite and rooted, simultaneous upward traversal must converge at the LCA.
|
|
580
|
+
|
|
581
|
+
**Worst case.** O(d). For real UI trees d rarely exceeds 5.
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
### 3.4 Cascade Sequence Pseudocode
|
|
586
|
+
|
|
587
|
+
```
|
|
588
|
+
FUNCTION ensure(target, source):
|
|
589
|
+
segments ← path(source ?? 'body', target)
|
|
590
|
+
|
|
591
|
+
mounted ← null
|
|
592
|
+
FOR node IN segments:
|
|
593
|
+
IF node.ref.deref() AND node.ref.deref().isConnected:
|
|
594
|
+
mounted ← node
|
|
595
|
+
ELSE:
|
|
596
|
+
BREAK
|
|
597
|
+
|
|
598
|
+
IF mounted is null → mounted ← root
|
|
599
|
+
|
|
600
|
+
start ← indexOf(mounted) + 1
|
|
601
|
+
FOR i FROM start TO segments.length - 1:
|
|
602
|
+
node ← segments[i]
|
|
603
|
+
parentEl ← node.parent.ref.deref()
|
|
604
|
+
ASSERT parentEl.isConnected
|
|
605
|
+
|
|
606
|
+
IF node.name contains '-':
|
|
607
|
+
AWAIT customElements.whenDefined(node.name)
|
|
608
|
+
|
|
609
|
+
el ← document.createElement(node.name)
|
|
610
|
+
|
|
611
|
+
IF parentEl.swap exists → AWAIT parentEl.swap(el)
|
|
612
|
+
ELSE → parentEl.replaceChildren(el)
|
|
613
|
+
|
|
614
|
+
AWAIT animationFrame // yield for connectedCallback
|
|
615
|
+
|
|
616
|
+
ASSERT graph.element(node.name) is defined
|
|
617
|
+
|
|
618
|
+
RETURN graph.element(target)
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
## 4. Edge Cases & Bugs
|
|
624
|
+
|
|
625
|
+
### 4.1 MutationObserver Starvation
|
|
626
|
+
|
|
627
|
+
**Bug.** The `MutationObserver` in `container.js` is initialised inside `requestIdleCallback`. Under sustained 60 fps animation `requestIdleCallback` may never fire — the observer never attaches and standard-element containers are never discovered.
|
|
628
|
+
|
|
629
|
+
**Fix.**
|
|
630
|
+
|
|
631
|
+
```js
|
|
632
|
+
function attach() { /* create and observe */ }
|
|
633
|
+
|
|
634
|
+
if (typeof requestIdleCallback !== 'undefined') {
|
|
635
|
+
requestIdleCallback(attach, { timeout: 100 });
|
|
636
|
+
} else {
|
|
637
|
+
requestAnimationFrame(attach);
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
The `timeout: 100` option forces the callback even under load, with `requestAnimationFrame` as the fallback for environments that lack `requestIdleCallback`.
|
|
642
|
+
|
|
643
|
+
---
|
|
644
|
+
|
|
645
|
+
### 4.2 WeakRef Timing Hole
|
|
646
|
+
|
|
647
|
+
**Bug.** Between `disconnectedCallback` and `FinalizationRegistry` callback, `getContainer()` returns `undefined` even for an intentionally unmounted element. The consumer cannot distinguish "GC'd unexpectedly" from "unmounted normally."
|
|
648
|
+
|
|
649
|
+
**Fix.** Track explicit unregistrations:
|
|
650
|
+
|
|
651
|
+
```js
|
|
652
|
+
const gone = new Set();
|
|
653
|
+
|
|
654
|
+
export function unregister(name) {
|
|
655
|
+
gone.add(name);
|
|
656
|
+
setTimeout(() => gone.delete(name), 0); // clear after one macrotask (rapid remount)
|
|
657
|
+
nodes.delete(name);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export function element(name) {
|
|
661
|
+
if (gone.has(name)) return undefined;
|
|
662
|
+
return nodes.get(name)?.ref.deref() ?? null;
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
### 4.3 Concurrent Navigation Race
|
|
669
|
+
|
|
670
|
+
**Bug.** Two rapid navigations can both pass the container check, then both call `startViewTransition`. The browser aborts the first transition when the second starts — visual jank.
|
|
671
|
+
|
|
672
|
+
**Fix.**
|
|
673
|
+
|
|
674
|
+
```js
|
|
675
|
+
// In the swap method of a container element
|
|
676
|
+
async swap(el, options = {}) {
|
|
677
|
+
if (this._tx) this._tx.skipTransition();
|
|
678
|
+
|
|
679
|
+
const go = () => this.replaceChildren(el);
|
|
680
|
+
|
|
681
|
+
if (typeof this.startViewTransition === 'function') {
|
|
682
|
+
this._tx = this.startViewTransition({ callback: go });
|
|
683
|
+
await this._tx.finished;
|
|
684
|
+
this._tx = null;
|
|
685
|
+
} else {
|
|
686
|
+
go();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
---
|
|
692
|
+
|
|
693
|
+
### 4.4 Tab Sync Redirect Amplification
|
|
694
|
+
|
|
695
|
+
**Bug.** `isSyncing` is reset in `.finally()` before a guard-triggered redirect completes. The redirect URL differs from `sent`, causing a re-broadcast storm across tabs.
|
|
696
|
+
|
|
697
|
+
**Fix.** Delay the unsync by one macrotask to cover the redirect chain:
|
|
698
|
+
|
|
699
|
+
```js
|
|
700
|
+
channel.onmessage = ({ data }) => {
|
|
701
|
+
const { type, url } = data ?? {};
|
|
702
|
+
if (type !== 'sync-navigate') return;
|
|
703
|
+
if (sent === url) return;
|
|
704
|
+
sent = url;
|
|
705
|
+
isSyncing = true;
|
|
706
|
+
|
|
707
|
+
const finish = () => setTimeout(() => { isSyncing = false; }, 0);
|
|
708
|
+
const result = router.navigate(url);
|
|
709
|
+
result?.finished?.then(finish, finish) ?? finish();
|
|
710
|
+
};
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
---
|
|
714
|
+
|
|
715
|
+
## 5. Integration Roadmap
|
|
716
|
+
|
|
717
|
+
| Phase | Change | Risk | Files | Status |
|
|
718
|
+
|-------|--------------------------------|--------|---------------------------------|--------|
|
|
719
|
+
| 1 | `boot.js` deferred gate | Low | `boot.js`, `intercept.js` | Done |
|
|
720
|
+
| 2 | `window.router` bridge | Low | `index.js` | Done |
|
|
721
|
+
| 3 | `graph.js` tree structure | Medium | `graph.js`, `container.js` | Done |
|
|
722
|
+
| 4 | `lca.js` algorithm | Low | `lca.js` | Done |
|
|
723
|
+
| 5 | `cascade.js` mount sequence | Medium | `cascade.js`, `intercept.js` | Done |
|
|
724
|
+
| 6 | `defs/` definitions layer | Low | `defs/page.js`, `defs/dock.js`, `defs/view.js` | Done |
|
|
725
|
+
| 7 | `trie.js` route matcher | Low | `trie.js`, `match.js` | Done |
|
|
726
|
+
| 8 | Bug fixes (§ 4.1 – 4.4) | Low | Various | Done |
|
|
727
|
+
|
|
728
|
+
All phases implemented. The `page` / `dock` / `view` / `part` definition layer is now the primary API. The legacy `ui.element({ url })` + `ui.container()` paths still function but are no longer recommended.
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
## 6. Performance Budget
|
|
733
|
+
|
|
734
|
+
| Operation | Target | Mechanism |
|
|
735
|
+
|------------------------|---------------------|----------------------------------|
|
|
736
|
+
| Route match | < 0.1 ms (warm) | Trie walk / URLPattern.exec |
|
|
737
|
+
| LCA computation | < 0.01 ms | Pointer arithmetic |
|
|
738
|
+
| Cascade (per level) | < 16 ms (one frame) | `createElement` + `rAF` |
|
|
739
|
+
| Container lookup | < 0.01 ms | `Map.get` + `WeakRef.deref` |
|
|
740
|
+
| Boot gate | < 50 ms after DCL | `Promise.all` |
|
|
741
|
+
|
|
742
|
+
All hot-path operations target zero GC pressure. `WeakRef.deref` is non-allocating. `Map.get` is O(1) amortised. The trie avoids intermediate allocations during traversal.
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
## 7. Naming Convention
|
|
747
|
+
|
|
748
|
+
All new identifiers follow the one-word rule. Hyphens in custom element tag names are a browser requirement, not a convention violation.
|
|
749
|
+
|
|
750
|
+
| Concept | Name | File |
|
|
751
|
+
|------------------------|-----------|-------------|
|
|
752
|
+
| Container tree node | `Node` | `graph.js` |
|
|
753
|
+
| Tree module | `graph` | `graph.js` |
|
|
754
|
+
| LCA function | `lca` | `lca.js` |
|
|
755
|
+
| Root-to-target path | `path` | `lca.js` |
|
|
756
|
+
| Boot prerequisite | `gate` | `boot.js` |
|
|
757
|
+
| Boot trigger | `boot` | `boot.js` |
|
|
758
|
+
| Cascade mount | `ensure` | `cascade.js`|
|
|
759
|
+
| Trie node | `Segment` | `trie.js` |
|
|
760
|
+
| Trie insert | `insert` | `trie.js` |
|
|
761
|
+
| Trie lookup | `find` | `trie.js` |
|
|
762
|
+
| Node alive check | `alive` | `graph.js` |
|
|
763
|
+
| DOM element resolve | `element` | `graph.js` |
|
|
764
|
+
| Route + element + slot | `page` | `defs/page.js` |
|
|
765
|
+
| Container element | `dock` | `defs/dock.js` |
|
|
766
|
+
| Plain component | `view` | `defs/view.js` |
|
|
767
|
+
| Container swap method | `swap` | element class |
|
|
768
|
+
|
|
769
|
+
Files: `graph.js`, `lca.js`, `cascade.js`, `boot.js`, `trie.js`, `defs/page.js`, `defs/dock.js`, `defs/view.js` — all single-word, lowercase.
|
|
770
|
+
|
|
771
|
+
---
|
|
772
|
+
|
|
773
|
+
*End of audit.*
|