solid-tag-runtime 0.0.15 → 0.0.17

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/ARCHITECTURE.md CHANGED
@@ -1476,3 +1476,76 @@ Reason: HTML lifecycle subscribers, devtools, status panels, and normal applicat
1476
1476
  Invariant: unrelated DOM mutations must not enqueue runtime discovery or emit `observer-batch`. Observing `document` is therefore robust against diagnostic/application UI churn, while matching declarations and mounted-render cleanup remain live.
1477
1477
 
1478
1478
  Regression coverage: package tests verify unrelated child-list additions enqueue no observer batch, matching runtime scripts still register normally, an `observer-batch` subscriber may render unrelated DOM without recursively triggering additional scans, and relevant declaration removals still dispose mounted render ownership. The browser playground test suite carries the same feedback-loop regression against the native `MutationObserver`.
1479
+
1480
+
1481
+ ### 0.0.16 — incremental mutation-driven observation
1482
+
1483
+ Decision: keep the 0.0.15 declaration-driven relevance filter, but stop turning every relevant `MutationObserver` callback back into `enqueueObserverScan()`. Normal observer callbacks now derive a bounded work set directly from `MutationRecord[]`.
1484
+
1485
+ Pipeline:
1486
+
1487
+ ```text
1488
+ MutationRecord[]
1489
+ ↓
1490
+ collectObserverMutationBatch()
1491
+ ├── exact added declaration elements
1492
+ ├── exact added <solid-render> retry candidates
1493
+ ├── tracked mounted records affected by removal
1494
+ └── unsafe/unknown host mutation → conservative full scan
1495
+ ↓
1496
+ enqueueObserverBatch()
1497
+ ↓
1498
+ process exact candidates once
1499
+ ```
1500
+
1501
+ Rules:
1502
+
1503
+ 1. An added element is checked directly and only its added subtree is queried for matching declarations.
1504
+ 2. Candidate identity is deduplicated across mutation records before registration.
1505
+ 3. Elements already owned by the same controller are dropped before observer work is queued, so `append()`/`addModule()` do not rediscover their own DOM mutations.
1506
+ 4. Relevant removals carry the exact mounted ownership records forward and dispose only records that are actually detached after the mutation batch settles.
1507
+ 5. `register()`, initial `observe({ registerExisting:true })`, explicit `flush()`, and root-change registration retain full-root scan semantics.
1508
+ 6. Unknown/custom DOM hosts that cannot be classified safely fall back to the previous full-root scan path.
1509
+ 7. Define-before-entry/render ordering is preserved because all exact declaration candidates in one observer callback are still claimed and defined as one batch before entries/renders execute.
1510
+
1511
+ Invariant: live observation is mutation-driven and incremental. Relevant mutation cost should scale with the changed subtree or tracked removal set, not with total root size. Full-root discovery is reserved for explicit scan operations and conservative fallback.
1512
+
1513
+ Regression coverage adds direct-addition and nested-subtree cases that assert zero root selector calls, duplicate-record deduplication, and explicit `append()` under active observation producing no observer batch. `bench/observer.mjs` provides a synthetic scaling check comparing incremental addition with forced `flush()`.
1514
+
1515
+
1516
+ ### 0.0.17 — explicit observation lifecycle strategies
1517
+
1518
+ Decision: observation is a compatibility mechanism for DOM changes the runtime does not already know about, not the primary module lifecycle mechanism. Keep `observe()` continuous by default for compatibility, but add an opt-in bootstrap lifecycle for applications that only need automatic discovery during initial page/framework startup.
1519
+
1520
+ Public modes:
1521
+
1522
+ ```text
1523
+ manual / no observer
1524
+ html.register() + explicit APIs + optional html.flush()
1525
+
1526
+ bootstrap / temporary observer
1527
+ html.observe({ mode: "bootstrap", idleMs })
1528
+
1529
+ continuous / long-lived observer
1530
+ html.observe()
1531
+ html.observe({ mode: "continuous" })
1532
+ ```
1533
+
1534
+ Bootstrap semantics:
1535
+
1536
+ 1. connect the normal incremental observer;
1537
+ 2. optionally register existing declarations using the existing `registerExisting` behavior;
1538
+ 3. remain connected through DOM readiness;
1539
+ 4. only relevant observer mutations increment bootstrap activity; unrelated UI mutations do not extend observation;
1540
+ 5. after `idleMs` with no new relevant activity, wait for the controller queue to drain;
1541
+ 6. if activity occurred during the wait, repeat the quiet-period check;
1542
+ 7. otherwise disconnect with lifecycle reason `bootstrap-idle`;
1543
+ 8. a manual `disconnect()` cancels bootstrap settling;
1544
+ 9. root rebinding preserves bootstrap state and resets relevant activity rather than creating a second observer;
1545
+ 10. after bootstrap completes, a later `observe()` may start a fresh continuous session.
1546
+
1547
+ Default `idleMs` is 50 ms. Invalid modes and negative/non-finite idle values are rejected.
1548
+
1549
+ Invariant: use continuous observation only when runtime declarations may enter the DOM through code outside the runtime's explicit APIs. Controlled dynamic module loading should remain observer-free after initial registration.
1550
+
1551
+ Regression coverage verifies continuous compatibility, bootstrap initial registration, late startup additions, unrelated mutation filtering, automatic disconnect, option validation, and re-observation after bootstrap.
package/README.md CHANGED
@@ -16,6 +16,31 @@ host Solid runtime
16
16
 
17
17
  The core package is module-first and DOM-independent. Browser discovery, ownership, declarative rendering, and `<solid-render>` live in `solid-tag-runtime/html`. Optional Solid setup/provider helpers live in `solid-tag-runtime/solid`.
18
18
 
