solid-tag-runtime 0.0.14 → 0.0.15

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,21 @@ 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`.
package/README.md CHANGED
@@ -16,6 +16,12 @@ 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.15 observer hardening
20
+
21
+ `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.
22
+
23
+ This prevents lifecycle/devtools subscribers that render diagnostics into an observed document from creating observer → event → DOM mutation feedback loops.
24
+
19
25
  ## Install
20
26
 
21
27
  ```bash
@@ -211,9 +217,9 @@ When loading from an import map, map every used package subpath to the same rele
211
217
  ```json
212
218
  {
213
219
  "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"
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"
217
223
  }
218
224
  }
219
225
  ```
package/docs/api/html.md CHANGED
@@ -35,6 +35,8 @@ await html.flush(options?);
35
35
  html.disconnect();
36
36
  ```
37
37
 
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.
39
+
38
40
  ## Explicit ownership
39
41
 
40
42
  ```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.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"
110
110
  }
111
111
  }
112
112
  ```
@@ -29,6 +29,10 @@ await html.observe({ registerExisting: false });
29
29
  await html.flush();
30
30
  ```
31
31
 
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.
33
+
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.
35
+
32
36
  ## Script declarations
33
37
 
34
38
  ```html
@@ -74,4 +74,6 @@ 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.
78
+
77
79
  Listener exceptions are isolated from runtime/HTML operations and are reported to the console rather than aborting the underlying action.
@@ -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` and `0.0.15`, 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
 
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.15",
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": {
package/src/html.js CHANGED
@@ -1219,7 +1219,8 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1219
1219
  }
1220
1220
  }
1221
1221
 
1222
- const observer = new MutationObserverImpl(() => {
1222
+ const observer = new MutationObserverImpl(records => {
1223
+ if (!observerMutationsRequireScan(controller, records, callOptions)) return;
1223
1224
  void enqueueObserverScan(controller, callOptions).catch(() => {});
1224
1225
  });
1225
1226
 
@@ -1247,6 +1248,80 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1247
1248
  return result;
1248
1249
  }
1249
1250
 
1251
+ function observerMutationsRequireScan(controller, records, callOptions = {}) {
1252
+ const mutations = Array.from(records ?? []);
1253
+
1254
+ // MutationObserver never invokes its callback with an empty batch, but custom
1255
+ // 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;
1258
+
1259
+ const selector = callOptions.selector ?? controller.selector;
1260
+
1261
+ for (const mutation of mutations) {
1262
+ if (!mutation || mutation.type !== "childList") return true;
1263
+
1264
+ const addedNodes = mutation.addedNodes;
1265
+ const removedNodes = mutation.removedNodes;
1266
+ if (addedNodes == null || removedNodes == null) return true;
1267
+
1268
+ for (const node of Array.from(addedNodes)) {
1269
+ if (nodeOrDescendantMatches(node, selector)) return true;
1270
+
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;
1274
+ }
1275
+
1276
+ for (const node of Array.from(removedNodes)) {
1277
+ if (removedNodeContainsMountedDeclaration(controller, node)) return true;
1278
+ }
1279
+ }
1280
+
1281
+ return false;
1282
+ }
1283
+
1284
+ function nodeOrDescendantMatches(node, selector) {
1285
+ if (!node) return false;
1286
+
1287
+ 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;
1291
+ } 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;
1295
+ }
1296
+
1297
+ // Standard DOM nodes that did not match above cannot affect discovery.
1298
+ if (typeof node.nodeType === "number") return false;
1299
+
1300
+ // Unknown host DOM objects cannot be classified safely.
1301
+ return typeof node.getAttribute === "function";
1302
+ }
1303
+
1304
+ function removedNodeContainsMountedDeclaration(controller, node) {
1305
+ if (!node) return false;
1306
+
1307
+ for (const record of controller.bindings.values()) {
1308
+ if (!record.mount) continue;
1309
+ const anchor = record.mount.mode === "in-place"
1310
+ ? record.range?.startMarker
1311
+ : record.element;
1312
+ if (!anchor) continue;
1313
+ if (node === anchor) return true;
1314
+
1315
+ try {
1316
+ if (typeof node.contains === "function" && node.contains(anchor)) return true;
1317
+ } catch {
1318
+ return true;
1319
+ }
1320
+ }
1321
+
1322
+ return false;
1323
+ }
1324
+
1250
1325
  function stopObserver(controller, options = {}) {
1251
1326
  if (!controller.connected && !controller.observer) return;
1252
1327
  const root = controller.observerRoot;