solid-tag-runtime 0.0.15 → 0.0.18

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/src/html.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ModuleResolutionError, SolidTagRuntimeError } from "./errors.js";
2
2
  import { createEventDispatcher } from "./events.js";
3
3
  import { HTMLRenderError, createDefaultHTMLRenderAdapter, resolveRenderComponent } from "./render.js";
4
+ import { parseHTMLDataLiteral, parseHTMLPropsObject } from "./html/data-literal.js";
4
5
 
5
6
  const DEFAULT_SELECTOR = [
6
7
  'script[type="solid-jsx"]',
@@ -12,6 +13,10 @@ const DEFAULT_SELECTOR = [
12
13
  ].join(",");
13
14
 
14
15
  const RUNTIME_SCOPE_ATTRIBUTE = "data-solid-runtime";
16
+ const SOLID_RENDER_STATE_ATTRIBUTE = "data-solid-render-state";
17
+ const SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE = "show-until-ready";
18
+ const SOLID_RENDER_VISIBILITY_STYLE_ATTRIBUTE = "data-solid-tag-render-visibility";
19
+ const solidRenderVisibilityDocuments = new WeakSet();
15
20
  const elementOwners = new WeakMap();
16
21
  const defaultControllers = new WeakMap();
17
22
  const activeControllers = new Set();
@@ -73,6 +78,7 @@ export function createHTMLRuntime(runtime, options = {}) {
73
78
  createElement: options.createElement,
74
79
  appendTo: options.appendTo,
75
80
  renderAdapter: options.renderAdapter ?? createDefaultHTMLRenderAdapter(runtime),
81
+ parseProps: typeof options.parseProps === "function" ? options.parseProps : undefined,
76
82
  bindings: new Map(),
77
83
  renderElements: new Set(),
78
84
  disposed: false,
@@ -83,6 +89,10 @@ export function createHTMLRuntime(runtime, options = {}) {
83
89
  queue: Promise.resolve(),
84
90
  lastError: undefined,
85
91
  connected: false,
92
+ defaultObservationMode: normalizeObservationMode(options.mode),
93
+ defaultBootstrapIdleMs: options.idleMs == null ? 50 : normalizeBootstrapIdleMs(options.idleMs),
94
+ observationMode: "continuous",
95
+ bootstrapState: undefined,
86
96
  registrationDepth: 0,
87
97
  registrationPasses: 0,
88
98
  solidRenderRetryScheduled: false,
@@ -130,6 +140,7 @@ export function createHTMLRuntime(runtime, options = {}) {
130
140
 
131
141
  controller.api = api;
132
142
  activeControllers.add(controller);
143
+ ensureSolidRenderVisibilityStyle(resolveControllerDocument(controller));
133
144
  // Do not define/upgrade <solid-render> here. Existing declarative script
134
145
  // modules must be installed first; otherwise connectedCallback() can import
135
146
  // a module before register()/observe() has defined the preceding <script>.
@@ -202,8 +213,19 @@ export function createHTMLRuntime(runtime, options = {}) {
202
213
  async function observe(callOptions = {}) {
203
214
  if (controller.connected) return api;
204
215
 
216
+ const observationMode = normalizeObservationMode(callOptions.mode ?? controller.defaultObservationMode);
205
217
  const root = getRoot(mergeOptions(controller, callOptions), "HTML runtime observe");
206
- controller.observerOptions = { ...callOptions };
218
+ controller.observationMode = observationMode;
219
+ controller.observerOptions = { ...callOptions, mode: observationMode };
220
+ controller.bootstrapState = observationMode === "bootstrap"
221
+ ? {
222
+ active: true,
223
+ activityVersion: 0,
224
+ idleMs: callOptions.idleMs == null
225
+ ? controller.defaultBootstrapIdleMs
226
+ : normalizeBootstrapIdleMs(callOptions.idleMs),
227
+ }
228
+ : undefined;
207
229
  const pendingBeforeUpgrade = collectConnectedSolidRenderElements(controller, root);
208
230
  controller.registrationDepth += 1;
209
231
 
@@ -225,6 +247,10 @@ export function createHTMLRuntime(runtime, options = {}) {
225
247
  controller.registrationDepth = Math.max(0, controller.registrationDepth - 1);
226
248
  }
227
249
 
250
+ if (observationMode === "bootstrap") {
251
+ await finishBootstrapObservation(controller, root);
252
+ }
253
+
228
254
  return api;
229
255
  }
230
256
 
@@ -279,7 +305,10 @@ export function createHTMLRuntime(runtime, options = {}) {
279
305
  const wasConnected = controller.connected;
280
306
  const previousObserverOptions = controller.observerOptions ?? {};
281
307
 
282
- if (wasConnected) stopObserver(controller, { preserveOptions: true, reason: "root-change" });
308
+ if (wasConnected) {
309
+ markBootstrapObserverActivity(controller);
310
+ stopObserver(controller, { preserveOptions: true, preserveBootstrap: true, reason: "root-change" });
311
+ }
283
312
 
284
313
  controller.root = nextRoot;
285
314
  if (Object.prototype.hasOwnProperty.call(callOptions, "appendTo")) {
@@ -645,6 +674,7 @@ export const htmlRuntimeScopeAttribute = RUNTIME_SCOPE_ATTRIBUTE;
645
674
  * browser; programmatic-only applications may call it explicitly.
646
675
  */
647
676
  export function registerSolidRenderElement(options = {}) {
677
+ ensureSolidRenderVisibilityStyle(options.document ?? globalThis.document);
648
678
  const registry = options.customElements ?? globalThis.customElements;
649
679
  const HTMLElementBase = options.HTMLElement ?? globalThis.HTMLElement;
650
680
  if (!registry || typeof registry.define !== "function" || typeof registry.get !== "function" || typeof HTMLElementBase !== "function") {
@@ -658,7 +688,7 @@ export function registerSolidRenderElement(options = {}) {
658
688
 
659
689
  class SolidRenderElement extends HTMLElementBase {
660
690
  static get observedAttributes() {
661
- return ["module", "component", RUNTIME_SCOPE_ATTRIBUTE];
691
+ return ["module", "component", RUNTIME_SCOPE_ATTRIBUTE, SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE];
662
692
  }
663
693
 
664
694
  constructor() {
@@ -674,13 +704,16 @@ export function registerSolidRenderElement(options = {}) {
674
704
  propObserver: undefined,
675
705
  pendingModuleResolution: undefined,
676
706
  pendingCheckGeneration: undefined,
707
+ renderState: "pending",
677
708
  });
678
709
  }
679
710
 
680
711
  connectedCallback() {
681
712
  const state = getSolidRenderState(this);
682
713
  state.connected = true;
714
+ setSolidRenderState(this, state, "pending");
683
715
  captureSolidRenderChildren(this, state);
716
+ syncSolidRenderPendingContent(this, state);
684
717
  startSolidRenderPropObserver(this, state);
685
718
  void remountSolidRender(this, "connect");
686
719
  }
@@ -698,9 +731,26 @@ export function registerSolidRenderElement(options = {}) {
698
731
  if (oldValue === newValue) return;
699
732
  const state = getSolidRenderState(this);
700
733
  if (!state.connected) return;
734
+ if (name === SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE) {
735
+ syncSolidRenderPendingContent(this, state);
736
+ return;
737
+ }
701
738
  void remountSolidRender(this, `identity:${name}`);
702
739
  }
703
740
 
741
+ get renderState() {
742
+ return getSolidRenderState(this).renderState;
743
+ }
744
+
745
+ get hideUntilReady() {
746
+ return !this.hasAttribute?.(SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE);
747
+ }
748
+
749
+ set hideUntilReady(value) {
750
+ if (value === false) this.setAttribute?.(SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE, "");
751
+ else this.removeAttribute?.(SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE);
752
+ }
753
+
704
754
  get props() {
705
755
  return getSolidRenderState(this).programmaticProps;
706
756
  }
@@ -731,12 +781,78 @@ function getSolidRenderState(element) {
731
781
  propObserver: undefined,
732
782
  pendingModuleResolution: undefined,
733
783
  pendingCheckGeneration: undefined,
784
+ renderState: "pending",
734
785
  };
735
786
  solidRenderInternals.set(element, state);
736
787
  }
737
788
  return state;
738
789
  }
739
790
 
791
+
792
+ function resolveControllerDocument(controller) {
793
+ const root = controller?.root ?? globalThis.document;
794
+ if (root && typeof root.createElement === "function" && (root.head || root.documentElement || root.body)) return root;
795
+ return root?.ownerDocument ?? globalThis.document;
796
+ }
797
+
798
+ function ensureSolidRenderVisibilityStyle(document) {
799
+ if (!document || (typeof document !== "object" && typeof document !== "function")) return;
800
+ if (solidRenderVisibilityDocuments.has(document)) return;
801
+
802
+ try {
803
+ if (document.querySelector?.(`style[${SOLID_RENDER_VISIBILITY_STYLE_ATTRIBUTE}]`)) {
804
+ solidRenderVisibilityDocuments.add(document);
805
+ return;
806
+ }
807
+ if (typeof document.createElement !== "function") return;
808
+ const style = document.createElement("style");
809
+ style.setAttribute?.(SOLID_RENDER_VISIBILITY_STYLE_ATTRIBUTE, "");
810
+ style.textContent = [
811
+ `solid-render:not([${SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE}]):not(:defined),`,
812
+ `solid-render:not([${SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE}])[${SOLID_RENDER_STATE_ATTRIBUTE}="pending"] {`,
813
+ " visibility: hidden !important;",
814
+ "}",
815
+ ].join("\n");
816
+ const target = document.head ?? document.documentElement ?? document.body;
817
+ if (typeof target?.append === "function") target.append(style);
818
+ else if (typeof target?.appendChild === "function") target.appendChild(style);
819
+ else return;
820
+ solidRenderVisibilityDocuments.add(document);
821
+ } catch {
822
+ // Styling is a progressive enhancement for pre-upgrade visibility. The
823
+ // custom element still manages pending/ready/error state in JavaScript.
824
+ }
825
+ }
826
+
827
+ function setSolidRenderState(element, state, nextState) {
828
+ state.renderState = nextState;
829
+ element.setAttribute?.(SOLID_RENDER_STATE_ATTRIBUTE, nextState);
830
+ }
831
+
832
+ function cloneSolidRenderFallbackNodes(state) {
833
+ const clone = state.childrenTemplate?.cloneNode?.(true);
834
+ if (!clone) return [];
835
+ if (clone.childNodes) return Array.from(clone.childNodes);
836
+ return [clone];
837
+ }
838
+
839
+ function restoreSolidRenderFallback(element, state) {
840
+ if (!state.childrenTemplate) {
841
+ element.replaceChildren?.();
842
+ return;
843
+ }
844
+ element.replaceChildren?.(...cloneSolidRenderFallbackNodes(state));
845
+ }
846
+
847
+ function syncSolidRenderPendingContent(element, state) {
848
+ if (state.renderState !== "pending" || state.mount) return;
849
+ if (element.hasAttribute?.(SOLID_RENDER_SHOW_UNTIL_READY_ATTRIBUTE)) {
850
+ restoreSolidRenderFallback(element, state);
851
+ } else {
852
+ element.replaceChildren?.();
853
+ }
854
+ }
855
+
740
856
  function captureSolidRenderChildren(element, state) {
741
857
  if (state.captured) return;
742
858
  state.captured = true;
@@ -762,7 +878,11 @@ function startSolidRenderPropObserver(element, state) {
762
878
  if (typeof MutationObserverImpl !== "function") return;
763
879
 
764
880
  const observer = new MutationObserverImpl(records => {
765
- if (!Array.from(records ?? []).some(record => String(record?.attributeName ?? "").startsWith("prop:"))) return;
881
+ const relevant = Array.from(records ?? []).some(record => {
882
+ const name = String(record?.attributeName ?? "").toLowerCase();
883
+ return name === "props" || name.startsWith("prop:");
884
+ });
885
+ if (!relevant) return;
766
886
  updateSolidRenderProps(element, state);
767
887
  });
768
888
  observer.observe(element, { attributes: true });
@@ -775,8 +895,10 @@ async function remountSolidRender(element, reason, options = {}) {
775
895
  const generation = ++state.generation;
776
896
  state.pendingModuleResolution = undefined;
777
897
  state.pendingCheckGeneration = undefined;
898
+ setSolidRenderState(element, state, "pending");
778
899
  await disposeSolidRenderMount(element, state, reason === "connect" ? "reconnect" : reason);
779
900
  if (!state.connected || generation !== state.generation) return;
901
+ syncSolidRenderPendingContent(element, state);
780
902
 
781
903
  let controller;
782
904
  try {
@@ -790,7 +912,7 @@ async function remountSolidRender(element, reason, options = {}) {
790
912
  }
791
913
  const componentName = nonEmpty(element.getAttribute?.("component"));
792
914
  const exportName = componentName ?? "default";
793
- const props = buildSolidRenderProps(element, state);
915
+ const props = buildSolidRenderProps(element, state, controller);
794
916
 
795
917
  emitHTML(controller, {
796
918
  type: "render-mounting",
@@ -827,6 +949,7 @@ async function remountSolidRender(element, reason, options = {}) {
827
949
  state.mount = mount;
828
950
  state.pendingModuleResolution = undefined;
829
951
  state.pendingCheckGeneration = undefined;
952
+ setSolidRenderState(element, state, "ready");
830
953
  emitHTML(controller, {
831
954
  type: "render-mounted",
832
955
  element,
@@ -862,6 +985,8 @@ async function remountSolidRender(element, reason, options = {}) {
862
985
  }
863
986
  }
864
987
 
988
+ setSolidRenderState(element, state, "error");
989
+ if (!state.mount) restoreSolidRenderFallback(element, state);
865
990
  if (controller) {
866
991
  emitHTMLError(controller, "solid-render", error, {
867
992
  element,
@@ -922,20 +1047,44 @@ async function disposeSolidRenderMount(element, state, reason = "dispose") {
922
1047
 
923
1048
  function updateSolidRenderProps(element, state) {
924
1049
  if (!state.mount?.updateProps) return;
925
- state.mount.updateProps(buildSolidRenderProps(element, state));
1050
+ let controller = state.controller;
1051
+ try {
1052
+ controller ??= resolveHTMLControllerForElement(element);
1053
+ const props = buildSolidRenderProps(element, state, controller);
1054
+ state.mount.updateProps(props);
1055
+ setSolidRenderState(element, state, "ready");
1056
+ } catch (error) {
1057
+ setSolidRenderState(element, state, "error");
1058
+ if (controller) {
1059
+ emitHTMLError(controller, "solid-render", error, {
1060
+ element,
1061
+ moduleId: nonEmpty(element.getAttribute?.("module")),
1062
+ origin: "solid-render",
1063
+ });
1064
+ }
1065
+ dispatchSolidRenderError(element, error);
1066
+ }
926
1067
  }
927
1068
 
928
- function buildSolidRenderProps(element, state) {
1069
+ function buildSolidRenderProps(element, state, controller) {
929
1070
  const declarative = {};
1071
+ const bulk = parseSolidRenderBulkProps(element, controller);
1072
+ copyPropRecord(declarative, bulk);
1073
+
930
1074
  for (const attribute of Array.from(element.attributes ?? [])) {
931
- const name = String(attribute?.name ?? "").toLowerCase();
1075
+ const rawName = String(attribute?.name ?? "");
1076
+ const name = rawName.toLowerCase();
932
1077
  if (!name.startsWith("prop:")) continue;
933
1078
  const key = kebabToCamel(name.slice(5));
934
1079
  if (!key) continue;
935
- declarative[key] = attribute.value === "" ? true : attribute.value;
1080
+ declarative[key] = parseSolidRenderAttributeProp(element, controller, key, attribute.value);
936
1081
  }
937
1082
 
938
- if (state.childrenTemplate && !("children" in (state.programmaticProps ?? {}))) {
1083
+ if (
1084
+ state.childrenTemplate
1085
+ && !("children" in declarative)
1086
+ && !("children" in (state.programmaticProps ?? {}))
1087
+ ) {
939
1088
  Object.defineProperty(declarative, "children", {
940
1089
  configurable: true,
941
1090
  enumerable: true,
@@ -946,14 +1095,69 @@ function buildSolidRenderProps(element, state) {
946
1095
  }
947
1096
 
948
1097
  const merged = Object.create(null);
949
- for (const key of Reflect.ownKeys(declarative)) {
950
- const descriptor = Object.getOwnPropertyDescriptor(declarative, key);
951
- if (descriptor) Object.defineProperty(merged, key, descriptor);
1098
+ copyPropRecord(merged, declarative);
1099
+ copyPropRecord(merged, state.programmaticProps ?? {});
1100
+ return merged;
1101
+ }
1102
+
1103
+ function copyPropRecord(target, source) {
1104
+ for (const key of Reflect.ownKeys(source ?? {})) {
1105
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
1106
+ if (descriptor) Object.defineProperty(target, key, descriptor);
952
1107
  }
953
- for (const key of Reflect.ownKeys(state.programmaticProps ?? {})) {
954
- merged[key] = state.programmaticProps[key];
1108
+ return target;
1109
+ }
1110
+
1111
+ function parseSolidRenderBulkProps(element, controller) {
1112
+ const source = element.getAttribute?.("props");
1113
+ if (source == null || String(source).trim() === "") return {};
1114
+ const value = parseSolidRenderData(controller, String(source), {
1115
+ element,
1116
+ kind: "props",
1117
+ attribute: "props",
1118
+ });
1119
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1120
+ throw new HTMLRenderError("<solid-render> `props` must parse to a top-level object.", { element });
1121
+ }
1122
+ return value;
1123
+ }
1124
+
1125
+ function parseSolidRenderAttributeProp(element, controller, key, source) {
1126
+ if (source === "") return true;
1127
+ const raw = String(source);
1128
+ const trimmed = raw.trim();
1129
+ if (!(trimmed.startsWith("{") && trimmed.endsWith("}"))) return raw;
1130
+
1131
+ const inner = trimmed.slice(1, -1).trim();
1132
+ if (!inner) {
1133
+ throw new HTMLRenderError(
1134
+ `<solid-render> prop:${key} uses an outer brace pair for typed data; the inner value cannot be empty.`,
1135
+ { element },
1136
+ );
1137
+ }
1138
+ return parseSolidRenderData(controller, inner, {
1139
+ element,
1140
+ kind: "prop",
1141
+ attribute: `prop:${key}`,
1142
+ key,
1143
+ });
1144
+ }
1145
+
1146
+ function parseSolidRenderData(controller, source, context) {
1147
+ try {
1148
+ if (typeof controller?.parseProps === "function") {
1149
+ return controller.parseProps(source, context);
1150
+ }
1151
+ return context.kind === "props"
1152
+ ? parseHTMLPropsObject(source)
1153
+ : parseHTMLDataLiteral(source);
1154
+ } catch (cause) {
1155
+ const label = context.kind === "props" ? "props" : context.attribute;
1156
+ throw new HTMLRenderError(
1157
+ `<solid-render> could not parse ${label} as declarative data: ${cause?.message ?? cause}`,
1158
+ { element: context.element, cause },
1159
+ );
955
1160
  }
956
- return merged;
957
1161
  }
958
1162
 
959
1163
  function kebabToCamel(value) {
@@ -1196,6 +1400,80 @@ async function disposeController(controller, options = {}) {
1196
1400
  controller.events.clear();
1197
1401
  }
1198
1402
 
1403
+ function normalizeObservationMode(mode) {
1404
+ if (mode == null || mode === "continuous") return "continuous";
1405
+ if (mode === "bootstrap") return "bootstrap";
1406
+ throw new TypeError(`Unknown HTML observation mode ${JSON.stringify(mode)}. Expected "continuous" or "bootstrap".`);
1407
+ }
1408
+
1409
+ function normalizeBootstrapIdleMs(value) {
1410
+ if (value == null) return 50;
1411
+ const idleMs = Number(value);
1412
+ if (!Number.isFinite(idleMs) || idleMs < 0) {
1413
+ throw new TypeError("observe({ idleMs }) must be a finite non-negative number.");
1414
+ }
1415
+ return idleMs;
1416
+ }
1417
+
1418
+ function markBootstrapObserverActivity(controller) {
1419
+ const state = controller.bootstrapState;
1420
+ if (state?.active) state.activityVersion += 1;
1421
+ }
1422
+
1423
+ async function finishBootstrapObservation(controller, root) {
1424
+ const state = controller.bootstrapState;
1425
+ if (!state?.active) return;
1426
+
1427
+ await waitForBootstrapDOMReady(root);
1428
+
1429
+ while (state.active && !controller.disposed) {
1430
+ const version = state.activityVersion;
1431
+ await waitForBootstrapIdle(state.idleMs);
1432
+ await controller.queue;
1433
+
1434
+ if (controller.lastError) {
1435
+ const error = controller.lastError;
1436
+ controller.lastError = undefined;
1437
+ stopObserver(controller, { reason: "bootstrap-error" });
1438
+ throw error;
1439
+ }
1440
+
1441
+ if (!state.active || controller.disposed) return;
1442
+ if (state.activityVersion !== version) continue;
1443
+
1444
+ // setRoot() may briefly disconnect/reconnect while preserving bootstrap
1445
+ // state. Wait another turn rather than treating that transition as settled.
1446
+ if (!controller.connected) {
1447
+ await waitForBootstrapIdle(0);
1448
+ continue;
1449
+ }
1450
+
1451
+ stopObserver(controller, { reason: "bootstrap-idle" });
1452
+ return;
1453
+ }
1454
+ }
1455
+
1456
+ function waitForBootstrapIdle(idleMs) {
1457
+ return new Promise(resolve => {
1458
+ const schedule = globalThis.setTimeout ?? ((callback) => Promise.resolve().then(callback));
1459
+ schedule(resolve, idleMs);
1460
+ });
1461
+ }
1462
+
1463
+ function waitForBootstrapDOMReady(root) {
1464
+ const document = root?.nodeType === 9
1465
+ ? root
1466
+ : root?.ownerDocument ?? (root === globalThis.document ? root : undefined);
1467
+
1468
+ if (!document || document.readyState !== "loading" || typeof document.addEventListener !== "function") {
1469
+ return Promise.resolve();
1470
+ }
1471
+
1472
+ return new Promise(resolve => {
1473
+ document.addEventListener("DOMContentLoaded", resolve, { once: true });
1474
+ });
1475
+ }
1476
+
1199
1477
  async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1200
1478
  const MutationObserverImpl = callOptions.MutationObserver ?? controller.MutationObserver ?? globalThis.MutationObserver;
1201
1479
 
@@ -1220,8 +1498,16 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1220
1498
  }
1221
1499
 
1222
1500
  const observer = new MutationObserverImpl(records => {
1223
- if (!observerMutationsRequireScan(controller, records, callOptions)) return;
1224
- void enqueueObserverScan(controller, callOptions).catch(() => {});
1501
+ const batch = collectObserverMutationBatch(controller, records, callOptions);
1502
+ if (!batch) return;
1503
+ markBootstrapObserverActivity(controller);
1504
+
1505
+ if (batch.requiresFallbackScan) {
1506
+ void enqueueObserverScan(controller, callOptions).catch(() => {});
1507
+ return;
1508
+ }
1509
+
1510
+ void enqueueObserverBatch(controller, batch, callOptions).catch(() => {});
1225
1511
  });
1226
1512
 
1227
1513
  controller.observer = observer;
@@ -1248,61 +1534,134 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
1248
1534
  return result;
1249
1535
  }
1250
1536
 
1251
- function observerMutationsRequireScan(controller, records, callOptions = {}) {
1537
+ function collectObserverMutationBatch(controller, records, callOptions = {}) {
1252
1538
  const mutations = Array.from(records ?? []);
1253
1539
 
1254
1540
  // MutationObserver never invokes its callback with an empty batch, but custom
1255
1541
  // 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;
1542
+ // behavior for those implementations by falling back to a full scan.
1543
+ if (mutations.length === 0) {
1544
+ return {
1545
+ declarations: [],
1546
+ solidRenders: [],
1547
+ removedRecords: [],
1548
+ requiresFallbackScan: true,
1549
+ };
1550
+ }
1258
1551
 
1259
1552
  const selector = callOptions.selector ?? controller.selector;
1553
+ const declarations = [];
1554
+ const declarationSet = new Set();
1555
+ const solidRenders = [];
1556
+ const solidRenderSet = new Set();
1557
+ const removedRecords = new Set();
1260
1558
 
1261
1559
  for (const mutation of mutations) {
1262
- if (!mutation || mutation.type !== "childList") return true;
1560
+ if (!mutation || mutation.type !== "childList") {
1561
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1562
+ }
1263
1563
 
1264
1564
  const addedNodes = mutation.addedNodes;
1265
1565
  const removedNodes = mutation.removedNodes;
1266
- if (addedNodes == null || removedNodes == null) return true;
1566
+ if (addedNodes == null || removedNodes == null) {
1567
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1568
+ }
1267
1569
 
1268
1570
  for (const node of Array.from(addedNodes)) {
1269
- if (nodeOrDescendantMatches(node, selector)) return true;
1571
+ const declarationMatches = collectNodeMatches(node, selector);
1572
+ if (declarationMatches.uncertain) {
1573
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1574
+ }
1575
+ for (const element of declarationMatches.elements) {
1576
+ // Explicit APIs claim before inserting into the DOM. If this controller
1577
+ // already owns the element, the observer has no discovery work to do.
1578
+ if (elementOwners.get(element)?.owner === controller) continue;
1579
+ if (controller.ignoredExisting.has(element)) continue;
1580
+ if (!declarationSet.has(element)) {
1581
+ declarationSet.add(element);
1582
+ declarations.push(element);
1583
+ }
1584
+ }
1270
1585
 
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;
1586
+ const renderMatches = collectNodeMatches(node, "solid-render");
1587
+ if (renderMatches.uncertain) {
1588
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1589
+ }
1590
+ for (const element of renderMatches.elements) {
1591
+ if (!solidRenderSet.has(element)) {
1592
+ solidRenderSet.add(element);
1593
+ solidRenders.push(element);
1594
+ }
1595
+ }
1274
1596
  }
1275
1597
 
1276
1598
  for (const node of Array.from(removedNodes)) {
1277
- if (removedNodeContainsMountedDeclaration(controller, node)) return true;
1599
+ const result = collectRemovedMountedRecords(controller, node, removedRecords);
1600
+ if (!result) {
1601
+ return { declarations, solidRenders, removedRecords: [...removedRecords], requiresFallbackScan: true };
1602
+ }
1278
1603
  }
1279
1604
  }
1280
1605
 
1281
- return false;
1606
+ if (declarations.length === 0 && solidRenders.length === 0 && removedRecords.size === 0) {
1607
+ return undefined;
1608
+ }
1609
+
1610
+ return {
1611
+ declarations,
1612
+ solidRenders,
1613
+ removedRecords: [...removedRecords],
1614
+ requiresFallbackScan: false,
1615
+ };
1282
1616
  }
1283
1617
 
1284
- function nodeOrDescendantMatches(node, selector) {
1285
- if (!node) return false;
1618
+ function collectNodeMatches(node, selector) {
1619
+ const elements = [];
1620
+ const seen = new Set();
1621
+ if (!node) return { elements, uncertain: false };
1286
1622
 
1623
+ let inspected = false;
1287
1624
  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;
1625
+ if (typeof node.matches === "function") {
1626
+ inspected = true;
1627
+ if (node.matches(selector)) {
1628
+ seen.add(node);
1629
+ elements.push(node);
1630
+ }
1631
+ }
1632
+
1633
+ if (typeof node.querySelectorAll === "function") {
1634
+ inspected = true;
1635
+ for (const element of Array.from(node.querySelectorAll(selector) ?? [])) {
1636
+ if (seen.has(element)) continue;
1637
+ seen.add(element);
1638
+ elements.push(element);
1639
+ }
1640
+ }
1291
1641
  } 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;
1642
+ return { elements, uncertain: true };
1295
1643
  }
1296
1644
 
1297
- // Standard DOM nodes that did not match above cannot affect discovery.
1298
- if (typeof node.nodeType === "number") return false;
1645
+ if (inspected) return { elements, uncertain: false };
1646
+
1647
+ // Text/comment nodes cannot contain declarations. Element/document/fragment
1648
+ // nodes in a standards DOM should expose matches/querySelectorAll as
1649
+ // appropriate; a host object that does not is conservatively scanned.
1650
+ if (typeof node.nodeType === "number") {
1651
+ if (node.nodeType === 3 || node.nodeType === 4 || node.nodeType === 8) {
1652
+ return { elements, uncertain: false };
1653
+ }
1654
+ return { elements, uncertain: true };
1655
+ }
1299
1656
 
1300
- // Unknown host DOM objects cannot be classified safely.
1301
- return typeof node.getAttribute === "function";
1657
+ return {
1658
+ elements,
1659
+ uncertain: typeof node.getAttribute === "function" || typeof node.contains === "function",
1660
+ };
1302
1661
  }
1303
1662
 
1304
- function removedNodeContainsMountedDeclaration(controller, node) {
1305
- if (!node) return false;
1663
+ function collectRemovedMountedRecords(controller, node, records) {
1664
+ if (!node) return true;
1306
1665
 
1307
1666
  for (const record of controller.bindings.values()) {
1308
1667
  if (!record.mount) continue;
@@ -1310,16 +1669,22 @@ function removedNodeContainsMountedDeclaration(controller, node) {
1310
1669
  ? record.range?.startMarker
1311
1670
  : record.element;
1312
1671
  if (!anchor) continue;
1313
- if (node === anchor) return true;
1672
+ if (node === anchor) {
1673
+ records.add(record);
1674
+ continue;
1675
+ }
1314
1676
 
1315
1677
  try {
1316
- if (typeof node.contains === "function" && node.contains(anchor)) return true;
1678
+ if (typeof node.contains === "function" && node.contains(anchor)) {
1679
+ records.add(record);
1680
+ }
1317
1681
  } catch {
1318
- return true;
1682
+ return false;
1319
1683
  }
1320
1684
  }
1321
1685
 
1322
- return false;
1686
+ if (typeof node.nodeType === "number" || typeof node.contains === "function") return true;
1687
+ return typeof node.getAttribute !== "function";
1323
1688
  }
1324
1689
 
1325
1690
  function stopObserver(controller, options = {}) {
@@ -1330,6 +1695,10 @@ function stopObserver(controller, options = {}) {
1330
1695
  controller.observer = undefined;
1331
1696
  controller.observerRoot = undefined;
1332
1697
  if (!options.preserveOptions) controller.observerOptions = undefined;
1698
+ if (!options.preserveBootstrap && controller.bootstrapState) {
1699
+ controller.bootstrapState.active = false;
1700
+ controller.bootstrapState = undefined;
1701
+ }
1333
1702
  emitHTML(controller, {
1334
1703
  type: "observer-disconnected",
1335
1704
  root,
@@ -1345,54 +1714,56 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1345
1714
  const selector = callOptions.selector ?? controller.selector;
1346
1715
  const origin = callOptions.origin ?? "observer";
1347
1716
  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
- }
1717
+ const classified = classifyObserverElements(controller, discovered, callOptions, origin);
1364
1718
 
1365
- const accepted = acceptsDiscoveredElement(controller, element, callOptions);
1366
- emitHTML(controller, {
1367
- type: "element-discovered",
1368
- element,
1369
- moduleId: nonEmpty(element?.getAttribute?.("module")),
1719
+ let result;
1720
+ try {
1721
+ result = await processElements(controller, classified.elements, {
1722
+ ...callOptions,
1723
+ root,
1370
1724
  origin,
1371
- accepted,
1725
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
1726
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
1372
1727
  });
1373
- if (!accepted) {
1374
- ignored += 1;
1375
- maybeWarnUnresolvedRenderScope(controller, element, callOptions, origin);
1376
- continue;
1377
- }
1378
- matched += 1;
1728
+ } catch (error) {
1729
+ emitHTMLError(controller, "observer", error, { origin });
1730
+ throw error;
1731
+ }
1379
1732
 
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
- }
1733
+ await cleanupDetachedRenderedDeclarations(controller, root);
1734
+ await retrySolidRenderElements(
1735
+ controller,
1736
+ collectConnectedSolidRenderElements(controller, root),
1737
+ { reason: "observer-batch-complete", forceMissingError: true },
1738
+ );
1389
1739
 
1390
- throw ownershipError(controller, element, ownership);
1391
- }
1740
+ emitObserverBatch(controller, {
1741
+ root,
1742
+ origin,
1743
+ discovered: discovered.length,
1744
+ matched: classified.matched,
1745
+ ignored: classified.ignored,
1746
+ result,
1747
+ });
1748
+ return result;
1749
+ });
1750
+
1751
+ trackObserverTask(controller, task, callOptions);
1752
+ return task;
1753
+ }
1754
+
1755
+ async function enqueueObserverBatch(controller, batch, callOptions = {}) {
1756
+ const task = controller.queue.then(async () => {
1757
+ if (!controller.connected) return emptyRegistrationResult();
1758
+
1759
+ const root = controller.observerRoot ?? getRoot(mergeOptions(controller, callOptions), "HTML runtime observer");
1760
+ const origin = callOptions.origin ?? "observer";
1761
+ const discovered = batch.declarations;
1762
+ const classified = classifyObserverElements(controller, discovered, callOptions, origin);
1392
1763
 
1393
1764
  let result;
1394
1765
  try {
1395
- result = await processElements(controller, elements, {
1766
+ result = await processElements(controller, classified.elements, {
1396
1767
  ...callOptions,
1397
1768
  root,
1398
1769
  origin,
@@ -1404,26 +1775,106 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1404
1775
  throw error;
1405
1776
  }
1406
1777
 
1407
- await cleanupDetachedRenderedDeclarations(controller, root);
1778
+ await cleanupRemovedRenderedDeclarations(controller, root, batch.removedRecords);
1408
1779
  await retrySolidRenderElements(
1409
1780
  controller,
1410
- collectConnectedSolidRenderElements(controller, root),
1781
+ batch.solidRenders,
1411
1782
  { reason: "observer-batch-complete", forceMissingError: true },
1412
1783
  );
1413
1784
 
1414
- emitHTML(controller, {
1415
- type: "observer-batch",
1785
+ emitObserverBatch(controller, {
1416
1786
  root,
1417
1787
  origin,
1418
1788
  discovered: discovered.length,
1419
- matched,
1420
- claimed: result.modules.length,
1421
- ignored,
1422
- modules: result.modules.map(module => module.id),
1789
+ matched: classified.matched,
1790
+ ignored: classified.ignored,
1791
+ result,
1423
1792
  });
1424
1793
  return result;
1425
1794
  });
1426
1795
 
1796
+ trackObserverTask(controller, task, callOptions);
1797
+ return task;
1798
+ }
1799
+
1800
+ function classifyObserverElements(controller, discovered, callOptions, origin) {
1801
+ const elements = [];
1802
+ let matched = 0;
1803
+ let ignored = 0;
1804
+
1805
+ for (const element of discovered) {
1806
+ if (controller.ignoredExisting.has(element)) {
1807
+ ignored += 1;
1808
+ emitHTML(controller, {
1809
+ type: "element-discovered",
1810
+ element,
1811
+ moduleId: nonEmpty(element?.getAttribute?.("module")),
1812
+ origin,
1813
+ accepted: false,
1814
+ });
1815
+ continue;
1816
+ }
1817
+
1818
+ const accepted = acceptsDiscoveredElement(controller, element, callOptions);
1819
+ emitHTML(controller, {
1820
+ type: "element-discovered",
1821
+ element,
1822
+ moduleId: nonEmpty(element?.getAttribute?.("module")),
1823
+ origin,
1824
+ accepted,
1825
+ });
1826
+ if (!accepted) {
1827
+ ignored += 1;
1828
+ maybeWarnUnresolvedRenderScope(controller, element, callOptions, origin);
1829
+ continue;
1830
+ }
1831
+ matched += 1;
1832
+
1833
+ const ownership = elementOwners.get(element);
1834
+ if (!ownership) {
1835
+ elements.push(element);
1836
+ continue;
1837
+ }
1838
+ if (ownership.owner === controller) {
1839
+ ignored += 1;
1840
+ continue;
1841
+ }
1842
+
1843
+ throw ownershipError(controller, element, ownership);
1844
+ }
1845
+
1846
+ return { elements, matched, ignored };
1847
+ }
1848
+
1849
+ async function cleanupRemovedRenderedDeclarations(controller, root, records) {
1850
+ for (const record of records ?? []) {
1851
+ if (!record?.mount) continue;
1852
+ const anchor = record.mount.mode === "in-place"
1853
+ ? record.range?.startMarker
1854
+ : record.element;
1855
+ if (!anchor || rootContainsElement(root, anchor)) continue;
1856
+
1857
+ await disposeRecordMount(controller, record, {
1858
+ reason: "declaration-detached",
1859
+ removeAnchors: false,
1860
+ });
1861
+ }
1862
+ }
1863
+
1864
+ function emitObserverBatch(controller, { root, origin, discovered, matched, ignored, result }) {
1865
+ emitHTML(controller, {
1866
+ type: "observer-batch",
1867
+ root,
1868
+ origin,
1869
+ discovered,
1870
+ matched,
1871
+ claimed: result.modules.length,
1872
+ ignored,
1873
+ modules: result.modules.map(module => module.id),
1874
+ });
1875
+ }
1876
+
1877
+ function trackObserverTask(controller, task, callOptions) {
1427
1878
  controller.queue = task.then(
1428
1879
  () => undefined,
1429
1880
  error => {
@@ -1435,8 +1886,6 @@ async function enqueueObserverScan(controller, callOptions = {}) {
1435
1886
  }
1436
1887
  },
1437
1888
  );
1438
-
1439
- return task;
1440
1889
  }
1441
1890
 
1442
1891
  async function processElements(controller, elements, options = {}) {