solid-tag-runtime 0.0.14 → 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
@@ -1458,3 +1458,94 @@ Decision: fix wrapperless Solid 2 delegated events with the smallest ownership-p
1458
1458
 
1459
1459
  The delegation host is infrastructure only. It is not a shared application owner, is not a parent of declarative roots, is not disposed with an individual range, and does not change selector rendering or `<solid-render>` container ownership. Low-level `createRuntime()` behavior is unchanged.
1460
1460
 
1461
+ ### 0.0.15 — declaration-driven observer scheduling
1462
+
1463
+ Decision: harden `solid-tag-runtime/html` observation by filtering `MutationObserver` child-list records before `enqueueObserverScan()` is scheduled. The observer no longer rescans its root for arbitrary UI DOM churn.
1464
+
1465
+ A mutation requires observer work when at least one of these is true:
1466
+
1467
+ 1. an added node is, or contains, an element matching the controller's runtime declaration selector;
1468
+ 2. an added node is, or contains, `<solid-render>`, preserving observer-batch retry behavior for newly connected renderer instances;
1469
+ 3. a removed subtree contains the active anchor for a mounted declarative render, so detached render ownership can still be disposed;
1470
+ 4. the host MutationObserver/DOM implementation cannot be classified safely, in which case the runtime conservatively preserves the previous scan behavior.
1471
+
1472
+ Explicit `html.flush()` remains a forced scan and does not apply mutation filtering. Empty mutation batches from custom/test `MutationObserver` implementations also retain conservative scan behavior for compatibility.
1473
+
1474
+ Reason: HTML lifecycle subscribers, devtools, status panels, and normal application rendering may mutate DOM inside a document-scoped observer. Previously every child-list mutation queued a full discovery scan, `observer-batch` could cause subscriber UI updates, and those updates could schedule another scan indefinitely. Observer scheduling must be driven by possible runtime-declaration effects rather than arbitrary DOM activity.
1475
+
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
+
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,37 @@ 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
+
44
+ ## 0.0.15 observer hardening
45
+
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.
47
+
48
+ This prevents lifecycle/devtools subscribers that render diagnostics into an observed document from creating observer → event → DOM mutation feedback loops.
49
+
19
50
  ## Install
20
51
 
21
52
  ```bash
@@ -211,9 +242,9 @@ When loading from an import map, map every used package subpath to the same rele
211
242
  ```json
212
243
  {
213
244
  "imports": {
214
- "solid-tag-runtime": "https://esm.sh/solid-tag-runtime@0.0.14",
215
- "solid-tag-runtime/html": "https://esm.sh/solid-tag-runtime@0.0.14/html",
216
- "solid-tag-runtime/solid": "https://esm.sh/solid-tag-runtime@0.0.14/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"
217
248
  }
218
249
  }
219
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,11 +30,19 @@ 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
 
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.
45
+
38
46
  ## Explicit ownership
39
47
 
40
48
  ```ts
@@ -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.14",
108
- "solid-tag-runtime/html": "https://esm.sh/solid-tag-runtime@0.0.14/html",
109
- "solid-tag-runtime/solid": "https://esm.sh/solid-tag-runtime@0.0.14/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,15 +20,49 @@ 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
+ ```
33
+
34
+ If raw DOM insertion is known and occasional, keep observation disabled and synchronize explicitly:
27
35
 
28
- // after external DOM mutations
36
+ ```ts
37
+ root.insertAdjacentHTML("beforeend", markup);
29
38
  await html.flush();
30
39
  ```
31
40
 
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.
63
+
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.
65
+
32
66
  ## Script declarations
33
67
 
34
68
  ```html
@@ -74,4 +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 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
+
77
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`, 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.14",
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
 
@@ -1219,8 +1315,17 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1219
1315
  }
1220
1316
  }
1221
1317
 
