solid-tag-runtime 0.0.7 → 0.0.9

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,4 +1,6 @@
1
1
  import { SolidTagRuntimeError } from "./errors.js";
2
+ import { createEventDispatcher } from "./events.js";
3
+ import { HTMLRenderError, createDefaultHTMLRenderAdapter, resolveRenderComponent } from "./render.js";
2
4
 
3
5
  const DEFAULT_SELECTOR = [
4
6
  'script[type="solid-jsx"]',
@@ -12,6 +14,9 @@ const DEFAULT_SELECTOR = [
12
14
  const RUNTIME_SCOPE_ATTRIBUTE = "data-solid-runtime";
13
15
  const elementOwners = new WeakMap();
14
16
  const defaultControllers = new WeakMap();
17
+ const activeControllers = new Set();
18
+ const solidRenderClasses = new WeakMap();
19
+ const solidRenderInternals = new WeakMap();
15
20
  let anonymousSequence = 0;
16
21
  let controllerSequence = 0;
17
22
 
@@ -34,6 +39,8 @@ export class HTMLModuleOwnershipError extends HTMLModuleError {
34
39
  }
35
40
  }
36
41
 
42
+ export { HTMLRenderError };
43
+
37
44
  /**
38
45
  * Create a persistent DOM/HTML adapter for one solid-tag-runtime instance.
39
46
  *
@@ -47,18 +54,27 @@ export function createHTMLRuntime(runtime, options = {}) {
47
54
  const controller = {
48
55
  id: `solid-tag-html-${++controllerSequence}`,
49
56
  runtime,
57
+ events: createEventDispatcher({
58
+ onListenerError(error) {
59
+ globalThis.console?.error?.("solid-tag-runtime/html lifecycle subscriber error", error);
60
+ },
61
+ }),
50
62
  scope: normalizeScope(options.scope),
51
63
  root: options.root,
52
64
  selector: options.selector ?? DEFAULT_SELECTOR,
53
65
  acceptUnscoped: options.acceptUnscoped !== false,
54
66
  executeEntries: options.executeEntries !== false,
67
+ executeRenders: options.executeRenders !== false,
55
68
  fetch: options.fetch,
56
69
  baseUrl: options.baseUrl,
57
70
  MutationObserver: options.MutationObserver,
58
71
  subtree: options.subtree !== false,
59
72
  createElement: options.createElement,
60
73
  appendTo: options.appendTo,
74
+ renderAdapter: options.renderAdapter ?? createDefaultHTMLRenderAdapter(runtime),
61
75
  bindings: new Map(),
76
+ renderElements: new Set(),
77
+ disposed: false,
62
78
  observer: undefined,
63
79
  observerRoot: undefined,
64
80
  observerOptions: undefined,
@@ -88,6 +104,7 @@ export function createHTMLRuntime(runtime, options = {}) {
88
104
  get appendTarget() {
89
105
  return getAppendTarget(controller);
90
106
  },
107
+ subscribe,
91
108
  register,
92
109
  observe,
93
110
  flush,
@@ -104,25 +121,58 @@ export function createHTMLRuntime(runtime, options = {}) {
104
121
  owns,
105
122
  getModuleId,
106
123
  getElement,
124
+ dispose,
107
125
  };
108
126
 
109
127
  controller.api = api;
128
+ activeControllers.add(controller);
129
+ // The HTML adapter owns the global custom-element registration. Registering
130
+ // here means a controller is already available before any existing
131
+ // <solid-render> elements are upgraded/connected. The helper is idempotent.
132
+ registerSolidRenderElement();
133
+ controller.runtimeDisposeUnsubscribe = runtime.subscribe?.("runtime-disposed", () => {
134
+ void disposeController(controller, { runtimeDisposed: true });
135
+ });
110
136
  return api;
111
137
 
138
+ function subscribe(typesOrListener, maybeListener) {
139
+ return controller.events.subscribe(typesOrListener, maybeListener);
140
+ }
141
+
112
142
  /** One-shot scan of matching scripts. */
113
143
  async function register(callOptions = {}) {
114
144
  const root = getRoot(mergeOptions(controller, callOptions), "HTML runtime register");
115
145
  const selector = callOptions.selector ?? controller.selector;
116
- const elements = Array.from(root.querySelectorAll(selector)).filter(element =>
117
- acceptsDiscoveredElement(controller, element, callOptions),
118
- );
146
+ const origin = callOptions.origin ?? "initial-scan";
147
+ const discovered = Array.from(root.querySelectorAll(selector));
148
+ const elements = [];
149
+
150
+ for (const element of discovered) {
151
+ const accepted = acceptsDiscoveredElement(controller, element, callOptions);
152
+ emitHTML(controller, {
153
+ type: "element-discovered",
154
+ element,
155
+ moduleId: nonEmpty(element?.getAttribute?.("module")),
156
+ origin,
157
+ accepted,
158
+ });
159
+ if (accepted) elements.push(element);
160
+ }
119
161
 
120
- return processElements(controller, elements, {
121
- ...callOptions,
122
- root,
123
- origin: callOptions.origin ?? "initial-scan",
124
- executeEntries: callOptions.executeEntries ?? controller.executeEntries,
125
- });
162
+ try {
163
+ const result = await processElements(controller, elements, {
164
+ ...callOptions,
165
+ root,
166
+ origin,
167
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
168
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
169
+ });
170
+ registerSolidRenderElement();
171
+ return result;
172
+ } catch (error) {
173
+ emitHTMLError(controller, "register", error, { origin });
174
+ throw error;
175
+ }
126
176
  }
127
177
 
128
178
  /**
@@ -140,6 +190,7 @@ export function createHTMLRuntime(runtime, options = {}) {
140
190
  registerExisting: callOptions.registerExisting !== false,
141
191
  initial: true,
142
192
  });
193
+ registerSolidRenderElement();
143
194
  } catch (error) {
144
195
  disconnect();
145
196
  throw error;
@@ -166,6 +217,10 @@ export function createHTMLRuntime(runtime, options = {}) {
166
217
  stopObserver(controller);
167
218
  }
168
219
 
220
+ async function dispose() {
221
+ await disposeController(controller, { runtimeDisposed: false });
222
+ }
223
+
169
224
  /**
170
225
  * Change the discovery/observation root. If observation is active, it is
171
226
  * rebound to the new root and existing matching scripts are registered by
@@ -174,6 +229,15 @@ export function createHTMLRuntime(runtime, options = {}) {
174
229
  */
175
230
  async function setRoot(root, callOptions = {}) {
176
231
  const nextRoot = getRoot({ root }, "HTML runtime setRoot");
232
+ const previousRoot = controller.root ?? globalThis.document;
233
+ const previousAppendTarget = getAppendTarget(controller);
234
+ emitHTML(controller, {
235
+ type: "root-changing",
236
+ previousRoot,
237
+ root: nextRoot,
238
+ connected: controller.connected,
239
+ registerExisting: callOptions.registerExisting ?? controller.connected,
240
+ });
177
241
 
178
242
  // Finish work scheduled against the old root before rebinding observer state.
179
243
  await controller.queue;
@@ -186,7 +250,7 @@ export function createHTMLRuntime(runtime, options = {}) {
186
250
  const wasConnected = controller.connected;
187
251
  const previousObserverOptions = controller.observerOptions ?? {};
188
252
 
189
- if (wasConnected) stopObserver(controller, { preserveOptions: true });
253
+ if (wasConnected) stopObserver(controller, { preserveOptions: true, reason: "root-change" });
190
254
 
191
255
  controller.root = nextRoot;
192
256
  if (Object.prototype.hasOwnProperty.call(callOptions, "appendTo")) {
@@ -204,10 +268,13 @@ export function createHTMLRuntime(runtime, options = {}) {
204
268
  };
205
269
  controller.observerOptions = { ...observeOptions };
206
270
  try {
207
- return await connectObserver(controller, nextRoot, observeOptions, {
271
+ const result = await connectObserver(controller, nextRoot, observeOptions, {
208
272
  registerExisting,
209
273
  initial: false,
274
+ reason: "root-change",
210
275
  });
276
+ emitRootChanged(controller, previousRoot, nextRoot, registerExisting, previousAppendTarget);
277
+ return result;
211
278
  } catch (error) {
212
279
  stopObserver(controller, { preserveOptions: true });
213
280
  throw error;
@@ -215,9 +282,12 @@ export function createHTMLRuntime(runtime, options = {}) {
215
282
  }
216
283
 
217
284
  if (registerExisting) {
218
- return register({ ...callOptions, root: nextRoot });
285
+ const result = await register({ ...callOptions, root: nextRoot, origin: callOptions.origin ?? "root-change-scan" });
286
+ emitRootChanged(controller, previousRoot, nextRoot, registerExisting, previousAppendTarget);
287
+ return result;
219
288
  }
220
289
 
290
+ emitRootChanged(controller, previousRoot, nextRoot, registerExisting, previousAppendTarget);
221
291
  return emptyRegistrationResult();
222
292
  }
223
293
 
@@ -235,7 +305,15 @@ export function createHTMLRuntime(runtime, options = {}) {
235
305
  if (!target || (typeof target.append !== "function" && typeof target.appendChild !== "function")) {
236
306
  throw new TypeError("setAppendTarget() requires a target with append() or appendChild().");
237
307
  }
308
+ const previousTarget = getAppendTarget(controller);
238
309
  controller.appendTo = target;
310
+ if (previousTarget !== target) {
311
+ emitHTML(controller, {
312
+ type: "append-target-changed",
313
+ previousTarget,
314
+ target,
315
+ });
316
+ }
239
317
  return api;
240
318
  }
241
319
 
@@ -246,6 +324,7 @@ export function createHTMLRuntime(runtime, options = {}) {
246
324
  origin: callOptions.origin ?? "define-element",
247
325
  explicit: true,
248
326
  executeEntries: false,
327
+ executeRenders: false,
249
328
  });
250
329
  return result.modules[0] ?? definitionFromOwnedElement(controller, element);
251
330
  }
@@ -257,6 +336,7 @@ export function createHTMLRuntime(runtime, options = {}) {
257
336
  origin: callOptions.origin ?? "register-element",
258
337
  explicit: true,
259
338
  executeEntries: callOptions.executeEntries ?? controller.executeEntries,
339
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
260
340
  });
261
341
  return result.modules[0] ?? definitionFromOwnedElement(controller, element);
262
342
  }
@@ -268,6 +348,12 @@ export function createHTMLRuntime(runtime, options = {}) {
268
348
  */
269
349
  async function updateElement(element, callOptions = {}) {
270
350
  const record = requireOwnedRecord(controller, element, "updateElement");
351
+ emitHTML(controller, {
352
+ type: "element-updating",
353
+ element,
354
+ moduleId: record.moduleId,
355
+ origin: callOptions.origin ?? "update-element",
356
+ });
271
357
  const metadata = inspectElement(controller, element, {
272
358
  ...callOptions,
273
359
  existingId: record.moduleId,
@@ -285,15 +371,30 @@ export function createHTMLRuntime(runtime, options = {}) {
285
371
  ...record,
286
372
  format: metadata.format,
287
373
  entry: metadata.entry,
374
+ render: metadata.render,
375
+ component: metadata.component,
288
376
  sourceUrl: metadata.sourceUrl,
289
377
  inline: !metadata.sourceUrl,
290
378
  };
291
379
 
292
380
  const { source } = await prepareRecordSource(controller, candidate, callOptions);
381
+ const previousRender = record.render;
293
382
  runtime.update(record.moduleId, source, { format: candidate.format });
294
383
 
384
+ if (record.mount) {
385
+ const previousInPlace = previousRender === true;
386
+ const nextInPlace = candidate.render === true;
387
+ await disposeRecordMount(controller, record, {
388
+ reason: "element-update",
389
+ removeAnchors: previousInPlace && !nextInPlace,
390
+ restoreElement: previousInPlace && !nextInPlace,
391
+ });
392
+ }
393
+
295
394
  record.format = candidate.format;
296
395
  record.entry = candidate.entry;
396
+ record.render = candidate.render;
397
+ record.component = candidate.component;
297
398
  record.sourceUrl = candidate.sourceUrl;
298
399
  record.inline = candidate.inline;
299
400
  record.state = "defined";
@@ -301,17 +402,24 @@ export function createHTMLRuntime(runtime, options = {}) {
301
402
  record.definition = definitionFromRecord(record);
302
403
 
303
404
  if (record.entry && (callOptions.executeEntries ?? controller.executeEntries) !== false) {
304
- record.state = "executing";
305
- try {
306
- await runtime.import(record.moduleId);
307
- record.state = "ready";
308
- } catch (error) {
309
- record.state = "error";
310
- record.error = error;
311
- throw error;
312
- }
405
+ await executeEntryRecord(controller, record, {
406
+ ...callOptions,
407
+ origin: callOptions.origin ?? "update-element",
408
+ });
409
+ } else if (record.render !== undefined && (callOptions.executeRenders ?? controller.executeRenders) !== false) {
410
+ await renderDeclarationRecord(controller, record, {
411
+ ...callOptions,
412
+ origin: callOptions.origin ?? "update-element",
413
+ });
313
414
  }
314
415
 
416
+ emitHTML(controller, {
417
+ type: "element-updated",
418
+ element,
419
+ moduleId: record.moduleId,
420
+ definition: record.definition,
421
+ origin: callOptions.origin ?? "update-element",
422
+ });
315
423
  return record.definition;
316
424
  }
317
425
 
@@ -322,10 +430,27 @@ export function createHTMLRuntime(runtime, options = {}) {
322
430
  */
323
431
  async function removeElement(element, callOptions = {}) {
324
432
  const record = requireOwnedRecord(controller, element, "removeElement");
433
+ const origin = callOptions.origin ?? "remove-element";
434
+ emitHTML(controller, {
435
+ type: "element-removing",
436
+ element,
437
+ moduleId: record.moduleId,
438
+ origin,
439
+ removeModule: callOptions.removeModule !== false,
440
+ removeFromDOM: callOptions.removeFromDOM !== false,
441
+ });
325
442
  const removeModule = callOptions.removeModule !== false;
326
443
  const removeFromDOM = callOptions.removeFromDOM !== false;
327
444
  const invalidateDependents = callOptions.invalidateDependents !== false;
328
445
 
446
+ if (record.mount) {
447
+ await disposeRecordMount(controller, record, {
448
+ reason: "element-remove",
449
+ removeAnchors: record.render === true,
450
+ restoreElement: record.render === true && !removeFromDOM,
451
+ });
452
+ }
453
+
329
454
  let moduleRemoved = false;
330
455
  if (removeModule) {
331
456
  moduleRemoved = runtime.remove(record.moduleId, { invalidateDependents });
@@ -334,22 +459,37 @@ export function createHTMLRuntime(runtime, options = {}) {
334
459
  controller.bindings.delete(record.moduleId);
335
460
  elementOwners.delete(element);
336
461
  record.state = "removed";
462
+ emitHTML(controller, {
463
+ type: "element-released",
464
+ element,
465
+ moduleId: record.moduleId,
466
+ origin,
467
+ });
337
468
 
338
469
  let elementRemoved = false;
339
470
  if (removeFromDOM) {
340
- elementRemoved = removeDOMElement(element);
471
+ elementRemoved = record.render === true ? true : removeDOMElement(element);
341
472
  } else {
342
473
  // Keep a released element in the observed tree from being immediately
343
474
  // rediscovered. Explicit registerElement() can still claim it again.
344
475
  controller.ignoredExisting.add(element);
345
476
  }
346
477
 
347
- return {
478
+ const result = {
348
479
  id: record.moduleId,
349
480
  element,
350
481
  moduleRemoved,
351
482
  elementRemoved,
352
483
  };
484
+ emitHTML(controller, {
485
+ type: "element-removed",
486
+ element,
487
+ moduleId: record.moduleId,
488
+ origin,
489
+ moduleRemoved,
490
+ elementRemoved,
491
+ });
492
+ return result;
353
493
  }
354
494
 
355
495
  /**
@@ -371,6 +511,7 @@ export function createHTMLRuntime(runtime, options = {}) {
371
511
  ...callOptions,
372
512
  origin: callOptions.origin ?? "append",
373
513
  executeEntries: callOptions.executeEntries ?? controller.executeEntries,
514
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
374
515
  });
375
516
 
376
517
  if (!claim.fresh) await claim.record.promise;
@@ -409,6 +550,9 @@ export function createHTMLRuntime(runtime, options = {}) {
409
550
  if (id) element.setAttribute("module", id);
410
551
  if (moduleOptions.src) element.setAttribute("src", String(moduleOptions.src));
411
552
  if (moduleOptions.entry) element.setAttribute("entry", "");
553
+ if (moduleOptions.render === true) element.setAttribute("render", "");
554
+ else if (typeof moduleOptions.render === "string") element.setAttribute("render", moduleOptions.render);
555
+ if (moduleOptions.component) element.setAttribute("component", String(moduleOptions.component));
412
556
  if (moduleOptions.language) element.setAttribute("language", String(moduleOptions.language));
413
557
  if (moduleOptions.source != null) element.textContent = String(moduleOptions.source);
414
558
 
@@ -465,6 +609,399 @@ export async function defineScript(runtime, element, options = {}) {
465
609
  export const htmlModuleSelector = DEFAULT_SELECTOR;
466
610
  export const htmlRuntimeScopeAttribute = RUNTIME_SCOPE_ATTRIBUTE;
467
611
 
612
+
613
+ /**
614
+ * Register the global <solid-render> custom element once for the selected
615
+ * CustomElementRegistry. registerHTML()/observe() call this automatically in a
616
+ * browser; programmatic-only applications may call it explicitly.
617
+ */
618
+ export function registerSolidRenderElement(options = {}) {
619
+ const registry = options.customElements ?? globalThis.customElements;
620
+ const HTMLElementBase = options.HTMLElement ?? globalThis.HTMLElement;
621
+ if (!registry || typeof registry.define !== "function" || typeof registry.get !== "function" || typeof HTMLElementBase !== "function") {
622
+ return undefined;
623
+ }
624
+
625
+ const existing = registry.get("solid-render");
626
+ if (existing) return existing;
627
+ const cached = solidRenderClasses.get(registry);
628
+ if (cached) return cached;
629
+
630
+ class SolidRenderElement extends HTMLElementBase {
631
+ static get observedAttributes() {
632
+ return ["module", "component", RUNTIME_SCOPE_ATTRIBUTE];
633
+ }
634
+
635
+ constructor() {
636
+ super();
637
+ solidRenderInternals.set(this, {
638
+ connected: false,
639
+ generation: 0,
640
+ captured: false,
641
+ childrenTemplate: undefined,
642
+ programmaticProps: {},
643
+ mount: undefined,
644
+ controller: undefined,
645
+ propObserver: undefined,
646
+ });
647
+ }
648
+
649
+ connectedCallback() {
650
+ const state = getSolidRenderState(this);
651
+ state.connected = true;
652
+ captureSolidRenderChildren(this, state);
653
+ startSolidRenderPropObserver(this, state);
654
+ void remountSolidRender(this, "connect");
655
+ }
656
+
657
+ disconnectedCallback() {
658
+ const state = getSolidRenderState(this);
659
+ state.connected = false;
660
+ state.generation += 1;
661
+ state.propObserver?.disconnect?.();
662
+ state.propObserver = undefined;
663
+ void disposeSolidRenderMount(this, state, "disconnect");
664
+ }
665
+
666
+ attributeChangedCallback(name, oldValue, newValue) {
667
+ if (oldValue === newValue) return;
668
+ const state = getSolidRenderState(this);
669
+ if (!state.connected) return;
670
+ void remountSolidRender(this, `identity:${name}`);
671
+ }
672
+
673
+ get props() {
674
+ return getSolidRenderState(this).programmaticProps;
675
+ }
676
+
677
+ set props(value) {
678
+ const state = getSolidRenderState(this);
679
+ state.programmaticProps = value && typeof value === "object" ? value : {};
680
+ updateSolidRenderProps(this, state);
681
+ }
682
+ }
683
+
684
+ registry.define("solid-render", SolidRenderElement);
685
+ solidRenderClasses.set(registry, SolidRenderElement);
686
+ return SolidRenderElement;
687
+ }
688
+
689
+ function getSolidRenderState(element) {
690
+ let state = solidRenderInternals.get(element);
691
+ if (!state) {
692
+ state = {
693
+ connected: false,
694
+ generation: 0,
695
+ captured: false,
696
+ childrenTemplate: undefined,
697
+ programmaticProps: {},
698
+ mount: undefined,
699
+ controller: undefined,
700
+ propObserver: undefined,
701
+ };
702
+ solidRenderInternals.set(element, state);
703
+ }
704
+ return state;
705
+ }
706
+
707
+ function captureSolidRenderChildren(element, state) {
708
+ if (state.captured) return;
709
+ state.captured = true;
710
+ const document = element.ownerDocument ?? globalThis.document;
711
+ if (!document || typeof document.createDocumentFragment !== "function") return;
712
+ const template = document.createDocumentFragment();
713
+ for (const node of Array.from(element.childNodes ?? [])) {
714
+ template.appendChild?.(node.cloneNode ? node.cloneNode(true) : node);
715
+ }
716
+ state.childrenTemplate = template;
717
+ element.replaceChildren?.();
718
+ }
719
+
720
+ function startSolidRenderPropObserver(element, state) {
721
+ if (state.propObserver) return;
722
+ let controller;
723
+ try {
724
+ controller = resolveHTMLControllerForElement(element);
725
+ } catch {
726
+ controller = undefined;
727
+ }
728
+ const MutationObserverImpl = controller?.MutationObserver ?? globalThis.MutationObserver;
729
+ if (typeof MutationObserverImpl !== "function") return;
730
+
731
+ const observer = new MutationObserverImpl(records => {
732
+ if (!Array.from(records ?? []).some(record => String(record?.attributeName ?? "").startsWith("prop:"))) return;
733
+ updateSolidRenderProps(element, state);
734
+ });
735
+ observer.observe(element, { attributes: true });
736
+ state.propObserver = observer;
737
+ }
738
+
739
+ async function remountSolidRender(element, reason) {
740
+ const state = getSolidRenderState(element);
741
+ if (!state.connected) return;
742
+ const generation = ++state.generation;
743
+ await disposeSolidRenderMount(element, state, reason === "connect" ? "reconnect" : reason);
744
+ if (!state.connected || generation !== state.generation) return;
745
+
746
+ let controller;
747
+ try {
748
+ controller = resolveHTMLControllerForElement(element);
749
+ state.controller = controller;
750
+ controller.renderElements.add(element);
751
+
752
+ const moduleId = nonEmpty(element.getAttribute?.("module"));
753
+ if (!moduleId) {
754
+ throw new HTMLRenderError("<solid-render> requires a `module` attribute.", { element });
755
+ }
756
+ const componentName = nonEmpty(element.getAttribute?.("component"));
757
+ const exportName = componentName ?? "default";
758
+ const props = buildSolidRenderProps(element, state);
759
+
760
+ emitHTML(controller, {
761
+ type: "render-mounting",
762
+ element,
763
+ moduleId,
764
+ component: exportName,
765
+ mode: "container",
766
+ origin: "solid-render",
767
+ source: "solid-render",
768
+ });
769
+
770
+ const namespace = await controller.runtime.import(moduleId);
771
+ if (!state.connected || generation !== state.generation) return;
772
+ const { Component } = resolveRenderComponent(namespace, componentName, {
773
+ moduleId,
774
+ element,
775
+ });
776
+
777
+ element.replaceChildren?.();
778
+ const mount = await controller.renderAdapter.mountContainer({
779
+ runtime: controller.runtime,
780
+ target: element,
781
+ Component,
782
+ props,
783
+ element,
784
+ moduleId,
785
+ });
786
+ if (!state.connected || generation !== state.generation) {
787
+ await mount.dispose?.();
788
+ return;
789
+ }
790
+
791
+ mount.mode = "container";
792
+ state.mount = mount;
793
+ emitHTML(controller, {
794
+ type: "render-mounted",
795
+ element,
796
+ moduleId,
797
+ component: exportName,
798
+ mode: "container",
799
+ target: element,
800
+ origin: "solid-render",
801
+ source: "solid-render",
802
+ });
803
+ } catch (error) {
804
+ // Identity changes or disconnects invalidate in-flight work. A stale
805
+ // failure belongs to the abandoned generation just like a stale success,
806
+ // so it must not overwrite/report against the current element state.
807
+ if (!state.connected || generation !== state.generation) return;
808
+ if (controller) {
809
+ emitHTMLError(controller, "solid-render", error, {
810
+ element,
811
+ moduleId: nonEmpty(element.getAttribute?.("module")),
812
+ origin: "solid-render",
813
+ });
814
+ }
815
+ dispatchSolidRenderError(element, error);
816
+ }
817
+ }
818
+
819
+ async function disposeSolidRenderMount(element, state, reason = "dispose") {
820
+ const mount = state.mount;
821
+ const controller = state.controller;
822
+ if (!mount) {
823
+ controller?.renderElements?.delete(element);
824
+ state.controller = undefined;
825
+ return;
826
+ }
827
+
828
+ const moduleId = nonEmpty(element.getAttribute?.("module"));
829
+ const component = nonEmpty(element.getAttribute?.("component")) ?? "default";
830
+ if (controller) {
831
+ emitHTML(controller, {
832
+ type: "render-disposing",
833
+ element,
834
+ moduleId,
835
+ component,
836
+ mode: "container",
837
+ reason,
838
+ source: "solid-render",
839
+ origin: "solid-render",
840
+ });
841
+ }
842
+
843
+ try {
844
+ await mount.dispose?.();
845
+ } finally {
846
+ state.mount = undefined;
847
+ element.replaceChildren?.();
848
+ controller?.renderElements?.delete(element);
849
+ state.controller = undefined;
850
+ }
851
+
852
+ if (controller) {
853
+ emitHTML(controller, {
854
+ type: "render-disposed",
855
+ element,
856
+ moduleId,
857
+ component,
858
+ mode: "container",
859
+ reason,
860
+ source: "solid-render",
861
+ origin: "solid-render",
862
+ });
863
+ }
864
+ }
865
+
866
+ function updateSolidRenderProps(element, state) {
867
+ if (!state.mount?.updateProps) return;
868
+ state.mount.updateProps(buildSolidRenderProps(element, state));
869
+ }
870
+
871
+ function buildSolidRenderProps(element, state) {
872
+ const declarative = {};
873
+ for (const attribute of Array.from(element.attributes ?? [])) {
874
+ const name = String(attribute?.name ?? "").toLowerCase();
875
+ if (!name.startsWith("prop:")) continue;
876
+ const key = kebabToCamel(name.slice(5));
877
+ if (!key) continue;
878
+ declarative[key] = attribute.value === "" ? true : attribute.value;
879
+ }
880
+
881
+ if (state.childrenTemplate && !("children" in (state.programmaticProps ?? {}))) {
882
+ Object.defineProperty(declarative, "children", {
883
+ configurable: true,
884
+ enumerable: true,
885
+ get() {
886
+ return state.childrenTemplate.cloneNode?.(true) ?? state.childrenTemplate;
887
+ },
888
+ });
889
+ }
890
+
891
+ const merged = Object.create(null);
892
+ for (const key of Reflect.ownKeys(declarative)) {
893
+ const descriptor = Object.getOwnPropertyDescriptor(declarative, key);
894
+ if (descriptor) Object.defineProperty(merged, key, descriptor);
895
+ }
896
+ for (const key of Reflect.ownKeys(state.programmaticProps ?? {})) {
897
+ merged[key] = state.programmaticProps[key];
898
+ }
899
+ return merged;
900
+ }
901
+
902
+ function kebabToCamel(value) {
903
+ return String(value).replace(/-([a-z0-9])/g, (_match, character) => character.toUpperCase());
904
+ }
905
+
906
+ function resolveHTMLControllerForElement(element) {
907
+ const scope = nonEmpty(element.getAttribute?.(RUNTIME_SCOPE_ATTRIBUTE));
908
+ const live = [...activeControllers].filter(controller => !controller.disposed);
909
+ let candidates = scope
910
+ ? live.filter(controller => controller.scope === scope)
911
+ : live.filter(controller => !controller.scope);
912
+
913
+ if (!scope && candidates.length === 0 && live.length === 1) candidates = live;
914
+ const contained = candidates.filter(controller => rootContainsElement(controller.root ?? globalThis.document, element));
915
+ if (contained.length === 1) return contained[0];
916
+ if (contained.length > 1) {
917
+ const specific = mostSpecificController(contained);
918
+ if (specific) return specific;
919
+ }
920
+ if (candidates.length === 1) return candidates[0];
921
+
922
+ if (candidates.length === 0) {
923
+ throw new HTMLRenderError(
924
+ scope
925
+ ? `No HTML runtime is registered for data-solid-runtime=${JSON.stringify(scope)}.`
926
+ : "No unambiguous HTML runtime is available for <solid-render>. Add data-solid-runtime when multiple runtimes exist.",
927
+ { element },
928
+ );
929
+ }
930
+
931
+ throw new HTMLRenderError(
932
+ `Multiple HTML runtimes match <solid-render>${scope ? ` for scope ${JSON.stringify(scope)}` : ""}. Use distinct scopes or DOM roots.`,
933
+ { element },
934
+ );
935
+ }
936
+
937
+ function rootContainsElement(root, element) {
938
+ if (!root || !element) return false;
939
+ if (root === element) return true;
940
+ if (typeof root.contains === "function") return root.contains(element);
941
+ let node = element.parentNode;
942
+ while (node) {
943
+ if (node === root) return true;
944
+ node = node.parentNode;
945
+ }
946
+ return false;
947
+ }
948
+
949
+ function mostSpecificController(controllers) {
950
+ for (const candidate of controllers) {
951
+ const root = candidate.root ?? globalThis.document;
952
+ if (controllers.every(other => {
953
+ if (other === candidate) return true;
954
+ const otherRoot = other.root ?? globalThis.document;
955
+ return !rootContainsElement(root, otherRoot);
956
+ })) return candidate;
957
+ }
958
+ return undefined;
959
+ }
960
+
961
+ function dispatchSolidRenderError(element, error) {
962
+ try {
963
+ const EventImpl = globalThis.CustomEvent;
964
+ if (typeof element?.dispatchEvent === "function" && typeof EventImpl === "function") {
965
+ element.dispatchEvent(new EventImpl("solid-render-error", {
966
+ detail: { error },
967
+ bubbles: false,
968
+ composed: false,
969
+ }));
970
+ }
971
+ } catch {
972
+ // Error reporting must not create a second failure.
973
+ }
974
+ globalThis.console?.error?.("solid-tag-runtime <solid-render> error", error);
975
+ }
976
+
977
+ async function disposeController(controller, options = {}) {
978
+ if (controller.disposed) return;
979
+ controller.disposed = true;
980
+ stopObserver(controller, { reason: options.runtimeDisposed ? "runtime-disposed" : "controller-disposed" });
981
+
982
+ for (const record of controller.bindings.values()) {
983
+ if (record.mount) {
984
+ await disposeRecordMount(controller, record, {
985
+ reason: options.runtimeDisposed ? "runtime-disposed" : "controller-disposed",
986
+ removeAnchors: record.render === true,
987
+ restoreElement: record.render === true,
988
+ });
989
+ }
990
+ }
991
+
992
+ for (const element of [...controller.renderElements]) {
993
+ const state = solidRenderInternals.get(element);
994
+ if (!state || state.controller !== controller) continue;
995
+ state.generation += 1;
996
+ await disposeSolidRenderMount(element, state, options.runtimeDisposed ? "runtime-disposed" : "controller-disposed");
997
+ }
998
+
999
+ activeControllers.delete(controller);
1000
+ controller.runtimeDisposeUnsubscribe?.();
1001
+ controller.runtimeDisposeUnsubscribe = undefined;
1002
+ controller.events.clear();
1003
+ }
1004
+
468
1005
  async function connectObserver(controller, root, callOptions = {}, mode = {}) {
469
1006
  const MutationObserverImpl = callOptions.MutationObserver ?? controller.MutationObserver ?? globalThis.MutationObserver;
470
1007
 
@@ -497,6 +1034,13 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
497
1034
  childList: true,
498
1035
  subtree: callOptions.subtree ?? controller.subtree,
499
1036
  });
1037
+ emitHTML(controller, {
1038
+ type: "observer-connected",
1039
+ root,
1040
+ subtree: callOptions.subtree ?? controller.subtree,
1041
+ registerExisting,
1042
+ reason: mode.reason ?? (mode.initial ? "observe" : "connect"),
1043
+ });
500
1044
 
501
1045
  const result = registerExisting
502
1046
  ? await enqueueObserverScan(controller, {
@@ -511,11 +1055,17 @@ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
511
1055
 
512
1056
  function stopObserver(controller, options = {}) {
513
1057
  if (!controller.connected && !controller.observer) return;
1058
+ const root = controller.observerRoot;
514
1059
  controller.connected = false;
515
1060
  controller.observer?.disconnect();
516
1061
  controller.observer = undefined;
517
1062
  controller.observerRoot = undefined;
518
1063
  if (!options.preserveOptions) controller.observerOptions = undefined;
1064
+ emitHTML(controller, {
1065
+ type: "observer-disconnected",
1066
+ root,
1067
+ reason: options.reason ?? "disconnect",
1068
+ });
519
1069
  }
520
1070
 
521
1071
  async function enqueueObserverScan(controller, callOptions = {}) {
@@ -524,23 +1074,79 @@ async function enqueueObserverScan(controller, callOptions = {}) {
524
1074
 
525
1075
  const root = controller.observerRoot ?? getRoot(mergeOptions(controller, callOptions), "HTML runtime observer");
526
1076
  const selector = callOptions.selector ?? controller.selector;
527
- const elements = Array.from(root.querySelectorAll(selector)).filter(element => {
528
- if (controller.ignoredExisting.has(element)) return false;
529
- if (!acceptsDiscoveredElement(controller, element, callOptions)) return false;
1077
+ const origin = callOptions.origin ?? "observer";
1078
+ const discovered = Array.from(root.querySelectorAll(selector));
1079
+ const elements = [];
1080
+ let matched = 0;
1081
+ let ignored = 0;
1082
+
1083
+ for (const element of discovered) {
1084
+ if (controller.ignoredExisting.has(element)) {
1085
+ ignored += 1;
1086
+ emitHTML(controller, {
1087
+ type: "element-discovered",
1088
+ element,
1089
+ moduleId: nonEmpty(element?.getAttribute?.("module")),
1090
+ origin,
1091
+ accepted: false,
1092
+ });
1093
+ continue;
1094
+ }
1095
+
1096
+ const accepted = acceptsDiscoveredElement(controller, element, callOptions);
1097
+ emitHTML(controller, {
1098
+ type: "element-discovered",
1099
+ element,
1100
+ moduleId: nonEmpty(element?.getAttribute?.("module")),
1101
+ origin,
1102
+ accepted,
1103
+ });
1104
+ if (!accepted) {
1105
+ ignored += 1;
1106
+ continue;
1107
+ }
1108
+ matched += 1;
530
1109
 
531
1110
  const ownership = elementOwners.get(element);
532
- if (!ownership) return true;
533
- if (ownership.owner === controller) return false;
1111
+ if (!ownership) {
1112
+ elements.push(element);
1113
+ continue;
1114
+ }
1115
+ if (ownership.owner === controller) {
1116
+ ignored += 1;
1117
+ continue;
1118
+ }
534
1119
 
535
1120
  throw ownershipError(controller, element, ownership);
536
- });
1121
+ }
537
1122
 
538
- return processElements(controller, elements, {
539
- ...callOptions,
1123
+ let result;
1124
+ try {
1125
+ result = await processElements(controller, elements, {
1126
+ ...callOptions,
1127
+ root,
1128
+ origin,
1129
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
1130
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
1131
+ });
1132
+ } catch (error) {
1133
+ emitHTMLError(controller, "observer", error, { origin });
1134
+ throw error;
1135
+ }
1136
+
1137
+ await cleanupDetachedRenderedDeclarations(controller, root);
1138
+
1139
+ emitHTML(controller, {
1140
+ type: "observer-batch",
540
1141
  root,
541
- origin: callOptions.origin ?? "observer",
542
- executeEntries: callOptions.executeEntries ?? controller.executeEntries,
1142
+ origin,
1143
+ discovered: discovered.length,
1144
+ matched,
1145
+ claimed: result.modules.length,
1146
+ ignored,
1147
+ modules: result.modules.map(module => module.id),
543
1148
  });
1149
+ return result;
544
1150
  });
545
1151
 
546
1152
  controller.queue = task.then(
@@ -587,6 +1193,12 @@ async function processClaims(controller, records, options = {}) {
587
1193
  try {
588
1194
  prepared = await Promise.all(records.map(record => prepareRecordSource(controller, record, options)));
589
1195
  } catch (error) {
1196
+ const failed = records.find(record => record.state === "loading") ?? records[0];
1197
+ emitHTMLError(controller, failed?.sourceUrl ? "fetch" : "register", error, {
1198
+ element: failed?.element,
1199
+ moduleId: failed?.moduleId,
1200
+ origin: failed?.origin ?? options.origin,
1201
+ });
590
1202
  for (const record of records) rejectRecord(record, error);
591
1203
  throw error;
592
1204
  }
@@ -604,8 +1216,21 @@ async function processClaims(controller, records, options = {}) {
604
1216
  record.definition = definitionFromRecord(record);
605
1217
  record.resolve(record.definition);
606
1218
  modules.push(record.definition);
1219
+ emitHTML(controller, {
1220
+ type: "element-registered",
1221
+ element: record.element,
1222
+ moduleId: record.moduleId,
1223
+ definition: record.definition,
1224
+ origin: record.origin,
1225
+ });
607
1226
  }
608
1227
  } catch (error) {
1228
+ const failed = records.find(record => record.state === "claimed" || record.state === "loading") ?? records[0];
1229
+ emitHTMLError(controller, "register", error, {
1230
+ element: failed?.element,
1231
+ moduleId: failed?.moduleId,
1232
+ origin: failed?.origin ?? options.origin,
1233
+ });
609
1234
  for (const record of records) {
610
1235
  if (record.state === "claimed" || record.state === "loading") rejectRecord(record, error);
611
1236
  }
@@ -613,34 +1238,324 @@ async function processClaims(controller, records, options = {}) {
613
1238
  }
614
1239
 
615
1240
  const entries = modules.filter(module => module.entry);
1241
+ const renders = modules.filter(module => module.render !== undefined);
616
1242
  const executed = [];
1243
+ const rendered = [];
617
1244
 
618
- if (options.executeEntries !== false) {
619
- for (const entry of entries) {
620
- const record = elementOwners.get(entry.element);
621
- if (record) record.state = "executing";
1245
+ // Every module in the batch is defined before any side-effect entry or render
1246
+ // action executes. This keeps document ordering independent from imports.
1247
+ for (const module of modules) {
1248
+ const record = elementOwners.get(module.element);
1249
+ if (!record) continue;
622
1250
 
623
- try {
624
- const namespace = await controller.runtime.import(entry.id);
625
- if (record) record.state = "ready";
626
- executed.push({ id: entry.id, namespace });
627
- } catch (error) {
628
- if (record) {
629
- record.state = "error";
630
- record.error = error;
631
- }
632
- throw error;
633
- }
1251
+ if (record.entry && options.executeEntries !== false) {
1252
+ executed.push(await executeEntryRecord(controller, record, options));
1253
+ continue;
1254
+ }
1255
+
1256
+ if (record.render !== undefined && options.executeRenders !== false) {
1257
+ rendered.push(await renderDeclarationRecord(controller, record, options));
634
1258
  }
635
1259
  }
636
1260
 
1261
+ registerSolidRenderElement();
637
1262
  return {
638
1263
  modules,
639
1264
  entries: entries.map(entry => entry.id),
640
1265
  executed,
1266
+ renders: renders.map(render => render.id),
1267
+ rendered,
641
1268
  };
642
1269
  }
643
1270
 
1271
+
1272
+ async function executeEntryRecord(controller, record, options = {}) {
1273
+ record.state = "executing";
1274
+ const origin = record.origin ?? options.origin ?? "entry";
1275
+ try {
1276
+ emitHTML(controller, {
1277
+ type: "entry-executing",
1278
+ element: record.element,
1279
+ moduleId: record.moduleId,
1280
+ origin,
1281
+ });
1282
+ const namespace = await controller.runtime.import(record.moduleId);
1283
+ record.state = "ready";
1284
+ emitHTML(controller, {
1285
+ type: "entry-executed",
1286
+ element: record.element,
1287
+ moduleId: record.moduleId,
1288
+ namespace,
1289
+ origin,
1290
+ });
1291
+ return { id: record.moduleId, namespace };
1292
+ } catch (error) {
1293
+ record.state = "error";
1294
+ record.error = error;
1295
+ emitHTMLError(controller, "entry", error, {
1296
+ element: record.element,
1297
+ moduleId: record.moduleId,
1298
+ origin,
1299
+ });
1300
+ throw error;
1301
+ }
1302
+ }
1303
+
1304
+ async function renderDeclarationRecord(controller, record, options = {}) {
1305
+ const origin = record.origin ?? options.origin ?? "render";
1306
+ const mode = record.render === true ? "in-place" : "selector";
1307
+ const exportName = record.component ?? "default";
1308
+
1309
+ emitHTML(controller, {
1310
+ type: "render-mounting",
1311
+ element: record.element,
1312
+ moduleId: record.moduleId,
1313
+ component: exportName,
1314
+ mode,
1315
+ origin,
1316
+ source: "script-render",
1317
+ });
1318
+
1319
+ try {
1320
+ if (record.mount) {
1321
+ await disposeRecordMount(controller, record, {
1322
+ reason: "remount",
1323
+ removeAnchors: false,
1324
+ });
1325
+ }
1326
+
1327
+ const namespace = await controller.runtime.import(record.moduleId);
1328
+ const { Component } = resolveRenderComponent(namespace, record.component, {
1329
+ moduleId: record.moduleId,
1330
+ element: record.element,
1331
+ });
1332
+
1333
+ let mount;
1334
+ if (mode === "selector") {
1335
+ const target = resolveRenderTarget(controller, record.element, record.render, options);
1336
+ mount = await controller.renderAdapter.mountContainer({
1337
+ runtime: controller.runtime,
1338
+ target,
1339
+ Component,
1340
+ props: {},
1341
+ element: record.element,
1342
+ moduleId: record.moduleId,
1343
+ });
1344
+ mount.mode = "selector";
1345
+ mount.renderTarget = record.render;
1346
+ mount.target = target;
1347
+ } else {
1348
+ const range = ensureInPlaceRange(record);
1349
+ mount = await controller.renderAdapter.mountRange({
1350
+ runtime: controller.runtime,
1351
+ parent: range.parent,
1352
+ before: range.endMarker,
1353
+ startMarker: range.startMarker,
1354
+ endMarker: range.endMarker,
1355
+ Component,
1356
+ props: {},
1357
+ element: record.element,
1358
+ moduleId: record.moduleId,
1359
+ });
1360
+ mount.mode = "in-place";
1361
+ mount.range = range;
1362
+ }
1363
+
1364
+ record.mount = mount;
1365
+ record.state = "ready";
1366
+ const result = {
1367
+ id: record.moduleId,
1368
+ namespace,
1369
+ component: exportName,
1370
+ mode,
1371
+ target: mount.target,
1372
+ element: record.element,
1373
+ };
1374
+ emitHTML(controller, {
1375
+ type: "render-mounted",
1376
+ element: record.element,
1377
+ moduleId: record.moduleId,
1378
+ component: exportName,
1379
+ mode,
1380
+ target: mount.target,
1381
+ origin,
1382
+ source: "script-render",
1383
+ });
1384
+ return result;
1385
+ } catch (error) {
1386
+ record.state = "error";
1387
+ record.error = error;
1388
+ emitHTMLError(controller, "render", error, {
1389
+ element: record.element,
1390
+ moduleId: record.moduleId,
1391
+ origin,
1392
+ });
1393
+ throw error;
1394
+ }
1395
+ }
1396
+
1397
+ async function disposeRecordMount(controller, record, options = {}) {
1398
+ const mount = record?.mount;
1399
+ if (!mount) return false;
1400
+ const mode = mount.mode ?? (record.render === true ? "in-place" : "selector");
1401
+ emitHTML(controller, {
1402
+ type: "render-disposing",
1403
+ element: record.element,
1404
+ moduleId: record.moduleId,
1405
+ component: record.component ?? "default",
1406
+ mode,
1407
+ reason: options.reason ?? "dispose",
1408
+ source: "script-render",
1409
+ origin: record.origin ?? "render",
1410
+ });
1411
+
1412
+ try {
1413
+ await mount.dispose?.();
1414
+ } finally {
1415
+ if (mode === "in-place" && options.removeAnchors !== false) {
1416
+ removeRangeAnchors(record, { restoreElement: options.restoreElement === true });
1417
+ }
1418
+ record.mount = undefined;
1419
+ }
1420
+
1421
+ emitHTML(controller, {
1422
+ type: "render-disposed",
1423
+ element: record.element,
1424
+ moduleId: record.moduleId,
1425
+ component: record.component ?? "default",
1426
+ mode,
1427
+ reason: options.reason ?? "dispose",
1428
+ source: "script-render",
1429
+ origin: record.origin ?? "render",
1430
+ });
1431
+ return true;
1432
+ }
1433
+
1434
+ function resolveRenderTarget(controller, element, selector, options = {}) {
1435
+ if (typeof selector !== "string" || !selector) {
1436
+ throw new HTMLRenderError("Selector rendering requires a non-empty render selector.", {
1437
+ moduleId: elementOwners.get(element)?.moduleId,
1438
+ element,
1439
+ });
1440
+ }
1441
+
1442
+ const root = options.renderRoot
1443
+ ?? options.root
1444
+ ?? controller.root
1445
+ ?? element.ownerDocument
1446
+ ?? globalThis.document;
1447
+
1448
+ if (!root || typeof root.querySelectorAll !== "function") {
1449
+ throw new HTMLRenderError(`Cannot resolve render target ${JSON.stringify(selector)} without a queryable DOM root.`, {
1450
+ moduleId: elementOwners.get(element)?.moduleId,
1451
+ element,
1452
+ });
1453
+ }
1454
+
1455
+ let target;
1456
+ try {
1457
+ if (typeof root.querySelector === "function") target = root.querySelector(selector);
1458
+ else target = Array.from(root.querySelectorAll(selector))[0];
1459
+ } catch (cause) {
1460
+ throw new HTMLRenderError(`Invalid render selector ${JSON.stringify(selector)}.`, {
1461
+ cause,
1462
+ moduleId: elementOwners.get(element)?.moduleId,
1463
+ element,
1464
+ });
1465
+ }
1466
+
1467
+ if (!target) {
1468
+ throw new HTMLRenderError(`Render target ${JSON.stringify(selector)} was not found.`, {
1469
+ moduleId: elementOwners.get(element)?.moduleId,
1470
+ element,
1471
+ });
1472
+ }
1473
+ return target;
1474
+ }
1475
+
1476
+ function ensureInPlaceRange(record) {
1477
+ if (record.range?.startMarker?.parentNode && record.range?.endMarker?.parentNode) {
1478
+ return record.range;
1479
+ }
1480
+
1481
+ const element = record.element;
1482
+ const parent = element?.parentNode;
1483
+ if (!parent) {
1484
+ throw new HTMLRenderError("In-place rendering requires the declaration script to have a parent node.", {
1485
+ moduleId: record.moduleId,
1486
+ element,
1487
+ });
1488
+ }
1489
+
1490
+ const document = element.ownerDocument ?? globalThis.document;
1491
+ if (!document || typeof document.createComment !== "function") {
1492
+ throw new HTMLRenderError("In-place rendering requires document.createComment().", {
1493
+ moduleId: record.moduleId,
1494
+ element,
1495
+ });
1496
+ }
1497
+
1498
+ const startMarker = document.createComment("solid-runtime:start");
1499
+ const endMarker = document.createComment("solid-runtime:end");
1500
+
1501
+ if (typeof element.replaceWith === "function") {
1502
+ element.replaceWith(startMarker, endMarker);
1503
+ } else if (typeof parent.insertBefore === "function" && typeof parent.removeChild === "function") {
1504
+ parent.insertBefore(startMarker, element);
1505
+ parent.insertBefore(endMarker, element);
1506
+ parent.removeChild(element);
1507
+ } else {
1508
+ throw new HTMLRenderError("In-place rendering requires replaceWith() or insertBefore()/removeChild() support.", {
1509
+ moduleId: record.moduleId,
1510
+ element,
1511
+ });
1512
+ }
1513
+
1514
+ record.range = { parent, startMarker, endMarker };
1515
+ return record.range;
1516
+ }
1517
+
1518
+ function removeRangeAnchors(record, options = {}) {
1519
+ const range = record.range;
1520
+ if (!range) return;
1521
+ const { parent, startMarker, endMarker } = range;
1522
+
1523
+ if (options.restoreElement && parent && startMarker?.parentNode === parent) {
1524
+ if (typeof parent.insertBefore === "function") parent.insertBefore(record.element, startMarker);
1525
+ }
1526
+
1527
+ clearNodesBetween(startMarker, endMarker);
1528
+ startMarker?.parentNode?.removeChild?.(startMarker);
1529
+ endMarker?.parentNode?.removeChild?.(endMarker);
1530
+ record.range = undefined;
1531
+ }
1532
+
1533
+ function clearNodesBetween(start, end) {
1534
+ if (!start || !end) return;
1535
+ let node = start.nextSibling;
1536
+ while (node && node !== end) {
1537
+ const next = node.nextSibling;
1538
+ node.parentNode?.removeChild?.(node);
1539
+ node = next;
1540
+ }
1541
+ }
1542
+
1543
+ async function cleanupDetachedRenderedDeclarations(controller, root) {
1544
+ if (!root || typeof root.contains !== "function") return;
1545
+ for (const record of controller.bindings.values()) {
1546
+ if (!record.mount) continue;
1547
+ const anchor = record.mount.mode === "in-place"
1548
+ ? record.range?.startMarker
1549
+ : record.element;
1550
+ if (anchor && !root.contains(anchor)) {
1551
+ await disposeRecordMount(controller, record, {
1552
+ reason: "declaration-detached",
1553
+ removeAnchors: false,
1554
+ });
1555
+ }
1556
+ }
1557
+ }
1558
+
644
1559
  function claimElement(controller, element, options = {}) {
645
1560
  assertElement(element, options.origin ?? "HTML runtime");
646
1561
 
@@ -688,8 +1603,12 @@ function claimElement(controller, element, options = {}) {
688
1603
  moduleId,
689
1604
  format: metadata.format,
690
1605
  entry: metadata.entry,
1606
+ render: metadata.render,
1607
+ component: metadata.component,
691
1608
  sourceUrl: metadata.sourceUrl,
692
1609
  inline: !metadata.sourceUrl,
1610
+ mount: undefined,
1611
+ range: undefined,
693
1612
  state: "claimed",
694
1613
  origin: options.origin ?? "unknown",
695
1614
  promise,
@@ -701,6 +1620,17 @@ function claimElement(controller, element, options = {}) {
701
1620
 
702
1621
  elementOwners.set(element, record);
703
1622
  controller.bindings.set(moduleId, record);
1623
+ emitHTML(controller, {
1624
+ type: "element-claimed",
1625
+ element,
1626
+ moduleId,
1627
+ format: record.format,
1628
+ entry: record.entry,
1629
+ render: record.render,
1630
+ component: record.component,
1631
+ sourceUrl: record.sourceUrl,
1632
+ origin: record.origin,
1633
+ });
704
1634
  return { fresh: true, record };
705
1635
  }
706
1636
 
@@ -709,12 +1639,29 @@ async function prepareRecordSource(controller, record, options) {
709
1639
 
710
1640
  let source;
711
1641
  if (record.sourceUrl) {
1642
+ const startedAt = performanceNow();
1643
+ emitHTML(controller, {
1644
+ type: "element-loading",
1645
+ element: record.element,
1646
+ moduleId: record.moduleId,
1647
+ sourceUrl: record.sourceUrl,
1648
+ origin: record.origin,
1649
+ });
712
1650
  source = await fetchSource(
713
1651
  record.sourceUrl,
714
1652
  options.fetch ?? controller.fetch ?? globalThis.fetch,
715
1653
  record.element,
716
1654
  record.moduleId,
717
1655
  );
1656
+ emitHTML(controller, {
1657
+ type: "element-loaded",
1658
+ element: record.element,
1659
+ moduleId: record.moduleId,
1660
+ sourceUrl: record.sourceUrl,
1661
+ origin: record.origin,
1662
+ bytes: String(source).length,
1663
+ durationMs: performanceNow() - startedAt,
1664
+ });
718
1665
  } else {
719
1666
  source = record.element.textContent ?? "";
720
1667
  }
@@ -725,6 +1672,14 @@ async function prepareRecordSource(controller, record, options) {
725
1672
  function inspectElement(controller, element, options = {}) {
726
1673
  const type = normalizeType(element.getAttribute("type"));
727
1674
  const entry = hasAttribute(element, "entry");
1675
+ const render = inspectRenderAttribute(element);
1676
+ const component = nonEmpty(element.getAttribute("component"));
1677
+ if (entry && render !== undefined) {
1678
+ throw new HTMLModuleError(
1679
+ "Runtime script declarations cannot combine `entry` and `render`. Use separate declarations for side effects and UI mounting.",
1680
+ { element },
1681
+ );
1682
+ }
728
1683
  const srcAttribute = nonEmpty(element.getAttribute("src"));
729
1684
  const explicitId = nonEmpty(element.getAttribute("module"));
730
1685
  const baseUrl = options.baseUrl
@@ -742,15 +1697,16 @@ function inspectElement(controller, element, options = {}) {
742
1697
  sourceUrl,
743
1698
  });
744
1699
 
745
- const id = explicitId ?? sourceUrl ?? (entry ? options.existingId ?? createAnonymousId(format) : undefined);
1700
+ const action = entry ? "entry" : render !== undefined ? "render" : undefined;
1701
+ const id = explicitId ?? sourceUrl ?? (action ? options.existingId ?? createAnonymousId(format, action) : undefined);
746
1702
  if (!id) {
747
1703
  throw new HTMLModuleError(
748
- "Inline solid runtime module scripts require a `module` attribute unless they are marked `entry`.",
1704
+ "Inline solid runtime module scripts require a `module` attribute unless they are marked `entry` or `render`.",
749
1705
  { element },
750
1706
  );
751
1707
  }
752
1708
 
753
- return { id, format, entry, sourceUrl };
1709
+ return { id, format, entry, render, component, sourceUrl };
754
1710
  }
755
1711
 
756
1712
  function requireOwnedRecord(controller, element, caller) {
@@ -787,6 +1743,8 @@ function definitionFromRecord(record) {
787
1743
  id: record.moduleId,
788
1744
  format: record.format,
789
1745
  entry: record.entry,
1746
+ render: record.render,
1747
+ component: record.component,
790
1748
  sourceUrl: record.sourceUrl,
791
1749
  inline: record.inline,
792
1750
  element: record.element,
@@ -863,6 +1821,46 @@ function ownershipError(controller, element, ownership) {
863
1821
  );
864
1822
  }
865
1823
 
1824
+ function emitHTML(controller, event) {
1825
+ controller.events.emit({
1826
+ htmlRuntimeId: controller.id,
1827
+ scope: controller.scope,
1828
+ timestamp: Date.now(),
1829
+ ...event,
1830
+ });
1831
+ }
1832
+
1833
+ function emitHTMLError(controller, operation, error, context = {}) {
1834
+ emitHTML(controller, {
1835
+ type: "html-error",
1836
+ operation,
1837
+ error,
1838
+ ...context,
1839
+ });
1840
+ }
1841
+
1842
+ function emitRootChanged(controller, previousRoot, root, registerExisting, previousAppendTarget) {
1843
+ emitHTML(controller, {
1844
+ type: "root-changed",
1845
+ previousRoot,
1846
+ root,
1847
+ registerExisting,
1848
+ connected: controller.connected,
1849
+ });
1850
+ const nextAppendTarget = getAppendTarget(controller);
1851
+ if (previousAppendTarget !== nextAppendTarget) {
1852
+ emitHTML(controller, {
1853
+ type: "append-target-changed",
1854
+ previousTarget: previousAppendTarget,
1855
+ target: nextAppendTarget,
1856
+ });
1857
+ }
1858
+ }
1859
+
1860
+ function performanceNow() {
1861
+ return globalThis.performance?.now?.() ?? Date.now();
1862
+ }
1863
+
866
1864
  function getDefaultController(runtime, options = {}) {
867
1865
  assertRuntime(runtime);
868
1866
  let controller = defaultControllers.get(runtime);
@@ -871,6 +1869,7 @@ function getDefaultController(runtime, options = {}) {
871
1869
  root: options.root,
872
1870
  selector: options.selector,
873
1871
  executeEntries: options.executeEntries,
1872
+ executeRenders: options.executeRenders,
874
1873
  fetch: options.fetch,
875
1874
  baseUrl: options.baseUrl,
876
1875
  MutationObserver: options.MutationObserver,
@@ -923,6 +1922,7 @@ function mergeOptions(controller, options) {
923
1922
  root: options.root ?? controller.root,
924
1923
  selector: options.selector ?? controller.selector,
925
1924
  executeEntries: options.executeEntries ?? controller.executeEntries,
1925
+ executeRenders: options.executeRenders ?? controller.executeRenders,
926
1926
  fetch: options.fetch ?? controller.fetch,
927
1927
  baseUrl: options.baseUrl ?? controller.baseUrl,
928
1928
  MutationObserver: options.MutationObserver ?? controller.MutationObserver,
@@ -937,6 +1937,8 @@ function emptyRegistrationResult() {
937
1937
  modules: [],
938
1938
  entries: [],
939
1939
  executed: [],
1940
+ renders: [],
1941
+ rendered: [],
940
1942
  };
941
1943
  }
942
1944
 
@@ -1037,9 +2039,15 @@ function resolveSourceUrl(src, baseUrl) {
1037
2039
  }
1038
2040
  }
1039
2041
 
1040
- function createAnonymousId(format) {
2042
+ function createAnonymousId(format, kind = "entry") {
1041
2043
  const extension = format === "js" ? "js" : "jsx";
1042
- return `/__solid_tag_runtime__/html-entry-${++anonymousSequence}.${extension}`;
2044
+ return `/__solid_tag_runtime__/html-${kind}-${++anonymousSequence}.${extension}`;
2045
+ }
2046
+
2047
+ function inspectRenderAttribute(element) {
2048
+ if (!hasAttribute(element, "render")) return undefined;
2049
+ const value = element.getAttribute("render");
2050
+ return value == null || value === "" ? true : String(value);
1043
2051
  }
1044
2052
 
1045
2053
  function stripQueryAndHash(value) {