19
+ ## 0.0.17 observation strategies
20
+
21
+ `0.0.17` adds explicit observation lifecycle strategies while keeping existing behavior backward compatible. `observe()` is still continuous by default. Applications that only need DOM discovery during startup can use `observe({ mode: "bootstrap" })`; it observes through DOM readiness and disconnects after a configurable quiet period of relevant runtime mutations.
22
+
23
+ ```ts
24
+ // Lowest ongoing overhead: initial scan, then explicit runtime APIs.
25
+ await html.register();
26
+
27
+ // Mostly declarative startup: temporary observer.
28
+ await html.observe({ mode: "bootstrap", idleMs: 50 });
29
+
30
+ // Unknown external DOM mutations throughout application lifetime.
31
+ await html.observe({ mode: "continuous" });
32
+ ```
33
+
34
+ Only relevant declaration/removal activity extends bootstrap lifetime. Ordinary application UI mutations do not.
35
+
36
+ ## 0.0.16 incremental observer processing
37
+
38
+ `0.0.16` keeps the `0.0.15` declaration-driven filter but removes the normal relevant-mutation root rescan. `MutationRecord[]` are converted into exact declaration, `<solid-render>`, and mounted-removal candidates; added subtrees are queried locally and duplicate candidates are coalesced before one observer batch.
39
+
40
+ Explicit APIs such as `append()` and `addModule()` claim ownership before DOM insertion, so an active observer now ignores that already-known work entirely. Full-root discovery remains available for initial registration, `flush()`, root-change registration, and conservative fallback when a host DOM cannot be classified safely.
41
+
42
+ The key performance invariant is that relevant observer work scales with the changed subtree rather than total root size.
43
+
19
44
  ## 0.0.15 observer hardening
20
45
 
21
46
  `0.0.15` filters `MutationObserver` records before scheduling HTML discovery. Ordinary UI DOM changes no longer enqueue a full runtime scan or emit an `observer-batch`; matching runtime declarations, `<solid-render>` retry boundaries, and removals that can detach mounted declarative renders still trigger observer work. Explicit `html.flush()` remains a deterministic forced scan.
@@ -217,9 +242,9 @@ When loading from an import map, map every used package subpath to the same rele
217
242
  ```json
218
243
  {
219
244
  "imports": {
220
- "solid-tag-runtime": "https://esm.sh/solid-tag-runtime@0.0.15",
221
- "solid-tag-runtime/html": "https://esm.sh/solid-tag-runtime@0.0.15/html",
222
- "solid-tag-runtime/solid": "https://esm.sh/solid-tag-runtime@0.0.15/solid"
245
+ "solid-tag-runtime": "https://esm.sh/solid-tag-runtime@0.0.17",
246
+ "solid-tag-runtime/html": "https://esm.sh/solid-tag-runtime@0.0.17/html",
247
+ "solid-tag-runtime/solid": "https://esm.sh/solid-tag-runtime@0.0.17/solid"
223
248
  }
224
249
  }
225
250
  ```
@@ -0,0 +1,103 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { createRuntime } from "../src/runtime.js";
3
+ import { createHTMLRuntime } from "../src/html.js";
4
+
5
+ class BenchMutationObserver {
6
+ static instances = [];
7
+ constructor(callback) { this.callback = callback; BenchMutationObserver.instances.push(this); }
8
+ observe(target) { this.target = target; this.connected = true; }
9
+ disconnect() { this.connected = false; }
10
+ trigger(records) { if (this.connected) this.callback(records, this); }
11
+ }
12
+
13
+ function script(id) {
14
+ const attrs = { type: "solid-js", module: id };
15
+ return {
16
+ nodeType: 1,
17
+ textContent: "export const value = 1;",
18
+ parentNode: null,
19
+ getAttribute(name) { return attrs[name] ?? null; },
20
+ hasAttribute(name) { return Object.prototype.hasOwnProperty.call(attrs, name); },
21
+ setAttribute(name, value) { attrs[name] = String(value); },
22
+ matches(selector) { return String(selector).includes('script[type="solid-js"]'); },
23
+ querySelectorAll() { return []; },
24
+ contains(target) { return target === this; },
25
+ };
26
+ }
27
+
28
+ function makeRoot(size) {
29
+ const filler = Array.from({ length: size }, () => ({ nodeType: 1 }));
30
+ const scripts = [];
31
+ let visited = 0;
32
+ return {
33
+ nodes: filler,
34
+ scripts,
35
+ querySelectorAll(selector) {
36
+ visited += this.nodes.length + this.scripts.length;
37
+ if (selector === "solid-render") return [];
38
+ return [...this.scripts];
39
+ },
40
+ contains(node) { return this.nodes.includes(node) || this.scripts.includes(node); },
41
+ resetVisited() { visited = 0; },
42
+ getVisited() { return visited; },
43
+ };
44
+ }
45
+
46
+ function makeRuntime() {
47
+ return createRuntime({
48
+ compiler: {
49
+ fingerprint: "observer-bench-v1",
50
+ analyze() { return { imports: [] }; },
51
+ transform(source) { return { code: source, diagnostics: [] }; },
52
+ },
53
+ });
54
+ }
55
+
56
+ async function waitTask() { await new Promise(resolve => setTimeout(resolve, 0)); }
57
+
58
+ async function measureIncremental(size) {
59
+ BenchMutationObserver.instances.length = 0;
60
+ const runtime = makeRuntime();
61
+ const root = makeRoot(size);
62
+ const html = createHTMLRuntime(runtime, { root, MutationObserver: BenchMutationObserver });
63
+ await html.observe({ registerExisting: false });
64
+ root.resetVisited();
65
+ const element = script(`/incremental-${size}.js`);
66
+ element.parentNode = root;
67
+ root.scripts.push(element);
68
+ const t0 = performance.now();
69
+ BenchMutationObserver.instances[0].trigger([{ type: "childList", addedNodes: [element], removedNodes: [] }]);
70
+ await waitTask();
71
+ const ms = performance.now() - t0;
72
+ const visited = root.getVisited();
73
+ html.disconnect();
74
+ runtime.dispose();
75
+ return { ms, visited };
76
+ }
77
+
78
+ async function measureForcedScan(size) {
79
+ BenchMutationObserver.instances.length = 0;
80
+ const runtime = makeRuntime();
81
+ const root = makeRoot(size);
82
+ const html = createHTMLRuntime(runtime, { root, MutationObserver: BenchMutationObserver });
83
+ await html.observe({ registerExisting: false });
84
+ const element = script(`/scan-${size}.js`);
85
+ element.parentNode = root;
86
+ root.scripts.push(element);
87
+ root.resetVisited();
88
+ const t0 = performance.now();
89
+ await html.flush();
90
+ const ms = performance.now() - t0;
91
+ const visited = root.getVisited();
92
+ html.disconnect();
93
+ runtime.dispose();
94
+ return { ms, visited };
95
+ }
96
+
97
+ console.log("observer benchmark (synthetic root; lower visited count is the main invariant)");
98
+ console.log("nodes\tincremental ms\tincremental visited\tflush ms\tflush visited");
99
+ for (const size of [1_000, 10_000, 50_000]) {
100
+ const incremental = await measureIncremental(size);
101
+ const scan = await measureForcedScan(size);
102
+ console.log(`${size}\t${incremental.ms.toFixed(3)}\t${incremental.visited}\t${scan.ms.toFixed(3)}\t${scan.visited}`);
103
+ }
package/docs/api/html.md CHANGED
@@ -30,12 +30,18 @@ Common options:
30
30
 
