solid-tag-runtime 0.0.8 → 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,5 +1,6 @@
1
1
  import { SolidTagRuntimeError } from "./errors.js";
2
2
  import { createEventDispatcher } from "./events.js";
3
+ import { HTMLRenderError, createDefaultHTMLRenderAdapter, resolveRenderComponent } from "./render.js";
3
4
 
4
5
  const DEFAULT_SELECTOR = [
5
6
  'script[type="solid-jsx"]',
@@ -13,6 +14,9 @@ const DEFAULT_SELECTOR = [
13
14
  const RUNTIME_SCOPE_ATTRIBUTE = "data-solid-runtime";
14
15
  const elementOwners = new WeakMap();
15
16
  const defaultControllers = new WeakMap();
17
+ const activeControllers = new Set();
18
+ const solidRenderClasses = new WeakMap();
19
+ const solidRenderInternals = new WeakMap();
16
20
  let anonymousSequence = 0;
17
21
  let controllerSequence = 0;
18
22
 
@@ -35,6 +39,8 @@ export class HTMLModuleOwnershipError extends HTMLModuleError {
35
39
  }
36
40
  }
37
41
 
42
+ export { HTMLRenderError };
43
+
38
44
  /**
39
45
  * Create a persistent DOM/HTML adapter for one solid-tag-runtime instance.
40
46
  *
@@ -58,13 +64,17 @@ export function createHTMLRuntime(runtime, options = {}) {
58
64
  selector: options.selector ?? DEFAULT_SELECTOR,
59
65
  acceptUnscoped: options.acceptUnscoped !== false,
60
66
  executeEntries: options.executeEntries !== false,
67
+ executeRenders: options.executeRenders !== false,
61
68
  fetch: options.fetch,
62
69
  baseUrl: options.baseUrl,
63
70
  MutationObserver: options.MutationObserver,
64
71
  subtree: options.subtree !== false,
65
72
  createElement: options.createElement,
66
73
  appendTo: options.appendTo,
74
+ renderAdapter: options.renderAdapter ?? createDefaultHTMLRenderAdapter(runtime),
67
75
  bindings: new Map(),
76
+ renderElements: new Set(),
77
+ disposed: false,
68
78
  observer: undefined,
69
79
  observerRoot: undefined,
70
80
  observerOptions: undefined,
@@ -111,9 +121,18 @@ export function createHTMLRuntime(runtime, options = {}) {
111
121
  owns,
112
122
  getModuleId,
113
123
  getElement,
124
+ dispose,
114
125
  };
115
126
 
116
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
+ });
117
136
  return api;
118
137
 
119
138
  function subscribe(typesOrListener, maybeListener) {
@@ -141,12 +160,15 @@ export function createHTMLRuntime(runtime, options = {}) {
141
160
  }
142
161
 
143
162
  try {
144
- return await processElements(controller, elements, {
163
+ const result = await processElements(controller, elements, {
145
164
  ...callOptions,
146
165
  root,
147
166
  origin,
148
167
  executeEntries: callOptions.executeEntries ?? controller.executeEntries,
168
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
149
169
  });
170
+ registerSolidRenderElement();
171
+ return result;
150
172
  } catch (error) {
151
173
  emitHTMLError(controller, "register", error, { origin });
152
174
  throw error;
@@ -168,6 +190,7 @@ export function createHTMLRuntime(runtime, options = {}) {
168
190
  registerExisting: callOptions.registerExisting !== false,
169
191
  initial: true,
170
192
  });
193
+ registerSolidRenderElement();
171
194
  } catch (error) {
172
195
  disconnect();
173
196
  throw error;
@@ -194,6 +217,10 @@ export function createHTMLRuntime(runtime, options = {}) {
194
217
  stopObserver(controller);
195
218
  }
196
219
 
220
+ async function dispose() {
221
+ await disposeController(controller, { runtimeDisposed: false });
222
+ }
223
+
197
224
  /**
198
225
  * Change the discovery/observation root. If observation is active, it is
199
226
  * rebound to the new root and existing matching scripts are registered by
@@ -297,6 +324,7 @@ export function createHTMLRuntime(runtime, options = {}) {
297
324
  origin: callOptions.origin ?? "define-element",
298
325
  explicit: true,
299
326
  executeEntries: false,
327
+ executeRenders: false,
300
328
  });
301
329
  return result.modules[0] ?? definitionFromOwnedElement(controller, element);
302
330
  }
@@ -308,6 +336,7 @@ export function createHTMLRuntime(runtime, options = {}) {
308
336
  origin: callOptions.origin ?? "register-element",
309
337
  explicit: true,
310
338
  executeEntries: callOptions.executeEntries ?? controller.executeEntries,
339
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
311
340
  });
312
341
  return result.modules[0] ?? definitionFromOwnedElement(controller, element);
313
342
  }
@@ -342,15 +371,30 @@ export function createHTMLRuntime(runtime, options = {}) {
342
371
  ...record,
343
372
  format: metadata.format,
344
373
  entry: metadata.entry,
374
+ render: metadata.render,
375
+ component: metadata.component,
345
376
  sourceUrl: metadata.sourceUrl,
346
377
  inline: !metadata.sourceUrl,
347
378
  };
348
379
 
349
380
  const { source } = await prepareRecordSource(controller, candidate, callOptions);
381
+ const previousRender = record.render;
350
382
  runtime.update(record.moduleId, source, { format: candidate.format });
351
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
+
352
394
  record.format = candidate.format;
353
395
  record.entry = candidate.entry;
396
+ record.render = candidate.render;
397
+ record.component = candidate.component;
354
398
  record.sourceUrl = candidate.sourceUrl;
355
399
  record.inline = candidate.inline;
356
400
  record.state = "defined";
@@ -358,33 +402,15 @@ export function createHTMLRuntime(runtime, options = {}) {
358
402
  record.definition = definitionFromRecord(record);
359
403
 
360
404
  if (record.entry && (callOptions.executeEntries ?? controller.executeEntries) !== false) {
361
- record.state = "executing";
362
- emitHTML(controller, {
363
- type: "entry-executing",
364
- element,
365
- moduleId: record.moduleId,
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,
366
412
  origin: callOptions.origin ?? "update-element",
367
413
  });
368
- try {
369
- const namespace = await runtime.import(record.moduleId);
370
- record.state = "ready";
371
- emitHTML(controller, {
372
- type: "entry-executed",
373
- element,
374
- moduleId: record.moduleId,
375
- namespace,
376
- origin: callOptions.origin ?? "update-element",
377
- });
378
- } catch (error) {
379
- record.state = "error";
380
- record.error = error;
381
- emitHTMLError(controller, "entry", error, {
382
- element,
383
- moduleId: record.moduleId,
384
- origin: callOptions.origin ?? "update-element",
385
- });
386
- throw error;
387
- }
388
414
  }
389
415
 
390
416
  emitHTML(controller, {
@@ -417,6 +443,14 @@ export function createHTMLRuntime(runtime, options = {}) {
417
443
  const removeFromDOM = callOptions.removeFromDOM !== false;
418
444
  const invalidateDependents = callOptions.invalidateDependents !== false;
419
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
+
420
454
  let moduleRemoved = false;
421
455
  if (removeModule) {
422
456
  moduleRemoved = runtime.remove(record.moduleId, { invalidateDependents });
@@ -434,7 +468,7 @@ export function createHTMLRuntime(runtime, options = {}) {
434
468
 
435
469
  let elementRemoved = false;
436
470
  if (removeFromDOM) {
437
- elementRemoved = removeDOMElement(element);
471
+ elementRemoved = record.render === true ? true : removeDOMElement(element);
438
472
  } else {
439
473
  // Keep a released element in the observed tree from being immediately
440
474
  // rediscovered. Explicit registerElement() can still claim it again.
@@ -477,6 +511,7 @@ export function createHTMLRuntime(runtime, options = {}) {
477
511
  ...callOptions,
478
512
  origin: callOptions.origin ?? "append",
479
513
  executeEntries: callOptions.executeEntries ?? controller.executeEntries,
514
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
480
515
  });
481
516
 
482
517
  if (!claim.fresh) await claim.record.promise;
@@ -515,6 +550,9 @@ export function createHTMLRuntime(runtime, options = {}) {
515
550
  if (id) element.setAttribute("module", id);
516
551
  if (moduleOptions.src) element.setAttribute("src", String(moduleOptions.src));
517
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));
518
556
  if (moduleOptions.language) element.setAttribute("language", String(moduleOptions.language));
519
557
  if (moduleOptions.source != null) element.textContent = String(moduleOptions.source);
520
558
 
@@ -571,6 +609,399 @@ export async function defineScript(runtime, element, options = {}) {
571
609
  export const htmlModuleSelector = DEFAULT_SELECTOR;
572
610
  export const htmlRuntimeScopeAttribute = RUNTIME_SCOPE_ATTRIBUTE;
573
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
+
574
1005
  async function connectObserver(controller, root, callOptions = {}, mode = {}) {
575
1006
  const MutationObserverImpl = callOptions.MutationObserver ?? controller.MutationObserver ?? globalThis.MutationObserver;
576
1007
 
@@ -696,12 +1127,15 @@ async function enqueueObserverScan(controller, callOptions = {}) {
696
1127
  root,
697
1128
  origin,
698
1129
  executeEntries: callOptions.executeEntries ?? controller.executeEntries,
1130
+ executeRenders: callOptions.executeRenders ?? controller.executeRenders,
699
1131
  });
700
1132
  } catch (error) {
701
1133
  emitHTMLError(controller, "observer", error, { origin });
702
1134
  throw error;
703
1135
  }
704
1136
 
1137
+ await cleanupDetachedRenderedDeclarations(controller, root);
1138
+
705
1139
  emitHTML(controller, {
706
1140
  type: "observer-batch",
707
1141
  root,
@@ -804,52 +1238,324 @@ async function processClaims(controller, records, options = {}) {
804
1238
  }
805
1239
 
806
1240
  const entries = modules.filter(module => module.entry);
1241
+ const renders = modules.filter(module => module.render !== undefined);
807
1242
  const executed = [];
1243
+ const rendered = [];
808
1244
 
809
- if (options.executeEntries !== false) {
810
- for (const entry of entries) {
811
- const record = elementOwners.get(entry.element);
812
- 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;
813
1250
 
814
- try {
815
- emitHTML(controller, {
816
- type: "entry-executing",
817
- element: entry.element,
818
- moduleId: entry.id,
819
- origin: record?.origin ?? options.origin ?? "entry",
820
- });
821
- const namespace = await controller.runtime.import(entry.id);
822
- if (record) record.state = "ready";
823
- executed.push({ id: entry.id, namespace });
824
- emitHTML(controller, {
825
- type: "entry-executed",
826
- element: entry.element,
827
- moduleId: entry.id,
828
- namespace,
829
- origin: record?.origin ?? options.origin ?? "entry",
830
- });
831
- } catch (error) {
832
- if (record) {
833
- record.state = "error";
834
- record.error = error;
835
- }
836
- emitHTMLError(controller, "entry", error, {
837
- element: entry.element,
838
- moduleId: entry.id,
839
- origin: record?.origin ?? options.origin ?? "entry",
840
- });
841
- throw error;
842
- }
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));
843
1258
  }
844
1259
  }
845
1260
 
1261
+ registerSolidRenderElement();
846
1262
  return {
847
1263
  modules,
848
1264
  entries: entries.map(entry => entry.id),
849
1265
  executed,
1266
+ renders: renders.map(render => render.id),
1267
+ rendered,
850
1268
  };
851
1269
  }
852
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
+
853
1559
  function claimElement(controller, element, options = {}) {
854
1560
  assertElement(element, options.origin ?? "HTML runtime");
855
1561
 
@@ -897,8 +1603,12 @@ function claimElement(controller, element, options = {}) {
897
1603
  moduleId,
898
1604
  format: metadata.format,
899
1605
  entry: metadata.entry,
1606
+ render: metadata.render,
1607
+ component: metadata.component,
900
1608
  sourceUrl: metadata.sourceUrl,
901
1609
  inline: !metadata.sourceUrl,
1610
+ mount: undefined,
1611
+ range: undefined,
902
1612
  state: "claimed",
903
1613
  origin: options.origin ?? "unknown",
904
1614
  promise,
@@ -916,6 +1626,8 @@ function claimElement(controller, element, options = {}) {
916
1626
  moduleId,
917
1627
  format: record.format,
918
1628
  entry: record.entry,
1629
+ render: record.render,
1630
+ component: record.component,
919
1631
  sourceUrl: record.sourceUrl,
920
1632
  origin: record.origin,
921
1633
  });
@@ -960,6 +1672,14 @@ async function prepareRecordSource(controller, record, options) {
960
1672
  function inspectElement(controller, element, options = {}) {
961
1673
  const type = normalizeType(element.getAttribute("type"));
962
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
+ }
963
1683
  const srcAttribute = nonEmpty(element.getAttribute("src"));
964
1684
  const explicitId = nonEmpty(element.getAttribute("module"));
965
1685
  const baseUrl = options.baseUrl
@@ -977,15 +1697,16 @@ function inspectElement(controller, element, options = {}) {
977
1697
  sourceUrl,
978
1698
  });
979
1699
 
980
- 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);
981
1702
  if (!id) {
982
1703
  throw new HTMLModuleError(
983
- "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`.",
984
1705
  { element },
985
1706
  );
986
1707
  }
987
1708
 
988
- return { id, format, entry, sourceUrl };
1709
+ return { id, format, entry, render, component, sourceUrl };
989
1710
  }
990
1711
 
991
1712
  function requireOwnedRecord(controller, element, caller) {
@@ -1022,6 +1743,8 @@ function definitionFromRecord(record) {
1022
1743
  id: record.moduleId,
1023
1744
  format: record.format,
1024
1745
  entry: record.entry,
1746
+ render: record.render,
1747
+ component: record.component,
1025
1748
  sourceUrl: record.sourceUrl,
1026
1749
  inline: record.inline,
1027
1750
  element: record.element,
@@ -1146,6 +1869,7 @@ function getDefaultController(runtime, options = {}) {
1146
1869
  root: options.root,
1147
1870
  selector: options.selector,
1148
1871
  executeEntries: options.executeEntries,
1872
+ executeRenders: options.executeRenders,
1149
1873
  fetch: options.fetch,
1150
1874
  baseUrl: options.baseUrl,
1151
1875
  MutationObserver: options.MutationObserver,
@@ -1198,6 +1922,7 @@ function mergeOptions(controller, options) {
1198
1922
  root: options.root ?? controller.root,
1199
1923
  selector: options.selector ?? controller.selector,
1200
1924
  executeEntries: options.executeEntries ?? controller.executeEntries,
1925
+ executeRenders: options.executeRenders ?? controller.executeRenders,
1201
1926
  fetch: options.fetch ?? controller.fetch,
1202
1927
  baseUrl: options.baseUrl ?? controller.baseUrl,
1203
1928
  MutationObserver: options.MutationObserver ?? controller.MutationObserver,
@@ -1212,6 +1937,8 @@ function emptyRegistrationResult() {
1212
1937
  modules: [],
1213
1938
  entries: [],
1214
1939
  executed: [],
1940
+ renders: [],
1941
+ rendered: [],
1215
1942
  };
1216
1943
  }
1217
1944
 
@@ -1312,9 +2039,15 @@ function resolveSourceUrl(src, baseUrl) {
1312
2039
  }
1313
2040
  }
1314
2041
 
1315
- function createAnonymousId(format) {
2042
+ function createAnonymousId(format, kind = "entry") {
1316
2043
  const extension = format === "js" ? "js" : "jsx";
1317
- 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);
1318
2051
  }
1319
2052
 
1320
2053
  function stripQueryAndHash(value) {