1222
- const observer = new MutationObserverImpl(() => {
1223
- void enqueueObserverScan(controller, callOptions).catch(() => {});
1318
+ const observer = new MutationObserverImpl(records => {
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(() => {});
1224
1329
  });
1225
1330
 
1226
1331
  controller.observer = observer;
@@ -1247,6 +1352,159 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1247
1352
  return result;
1248
1353
  }
1249
1354
 
1355
+ function collectObserverMutationBatch(controller, records, callOptions = {}) {
1356
+ const mutations = Array.from(records ?? []);
1357
+
1358
+ // MutationObserver never invokes its callback with an empty batch, but custom
1359
+ // test/host implementations sometimes do. Preserve the historical conservative
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
+ }
1369
+
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();
1376
+
1377
+ for (const mutation of mutations) {
1378
+ if (!mutation || mutation.type !== "childList") {
1379
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1380
+ }
1381
+
1382
+ const addedNodes = mutation.addedNodes;
1383
+ const removedNodes = mutation.removedNodes;
1384
+ if (addedNodes == null || removedNodes == null) {
1385
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1386
+ }
1387
+
1388
+ for (const node of Array.from(addedNodes)) {
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
+ }
1403
+
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
+ }
1414
+ }
1415
+
1416
+ for (const node of Array.from(removedNodes)) {
1417
+ const result = collectRemovedMountedRecords(controller, node, removedRecords);
1418
+ if (!result) {
1419
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1420
+ }
1421
+ }
1422
+ }
1423
+
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
+ };
1434
+ }
1435
+
1436
+ function collectNodeMatches(node, selector) {
1437
+ const elements = [];
1438
+ const seen = new Set();
1439
+ if (!node) return { elements, uncertain: false };
1440
+
1441
+ let inspected = false;
1442
+ try {
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
+ }
1459
+ } catch {
1460
+ return { elements, uncertain: true };
1461
+ }
1462
+
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
+ }
1474
+
1475
+ return {
1476
+ elements,
1477
+ uncertain: typeof node.getAttribute === "function" || typeof node.contains === "function",
1478
+ };
1479
+ }
1480
+
1481
+ function collectRemovedMountedRecords(controller, node, records) {
1482
+ if (!node) return true;
1483
+
1484
+ for (const record of controller.bindings.values()) {
1485
+ if (!record.mount) continue;
1486
+ const anchor = record.mount.mode === "in-place"
1487
+ ? record.range?.startMarker
1488
+ : record.element;
1489
+ if (!anchor) continue;
1490
+ if (node === anchor) {
1491
+ records.add(record);
1492
+ continue;
1493
+ }
1494
+
1495
+ try {
1496
+ if (typeof node.contains === "function" && node.contains(anchor)) {
1497
+ records.add(record);
1498
+ }
1499
+ } catch {
1500
+ return false;
1501
+ }
1502
+ }
1503
+
1504
+ if (typeof node.nodeType === "number" || typeof node.contains === "function") return true;
1505
+ return typeof node.getAttribute !== "function";
1506
+ }
1507
+
1250
1508
  function stopObserver(controller, options = {}) {
1251
1509
  if (!controller.connected && !controller.observer) return;
1252
1510
  const root = controller.observerRoot;
@@ -1255,6 +1513,10 @@ function stopObserver(controller, options = {}) {
1255
1513
  controller.observer = undefined;
1256
1514
  controller.observerRoot = undefined;
1257
1515
  if (!options.preserveOptions) controller.observerOptions = undefined;
1516
+ if (!options.preserveBootstrap && controller.bootstrapState) {
1517
+ controller.bootstrapState.active = false;
1518
+ controller.bootstrapState = undefined;
1519
+ }
1258
1520
  emitHTML(controller, {
1259
1521
  type: "observer-disconnected",
1260
1522
  root,
@@ -1270,54 +1532,56 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1270
1532
  const selector = callOptions.selector ?? controller.selector;
1271
1533
  const origin = callOptions.origin ?? "observer";
1272
1534
  const discovered = Array.from(root.querySelectorAll(selector));
1273
- const elements = [];
1274
- let matched = 0;
1275
- let ignored = 0;
1276
-
1277
- for (const element of discovered) {
1278
- if (controller.ignoredExisting.has(element)) {
1279
- ignored += 1;
1280
- emitHTML(controller, {
1281
- type: "element-discovered",
1282
- element,
1283
- moduleId: nonEmpty(element?.getAttribute?.("module")),
1284
- origin,
1285
- accepted: false,
1286
- });
1287
- continue;
1288
- }
1535
+ const classified = classifyObserverElements(controller, discovered, callOptions, origin);
1289
1536
 
1290
- const accepted = acceptsDiscoveredElement(controller, element, callOptions);
1291
- emitHTML(controller, {
1292
- type: "element-discovered",
1293
- element,
1294
- moduleId: nonEmpty(element?.getAttribute?.("module")),
1537
+ let result;
1538
+ try {
1539
+ result = await processElements(controller, classified.elements, {
1540
+ ...callOptions,
1541
+ root,
1295
1542
  origin,
1296
- accepted,
1543
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
1544
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
1297
1545
  });
1298
- if (!accepted) {
1299
- ignored += 1;
1300
- maybeWarnUnresolvedRenderScope(controller, element, callOptions, origin);
1301
- continue;
1302
- }
1303
- matched += 1;
1546
+ } catch (error) {
1547
+ emitHTMLError(controller, "observer", error, { origin });
1548
+ throw error;
1549
+ }
1304
1550
 
1305
- const ownership = elementOwners.get(element);
1306
- if (!ownership) {
1307
- elements.push(element);
1308
- continue;
1309
- }
1310
- if (ownership.owner === controller) {
1311
- ignored += 1;
1312
- continue;
1313
- }
1551
+ await cleanupDetachedRenderedDeclarations(controller, root);
1552
+ await retrySolidRenderElements(
1553
+ controller,
1554
+ collectConnectedSolidRenderElements(controller, root),
1555
+ { reason: "observer-batch-complete", forceMissingError: true },
1556
+ );
1314
1557
 
1315
- throw ownershipError(controller, element, ownership);
1316
- }
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);
1317
1581
 
1318
1582
  let result;
1319
1583
  try {
1320
- result = await processElements(controller, elements, {
1584
+ result = await processElements(controller, classified.elements, {
1321
1585
  ...callOptions,
1322
1586
  root,
1323
1587
  origin,
@@ -1329,26 +1593,106 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1329
1593
  throw error;
1330
1594
  }
1331
1595
 
1332
- await cleanupDetachedRenderedDeclarations(controller, root);
1596
+ await cleanupRemovedRenderedDeclarations(controller, root, batch.removedRecords);
1333
1597
  await retrySolidRenderElements(
1334
1598
  controller,
1335
- collectConnectedSolidRenderElements(controller, root),
1599
+ batch.solidRenders,
1336
1600
  { reason: "observer-batch-complete", forceMissingError: true },
1337
1601
  );
1338
1602
 
1339
- emitHTML(controller, {
1340
- type: "observer-batch",
1603
+ emitObserverBatch(controller, {
1341
1604
  root,
1342
1605
  origin,
1343
1606
  discovered: discovered.length,
1344
- matched,
1345
- claimed: result.modules.length,
1346
- ignored,
1347
- modules: result.modules.map(module => module.id),
1607
+ matched: classified.matched,
1608
+ ignored: classified.ignored,
1609
+ result,
1348
1610
  });
1349
1611
  return result;
1350
1612
  });
1351
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) {
1352
1696
  controller.queue = task.then(
1353
1697
  () => undefined,
1354
1698
  error => {
@@ -1360,8 +1704,6 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1360
1704
  }
1361
1705
  },
1362
1706
  );
1363
-
1364
- return task;
1365
1707
  }
1366
1708
 
1367
1709
  async function processElements(controller, elements, options = {}) {