31
31
  ```ts
32
32
  await html.register(options?);
33
- await html.observe(options?);
33
+ await html.observe({
34
+ mode: "continuous" | "bootstrap",
35
+ idleMs: 50,
36
+ ...options,
37
+ });
34
38
  await html.flush(options?);
35
39
  html.disconnect();
36
40
  ```
37
41
 
38
- `observe()` filters child-list mutation records and schedules discovery only for mutations that can affect runtime declarations, `<solid-render>` retry work, or mounted declarative-render cleanup. `flush()` bypasses that filter and forces a scan.
42
+ `observe()` is continuous by default. `mode: "bootstrap"` temporarily observes startup DOM activity and resolves after DOM readiness plus `idleMs` with no relevant runtime mutations, at which point the observer is disconnected. `mode: "continuous"` retains long-lived observation.
43
+
44
+ `observe()` processes relevant child-list mutation records incrementally. Added declarations are discovered from the added node/subtree itself, removals consult tracked mounted ownership, and explicit API-owned elements are skipped. A full-root scan is reserved for initial registration, `flush()`, root-change registration, or conservative fallback. `flush()` always forces a full scan.
39
45
 
40
46
  ## Explicit ownership
41
47
 
@@ -104,9 +104,9 @@ Map every used package subpath explicitly:
104
104
  ```json
105
105
  {
106
106
  "imports": {
107
- "solid-tag-runtime": "https://esm.sh/solid-tag-runtime@0.0.15",
108
- "solid-tag-runtime/html": "https://esm.sh/solid-tag-runtime@0.0.15/html",
109
- "solid-tag-runtime/solid": "https://esm.sh/solid-tag-runtime@0.0.15/solid"
107
+ "solid-tag-runtime": "https://esm.sh/solid-tag-runtime@0.0.17",
108
+ "solid-tag-runtime/html": "https://esm.sh/solid-tag-runtime@0.0.17/html",
109
+ "solid-tag-runtime/solid": "https://esm.sh/solid-tag-runtime@0.0.17/solid"
110
110
  }
111
111
  }
112
112
  ```
@@ -20,18 +20,48 @@ const html = createHTMLRuntime(runtime, {
20
20
  await html.register();
21
21
  ```
22
22
 
23
- ## Observe later declarations
23
+ ## Choose an observation strategy
24
+
25
+ For controlled applications, prefer one initial scan plus explicit runtime APIs:
24
26
 
25
27
  ```ts
26
- await html.observe({ registerExisting: false });
28
+ await html.register();
29
+
30
+ await html.addModule(...);
31
+ await html.append(script);
32
+ ```
27
33
 
28
- // after external DOM mutations
34
+ If raw DOM insertion is known and occasional, keep observation disabled and synchronize explicitly:
35
+
36
+ ```ts
37
+ root.insertAdjacentHTML("beforeend", markup);
29
38
  await html.flush();
30
39
  ```
31
40
 
32
- The live observer is declaration-driven rather than mutation-driven. It inspects each `MutationRecord` before scheduling discovery and ignores ordinary UI child-list changes that cannot add a matching runtime declaration or require mounted-render cleanup. This means a document-scoped controller can coexist with application/devtools DOM updates without repeatedly rescanning the document.
41
+ For mostly declarative startup, use a temporary bootstrap observer:
42
+
43
+ ```ts
44
+ await html.observe({
45
+ mode: "bootstrap",
46
+ idleMs: 50,
47
+ });
48
+ ```
49
+
50
+ Bootstrap mode observes through DOM readiness, waits until there have been no **relevant runtime mutations** for `idleMs`, drains queued registration work, and disconnects. Ordinary UI mutations do not reset the quiet period.
51
+
52
+ For pages where declarations can arrive through uncontrolled DOM code throughout the application lifetime, use continuous observation:
53
+
54
+ ```ts
55
+ await html.observe({ mode: "continuous" });
56
+ ```
57
+
58
+ `observe()` with no `mode` remains equivalent to continuous mode for backward compatibility.
59
+
60
+ The live observer is declaration-driven and incremental. It inspects each `MutationRecord`, ignores ordinary UI child-list changes, and derives exact runtime declaration/removal candidates from relevant added or removed subtrees. Normal relevant mutations do not rescan the configured root.
61
+
62
+ Added subtrees are queried only within the subtree that changed, duplicate candidates are coalesced, and elements already claimed through explicit APIs are skipped before observer work is queued. This means observer cost scales primarily with changed runtime-relevant DOM rather than total page size.
33
63
 
34
- `flush()` is intentionally different: it remains an explicit forced scan and can be used as a deterministic synchronization point even when no relevant observer mutation was seen.
64
+ `flush()` is intentionally different: it remains an explicit forced full-root scan and can be used as a deterministic synchronization point after known raw DOM insertion.
35
65
 
36
66
  ## Script declarations
37
67
 
@@ -74,6 +74,11 @@ html.subscribe("element-registered", event => {
74
74
 
75
75
  HTML events cover ownership, observation, roots, entry execution, render mounting/disposal, warnings, and errors.
76
76
 
77
- `observer-batch` is emitted only when observer work is actually scheduled. Unrelated child-list mutations are filtered before discovery, so a lifecycle subscriber may render diagnostics/status UI inside an observed document without causing a self-sustaining observer/event loop. Explicit `html.flush()` still performs and reports a forced scan.
77
+ `observer-batch` is emitted only when semantic observer work is scheduled. Unrelated child-list mutations are filtered before discovery, and relevant additions are processed from their exact changed subtree instead of rescanning the whole root. A lifecycle subscriber may therefore render diagnostics/status UI inside an observed document without causing a feedback loop. Explicit `html.flush()` still performs and reports a forced full-root scan.
78
78
 
79
79
  Listener exceptions are isolated from runtime/HTML operations and are reported to the console rather than aborting the underlying action.
80
+
81
+
82
+ ## Bootstrap observation lifecycle
83
+
84
+ `observe({ mode: "bootstrap" })` emits the normal `observer-connected` event and later an `observer-disconnected` event with reason `bootstrap-idle` when its relevant-mutation quiet period completes. Unrelated UI DOM mutations neither emit `observer-batch` nor extend bootstrap lifetime.
@@ -126,7 +126,7 @@ When provider fallback is needed and there is no versioned Solid anchor, this re
126
126
  import { TESTED_SOLID_VERSION } from "solid-tag-runtime/solid";
127
127
  ```
128
128
 
129
- For `0.0.14` and `0.0.15`, the tested fallback line is Solid `2.0.0-rc.13`.
129
+ For `0.0.14` through `0.0.17`, the tested fallback line is Solid `2.0.0-rc.13`.
130
130
 
131
131
  If an existing Solid mapping is explicitly versioned, that version becomes the family anchor for generated siblings. An explicitly unversioned existing mapping remains unversioned; the loader does not pretend it is pinned.
132
132
 
@@ -0,0 +1,19 @@
1
+ import { createHTMLRuntime } from "solid-tag-runtime/html";
2
+
3
+ export async function manual(runtime, root) {
4
+ const html = createHTMLRuntime(runtime, { root });
5
+ await html.register();
6
+ return html;
7
+ }
8
+
9
+ export async function bootstrap(runtime, root) {
10
+ const html = createHTMLRuntime(runtime, { root });
11
+ await html.observe({ mode: "bootstrap", idleMs: 50 });
12
+ return html; // disconnected after startup settles
13
+ }
14
+
15
+ export async function continuous(runtime, root) {
16
+ const html = createHTMLRuntime(runtime, { root });
17
+ await html.observe({ mode: "continuous" });
18
+ return html;
19
+ }
package/html.d.ts CHANGED
@@ -223,7 +223,13 @@ export type HTMLRuntimeEventType = HTMLRuntimeEvent["type"];
223
223
  export type HTMLRuntimeEventOfType<T extends HTMLRuntimeEventType> =
224
224
  Extract<HTMLRuntimeEvent, { type: T }>;
225
225
 
226
+ export type HTMLObservationMode = "continuous" | "bootstrap";
227
+
226
228
  export interface ObserveHTMLOptions extends RegisterHTMLOptions {
229
+ /** Observation lifecycle. Default: "continuous". */
230
+ mode?: HTMLObservationMode;
231
+ /** Bootstrap-mode quiet period after DOM readiness and the last relevant mutation. Default: 50 ms. */
232
+ idleMs?: number;
227
233
  /** Register scripts already present when observation starts. Default: true. */
228
234
  registerExisting?: boolean;
229
235
  /** Observe descendant additions as well as direct children. Default: true. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-tag-runtime",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "Runtime module system for JSX modules compiled with solid-tag and executed through @solidjs/html",
5
5
  "type": "module",
6
6
  "exports": {
@@ -41,7 +41,8 @@
41
41
  "README.md",
42
42
  "ARCHITECTURE.md",
43
43
  "docs",
44
- "examples"
44
+ "examples",
45
+ "bench"
45
46
  ],
46
47
  "sideEffects": false,
47
48
  "dependencies": {
@@ -49,7 +50,8 @@
49
50
  },
50
51
  "scripts": {
51
52
  "test": "node ./test/run.mjs",
52
- "pack:check": "npm pack --dry-run"
53
+ "pack:check": "npm pack --dry-run",
54
+ "bench:observer": "node ./bench/observer.mjs"
53
55
  },
54
56
  "keywords": [
55
57
  "solid",
package/src/html.js CHANGED
@@ -83,6 +83,10 @@ export function createHTMLRuntime(runtime, options = {}) {
83
83
  queue: Promise.resolve(),
84
84
  lastError: undefined,
85
85
  connected: false,
86
+ defaultObservationMode: normalizeObservationMode(options.mode),
87
+ defaultBootstrapIdleMs: options.idleMs == null ? 50 : normalizeBootstrapIdleMs(options.idleMs),
88
+ observationMode: "continuous",
89
+ bootstrapState: undefined,
86
90
  registrationDepth: 0,
87
91
  registrationPasses: 0,
88
92
  solidRenderRetryScheduled: false,
@@ -202,8 +206,19 @@ export function createHTMLRuntime(runtime, options = {}) {
202
206
  async function observe(callOptions = {}) {
203
207
  if (controller.connected) return api;
204
208
 
209
+ const observationMode = normalizeObservationMode(callOptions.mode ?? controller.defaultObservationMode);
205
210
  const root = getRoot(mergeOptions(controller, callOptions), "HTML runtime observe");
206
- controller.observerOptions = { ...callOptions };
211
+ controller.observationMode = observationMode;
212
+ controller.observerOptions = { ...callOptions, mode: observationMode };
213
+ controller.bootstrapState = observationMode === "bootstrap"
214
+ ? {
215
+ active: true,
216
+ activityVersion: 0,
217
+ idleMs: callOptions.idleMs == null
218
+ ? controller.defaultBootstrapIdleMs
219
+ : normalizeBootstrapIdleMs(callOptions.idleMs),
220
+ }
221
+ : undefined;
207
222
  const pendingBeforeUpgrade = collectConnectedSolidRenderElements(controller, root);
208
223
  controller.registrationDepth += 1;
209
224
 
@@ -225,6 +240,10 @@ export function createHTMLRuntime(runtime, options = {}) {
225
240
  controller.registrationDepth = Math.max(0, controller.registrationDepth - 1);
226
241
  }
227
242
 
243
+ if (observationMode === "bootstrap") {
244
+ await finishBootstrapObservation(controller, root);
245
+ }
246
+
228
247
  return api;
229
248
  }
230
249
 
@@ -279,7 +298,10 @@ export function createHTMLRuntime(runtime, options = {}) {
279
298
  const wasConnected = controller.connected;
280
299
  const previousObserverOptions = controller.observerOptions ?? {};
281
300
 
282
- if (wasConnected) stopObserver(controller, { preserveOptions: true, reason: "root-change" });
301
+ if (wasConnected) {
302
+ markBootstrapObserverActivity(controller);
303
+ stopObserver(controller, { preserveOptions: true, preserveBootstrap: true, reason: "root-change" });
304
+ }
283
305
 
284
306
  controller.root = nextRoot;
285
307
  if (Object.prototype.hasOwnProperty.call(callOptions, "appendTo")) {
@@ -1196,6 +1218,80 @@ async function disposeController(controller, options = {}) {
1196
1218
  controller.events.clear();
1197
1219
  }
1198
1220
 
1221
+ function normalizeObservationMode(mode) {
1222
+ if (mode == null || mode === "continuous") return "continuous";
1223
+ if (mode === "bootstrap") return "bootstrap";
1224
+ throw new TypeError(`Unknown HTML observation mode ${JSON.stringify(mode)}. Expected "continuous" or "bootstrap".`);
1225
+ }
1226
+
1227
+ function normalizeBootstrapIdleMs(value) {
1228
+ if (value == null) return 50;
1229
+ const idleMs = Number(value);
1230
+ if (!Number.isFinite(idleMs) || idleMs < 0) {
1231
+ throw new TypeError("observe({ idleMs }) must be a finite non-negative number.");
1232
+ }
1233
+ return idleMs;
1234
+ }
1235
+
1236
+ function markBootstrapObserverActivity(controller) {
1237
+ const state = controller.bootstrapState;
1238
+ if (state?.active) state.activityVersion += 1;
1239
+ }
1240
+
1241
+ async function finishBootstrapObservation(controller, root) {
1242
+ const state = controller.bootstrapState;
1243
+ if (!state?.active) return;
1244
+
1245
+ await waitForBootstrapDOMReady(root);
1246
+
1247
+ while (state.active && !controller.disposed) {
1248
+ const version = state.activityVersion;
1249
+ await waitForBootstrapIdle(state.idleMs);
1250
+ await controller.queue;
1251
+
1252
+ if (controller.lastError) {
1253
+ const error = controller.lastError;
1254
+ controller.lastError = undefined;
1255
+ stopObserver(controller, { reason: "bootstrap-error" });
1256
+ throw error;
1257
+ }
1258
+
1259
+ if (!state.active || controller.disposed) return;
1260
+ if (state.activityVersion !== version) continue;
1261
+
1262
+ // setRoot() may briefly disconnect/reconnect while preserving bootstrap
1263
+ // state. Wait another turn rather than treating that transition as settled.
1264
+ if (!controller.connected) {
1265
+ await waitForBootstrapIdle(0);
1266
+ continue;
1267
+ }
1268
+
1269
+ stopObserver(controller, { reason: "bootstrap-idle" });
1270
+ return;
1271
+ }
1272
+ }
1273
+
1274
+ function waitForBootstrapIdle(idleMs) {
1275
+ return new Promise(resolve => {
1276
+ const schedule = globalThis.setTimeout ?? ((callback) => Promise.resolve().then(callback));
1277
+ schedule(resolve, idleMs);
1278
+ });
1279
+ }
1280
+
1281
+ function waitForBootstrapDOMReady(root) {
1282
+ const document = root?.nodeType === 9
1283
+ ? root
1284
+ : root?.ownerDocument ?? (root === globalThis.document ? root : undefined);
1285
+
1286
+ if (!document || document.readyState !== "loading" || typeof document.addEventListener !== "function") {
1287
+ return Promise.resolve();
1288
+ }
1289
+
1290
+ return new Promise(resolve => {
1291
+ document.addEventListener("DOMContentLoaded", resolve, { once: true });
1292
+ });
1293
+ }
1294
+
1199
1295
  async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1200
1296
  const MutationObserverImpl = callOptions.MutationObserver ?? controller.MutationObserver ?? globalThis.MutationObserver;
1201
1297
 
@@ -1220,8 +1316,16 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1220
1316
  }
1221
1317
 
1222
1318
  const observer = new MutationObserverImpl(records => {
1223
- if (!observerMutationsRequireScan(controller, records, callOptions)) return;
1224
- void enqueueObserverScan(controller, callOptions).catch(() => {});
1319
+ const batch = collectObserverMutationBatch(controller, records, callOptions);
1320
+ if (!batch) return;
1321
+ markBootstrapObserverActivity(controller);
1322
+
1323
+ if (batch.requiresFallbackScan) {
1324
+ void enqueueObserverScan(controller, callOptions).catch(() => {});
1325
+ return;
1326
+ }
1327
+
1328
+ void enqueueObserverBatch(controller, batch, callOptions).catch(() => {});
1225
1329
  });
1226
1330
 
1227
1331
  controller.observer = observer;
@@ -1248,61 +1352,134 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1248
1352
  return result;
1249
1353
  }
1250
1354
 
1251
- function observerMutationsRequireScan(controller, records, callOptions = {}) {
1355
+ function collectObserverMutationBatch(controller, records, callOptions = {}) {
1252
1356
  const mutations = Array.from(records ?? []);
1253
1357
 
1254
1358
  // MutationObserver never invokes its callback with an empty batch, but custom
1255
1359
  // test/host implementations sometimes do. Preserve the historical conservative
1256
- // behavior for those implementations instead of silently dropping discovery.
1257
- if (mutations.length === 0) return true;
1360
+ // behavior for those implementations by falling back to a full scan.
1361
+ if (mutations.length === 0) {
1362
+ return {
1363
+ declarations: [],
1364
+ solidRenders: [],
1365
+ removedRecords: [],
1366
+ requiresFallbackScan: true,
1367
+ };
1368
+ }
1258
1369
 
1259
1370
  const selector = callOptions.selector ?? controller.selector;
1371
+ const declarations = [];
1372
+ const declarationSet = new Set();
1373
+ const solidRenders = [];
1374
+ const solidRenderSet = new Set();
1375
+ const removedRecords = new Set();
1260
1376
 
1261
1377
  for (const mutation of mutations) {
1262
- if (!mutation || mutation.type !== "childList") return true;
1378
+ if (!mutation || mutation.type !== "childList") {
1379
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1380
+ }
1263
1381
 
1264
1382
  const addedNodes = mutation.addedNodes;
1265
1383
  const removedNodes = mutation.removedNodes;
1266
- if (addedNodes == null || removedNodes == null) return true;
1384
+ if (addedNodes == null || removedNodes == null) {
1385
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1386
+ }
1267
1387
 
1268
1388
  for (const node of Array.from(addedNodes)) {
1269
- if (nodeOrDescendantMatches(node, selector)) return true;
1389
+ const declarationMatches = collectNodeMatches(node, selector);
1390
+ if (declarationMatches.uncertain) {
1391
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1392
+ }
1393
+ for (const element of declarationMatches.elements) {
1394
+ // Explicit APIs claim before inserting into the DOM. If this controller
1395
+ // already owns the element, the observer has no discovery work to do.
1396
+ if (elementOwners.get(element)?.owner === controller) continue;
1397
+ if (controller.ignoredExisting.has(element)) continue;
1398
+ if (!declarationSet.has(element)) {
1399
+ declarationSet.add(element);
1400
+ declarations.push(element);
1401
+ }
1402
+ }
1270
1403
 
1271
- // <solid-render> is not part of the script selector, but observer batch
1272
- // completion is also a retry boundary for newly connected render instances.
1273
- if (nodeOrDescendantMatches(node, "solid-render")) return true;
1404
+ const renderMatches = collectNodeMatches(node, "solid-render");
1405
+ if (renderMatches.uncertain) {
1406
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1407
+ }
1408
+ for (const element of renderMatches.elements) {
1409
+ if (!solidRenderSet.has(element)) {
1410
+ solidRenderSet.add(element);
1411
+ solidRenders.push(element);
1412
+ }
1413
+ }
1274
1414
  }
1275
1415
 
1276
1416
  for (const node of Array.from(removedNodes)) {
1277
- if (removedNodeContainsMountedDeclaration(controller, node)) return true;
1417
+ const result = collectRemovedMountedRecords(controller, node, removedRecords);
1418
+ if (!result) {
1419
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1420
+ }
1278
1421
  }
1279
1422
  }
1280
1423
 
1281
- return false;
1424
+ if (declarations.length === 0 && solidRenders.length === 0 && removedRecords.size === 0) {
1425
+ return undefined;
1426
+ }
1427
+
1428
+ return {
1429
+ declarations,
1430
+ solidRenders,
1431
+ removedRecords: [...removedRecords],
1432
+ requiresFallbackScan: false,
1433
+ };
1282
1434
  }
1283
1435
 
1284
- function nodeOrDescendantMatches(node, selector) {
1285
- if (!node) return false;
1436
+ function collectNodeMatches(node, selector) {
1437
+ const elements = [];
1438
+ const seen = new Set();
1439
+ if (!node) return { elements, uncertain: false };
1286
1440
 
1441
+ let inspected = false;
1287
1442
  try {
1288
- if (typeof node.matches === "function" && node.matches(selector)) return true;
1289
- if (typeof node.querySelector === "function" && node.querySelector(selector)) return true;
1290
- if (typeof node.querySelectorAll === "function" && node.querySelectorAll(selector)?.length > 0) return true;
1443
+ if (typeof node.matches === "function") {
1444
+ inspected = true;
1445
+ if (node.matches(selector)) {
1446
+ seen.add(node);
1447
+ elements.push(node);
1448
+ }
1449
+ }
1450
+
1451
+ if (typeof node.querySelectorAll === "function") {
1452
+ inspected = true;
1453
+ for (const element of Array.from(node.querySelectorAll(selector) ?? [])) {
1454
+ if (seen.has(element)) continue;
1455
+ seen.add(element);
1456
+ elements.push(element);
1457
+ }
1458
+ }
1291
1459
  } catch {
1292
- // A custom selector/DOM implementation that cannot be classified safely
1293
- // should keep the old conservative behavior rather than miss discovery.
1294
- return true;
1460
+ return { elements, uncertain: true };
1295
1461
  }
1296
1462
 
1297
- // Standard DOM nodes that did not match above cannot affect discovery.
1298
- if (typeof node.nodeType === "number") return false;
1463
+ if (inspected) return { elements, uncertain: false };
1464
+
1465
+ // Text/comment nodes cannot contain declarations. Element/document/fragment
1466
+ // nodes in a standards DOM should expose matches/querySelectorAll as
1467
+ // appropriate; a host object that does not is conservatively scanned.
1468
+ if (typeof node.nodeType === "number") {
1469
+ if (node.nodeType === 3 || node.nodeType === 4 || node.nodeType === 8) {
1470
+ return { elements, uncertain: false };
1471
+ }
1472
+ return { elements, uncertain: true };
1473
+ }
1299
1474
 
1300
- // Unknown host DOM objects cannot be classified safely.
1301
- return typeof node.getAttribute === "function";
1475
+ return {
1476
+ elements,
1477
+ uncertain: typeof node.getAttribute === "function" || typeof node.contains === "function",
1478
+ };
1302
1479
  }
1303
1480
 
1304
- function removedNodeContainsMountedDeclaration(controller, node) {
1305
- if (!node) return false;
1481
+ function collectRemovedMountedRecords(controller, node, records) {
1482
+ if (!node) return true;
1306
1483
 
1307
1484
  for (const record of controller.bindings.values()) {
1308
1485
  if (!record.mount) continue;
@@ -1310,16 +1487,22 @@ function removedNodeContainsMountedDeclaration(controller, node) {
1310
1487
  ? record.range?.startMarker
1311
1488
  : record.element;
1312
1489
  if (!anchor) continue;
1313
- if (node === anchor) return true;
1490
+ if (node === anchor) {
1491
+ records.add(record);
1492
+ continue;
1493
+ }
1314
1494
 
1315
1495
  try {
1316
- if (typeof node.contains === "function" && node.contains(anchor)) return true;
1496
+ if (typeof node.contains === "function" && node.contains(anchor)) {
1497
+ records.add(record);
1498
+ }
1317
1499
  } catch {
1318
- return true;
1500
+ return false;
1319
1501
  }
1320
1502
  }
1321
1503
 
1322
- return false;
1504
+ if (typeof node.nodeType === "number" || typeof node.contains === "function") return true;
1505
+ return typeof node.getAttribute !== "function";
1323
1506
  }
1324
1507
 
1325
1508
  function stopObserver(controller, options = {}) {
@@ -1330,6 +1513,10 @@ function stopObserver(controller, options = {}) {
1330
1513
  controller.observer = undefined;
1331
1514
  controller.observerRoot = undefined;
1332
1515
  if (!options.preserveOptions) controller.observerOptions = undefined;
1516
+ if (!options.preserveBootstrap && controller.bootstrapState) {
1517
+ controller.bootstrapState.active = false;
1518
+ controller.bootstrapState = undefined;
1519
+ }
1333
1520
  emitHTML(controller, {
1334
1521
  type: "observer-disconnected",
1335
1522
  root,
@@ -1345,54 +1532,56 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1345
1532
  const selector = callOptions.selector ?? controller.selector;
1346
1533
  const origin = callOptions.origin ?? "observer";
1347
1534
  const discovered = Array.from(root.querySelectorAll(selector));
1348
- const elements = [];
1349
- let matched = 0;
1350
- let ignored = 0;
1351
-
1352
- for (const element of discovered) {
1353
- if (controller.ignoredExisting.has(element)) {
1354
- ignored += 1;
1355
- emitHTML(controller, {
1356
- type: "element-discovered",
1357
- element,
1358
- moduleId: nonEmpty(element?.getAttribute?.("module")),
1359
- origin,
1360
- accepted: false,
1361
- });
1362
- continue;
1363
- }
1535
+ const classified = classifyObserverElements(controller, discovered, callOptions, origin);
1364
1536
 
1365
- const accepted = acceptsDiscoveredElement(controller, element, callOptions);
1366
- emitHTML(controller, {
1367
- type: "element-discovered",
1368
- element,
1369
- moduleId: nonEmpty(element?.getAttribute?.("module")),
1537
+ let result;
1538
+ try {
1539
+ result = await processElements(controller, classified.elements, {
1540
+ ...callOptions,
1541
+ root,
1370
1542
  origin,
1371
- accepted,
1543
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
1544
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
1372
1545
  });
1373
- if (!accepted) {
1374
- ignored += 1;
1375
- maybeWarnUnresolvedRenderScope(controller, element, callOptions, origin);
1376
- continue;
1377
- }
1378
- matched += 1;
1546
+ } catch (error) {
1547
+ emitHTMLError(controller, "observer", error, { origin });
1548
+ throw error;
1549
+ }
1379
1550
 
1380
- const ownership = elementOwners.get(element);
1381
- if (!ownership) {
1382
- elements.push(element);
1383
- continue;
1384
- }
1385
- if (ownership.owner === controller) {
1386
- ignored += 1;
1387
- continue;
1388
- }
1551
+ await cleanupDetachedRenderedDeclarations(controller, root);
1552
+ await retrySolidRenderElements(
1553
+ controller,
1554
+ collectConnectedSolidRenderElements(controller, root),
1555
+ { reason: "observer-batch-complete", forceMissingError: true },
1556
+ );
1389
1557
 
1390
- throw ownershipError(controller, element, ownership);
1391
- }
1558
+ emitObserverBatch(controller, {
1559
+ root,
1560
+ origin,
1561
+ discovered: discovered.length,
1562
+ matched: classified.matched,
1563
+ ignored: classified.ignored,
1564
+ result,
1565
+ });
1566
+ return result;
1567
+ });
1568
+
1569
+ trackObserverTask(controller, task, callOptions);
1570
+ return task;
1571
+ }
1572
+
1573
+ async function enqueueObserverBatch(controller, batch, callOptions = {}) {
1574
+ const task = controller.queue.then(async () => {
1575
+ if (!controller.connected) return emptyRegistrationResult();
1576
+
1577
+ const root = controller.observerRoot ?? getRoot(mergeOptions(controller, callOptions), "HTML runtime observer");
1578
+ const origin = callOptions.origin ?? "observer";
1579
+ const discovered = batch.declarations;
1580
+ const classified = classifyObserverElements(controller, discovered, callOptions, origin);
1392
1581
 
1393
1582
  let result;
1394
1583
  try {
1395
- result = await processElements(controller, elements, {
1584
+ result = await processElements(controller, classified.elements, {
1396
1585
  ...callOptions,
1397
1586
  root,
1398
1587
  origin,
@@ -1404,26 +1593,106 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1404
1593
  throw error;
1405
1594
  }
1406
1595
 
1407
- await cleanupDetachedRenderedDeclarations(controller, root);
1596
+ await cleanupRemovedRenderedDeclarations(controller, root, batch.removedRecords);
1408
1597
  await retrySolidRenderElements(
1409
1598
  controller,
1410
- collectConnectedSolidRenderElements(controller, root),
1599
+ batch.solidRenders,
1411
1600
  { reason: "observer-batch-complete", forceMissingError: true },
1412
1601
  );
1413
1602
 
1414
- emitHTML(controller, {
1415
- type: "observer-batch",
1603
+ emitObserverBatch(controller, {
1416
1604
  root,
1417
1605
  origin,
1418
1606
  discovered: discovered.length,
1419
- matched,
1420
- claimed: result.modules.length,
1421
- ignored,
1422
- modules: result.modules.map(module => module.id),
1607
+ matched: classified.matched,
1608
+ ignored: classified.ignored,
1609
+ result,
1423
1610
  });
1424
1611
  return result;
1425
1612
  });
1426
1613
 
1614
+ trackObserverTask(controller, task, callOptions);
1615
+ return task;
1616
+ }
1617
+
1618
+ function classifyObserverElements(controller, discovered, callOptions, origin) {
1619
+ const elements = [];
1620
+ let matched = 0;
1621
+ let ignored = 0;
1622
+
1623
+ for (const element of discovered) {
1624
+ if (controller.ignoredExisting.has(element)) {
1625
+ ignored += 1;
1626
+ emitHTML(controller, {
1627
+ type: "element-discovered",
1628
+ element,
1629
+ moduleId: nonEmpty(element?.getAttribute?.("module")),
1630
+ origin,
1631
+ accepted: false,
1632
+ });
1633
+ continue;
1634
+ }
1635
+
1636
+ const accepted = acceptsDiscoveredElement(controller, element, callOptions);
1637
+ emitHTML(controller, {
1638
+ type: "element-discovered",
1639
+ element,
1640
+ moduleId: nonEmpty(element?.getAttribute?.("module")),
1641
+ origin,
1642
+ accepted,
1643
+ });
1644
+ if (!accepted) {
1645
+ ignored += 1;
1646
+ maybeWarnUnresolvedRenderScope(controller, element, callOptions, origin);
1647
+ continue;
1648
+ }
1649
+ matched += 1;
1650
+
1651
+ const ownership = elementOwners.get(element);
1652
+ if (!ownership) {
1653
+ elements.push(element);
1654
+ continue;
1655
+ }
1656
+ if (ownership.owner === controller) {
1657
+ ignored += 1;
1658
+ continue;
1659
+ }
1660
+
1661
+ throw ownershipError(controller, element, ownership);
1662
+ }
1663
+
1664
+ return { elements, matched, ignored };
1665
+ }
1666
+
1667
+ async function cleanupRemovedRenderedDeclarations(controller, root, records) {
1668
+ for (const record of records ?? []) {
1669
+ if (!record?.mount) continue;
1670
+ const anchor = record.mount.mode === "in-place"
1671
+ ? record.range?.startMarker
1672
+ : record.element;
1673
+ if (!anchor || rootContainsElement(root, anchor)) continue;
1674
+
1675
+ await disposeRecordMount(controller, record, {
1676
+ reason: "declaration-detached",
1677
+ removeAnchors: false,
1678
+ });
1679
+ }
1680
+ }
1681
+
1682
+ function emitObserverBatch(controller, { root, origin, discovered, matched, ignored, result }) {
1683
+ emitHTML(controller, {
1684
+ type: "observer-batch",
1685
+ root,
1686
+ origin,
1687
+ discovered,
1688
+ matched,
1689
+ claimed: result.modules.length,
1690
+ ignored,
1691
+ modules: result.modules.map(module => module.id),
1692
+ });
1693
+ }
1694
+
1695
+ function trackObserverTask(controller, task, callOptions) {
1427
1696
  controller.queue = task.then(
1428
1697
  () => undefined,
1429
1698
  error => {
@@ -1435,8 +1704,6 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1435
1704
  }
1436
1705
  },
1437
1706
  );
1438
-
1439
- return task;
1440
1707
  }
1441
1708
 
1442
1709
  async function processElements(controller, elements, options = {}) {