solid-tag-runtime 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -235,6 +235,10 @@ html.observe(options?);
235
235
  html.flush(options?);
236
236
  html.disconnect();
237
237
 
238
+ await html.setRoot(root, options?);
239
+ await html.moveTo(root, options?);
240
+ html.setAppendTarget(target);
241
+
238
242
  html.defineElement(element, options?);
239
243
  html.registerElement(element, options?);
240
244
  html.append(element, options?);
@@ -321,6 +325,45 @@ A scoped controller accepts matching scripts. It may also claim unscoped scripts
321
325
 
322
326
  An unscoped controller does not silently claim a script carrying `data-solid-runtime` for another runtime.
323
327
 
328
+
329
+ ### Mutable root and append-target semantics
330
+
331
+ `0.0.6` makes the HTML controller's DOM boundaries mutable without replacing the controller or core runtime.
332
+
333
+ The discovery root may be any DOM-like container that supports `querySelectorAll()` and can be observed by `MutationObserver`, including browser `Document`, `Element`, `DocumentFragment`, and `ShadowRoot`.
334
+
335
+ ```ts
336
+ await html.setRoot(nextRoot, {
337
+ registerExisting: true,
338
+ });
339
+
340
+ await html.moveTo(nextRoot);
341
+ html.setAppendTarget(target);
342
+ ```
343
+
344
+ The two boundaries remain distinct:
345
+
346
+ ```text
347
+ root
348
+ one-shot discovery + MutationObserver target
349
+
350
+ appendTarget
351
+ default destination for append()/addModule()
352
+ ```
353
+
354
+ Important invariants:
355
+
356
+ 1. Reparenting the exact same observed root node does not require rebinding; MutationObserver follows node identity rather than the node's former parent.
357
+ 2. Replacing that root with a different node requires `setRoot()`/`moveTo()` for the controller to observe the new subtree.
358
+ 3. A connected controller stays connected when `setRoot()` changes the node: pending work against the old root is drained, the old observer target is disconnected, and observation is rebound to the new root.
359
+ 4. Connected root changes register matching scripts already present in the new root by default; `registerExisting: false` opts out.
360
+ 5. Disconnected `setRoot()` is configuration-only by default; `registerExisting: true` explicitly performs a one-shot scan.
361
+ 6. `moveTo()` changes both discovery root and append target. For a `Document`, its body/documentElement is chosen as the append target rather than appending directly beside the document element.
362
+ 7. `setAppendTarget()` never changes observation.
363
+ 8. Root rebinding does not reset module ownership or the runtime graph. Elements already owned remain owned; module IDs remain reserved by the same controller.
364
+
365
+ Each HTML runtime controller owns its own MutationObserver instance. The observer is attached directly to the selected root; the implementation does not observe the entire document and then perform containment filtering. This keeps unrelated DOM mutation traffic out of the controller and correctly supports isolated `ShadowRoot` trees.
366
+
324
367
  ### User-created script elements
325
368
 
326
369
  Applications may create the element themselves and explicitly associate it with one runtime:
@@ -916,3 +959,24 @@ Reason: attributes are useful across declarative HTML and DevTools but cannot un
916
959
  ### 0.0.4 — typing/test/documentation hardening
917
960
 
918
961
  Decision: publish the next package as `0.0.4` because `0.0.3` has already been published. `0.0.4` preserves the `0.0.3` runtime/HTML semantics while shipping the DOM-compatible HTML adapter declaration fixes, broader regression coverage, and updated feature documentation. No module-resolution, ownership, execution-backend, or cache-invalidation semantics change in this release.
962
+ ### 0.0.5 — fresh module identity after invalidation
963
+
964
+ Decision: every execution-backend `create(code)` call must produce a fresh native ESM module identity, even when the generated source text is byte-for-byte identical to an earlier version. The `data:` backend now appends a monotonically increasing fragment to each generated URL. This matches the naturally unique URLs returned by `URL.createObjectURL()` and prevents the browser/Node ESM cache from returning a previously evaluated module after `invalidate()`, `update()`, or `defineModule()` replacement.
965
+
966
+ Reason: replacing a host namespace correctly invalidated its dependents in the runtime graph, but a deterministic `data:` URL could recreate the exact same URL for the host bridge and dependent source. Native ESM caches by URL, so the old evaluation was reused and dependents continued to observe stale host exports.
967
+
968
+ Invariant: invalidation must result in a fresh executable module identity on the next compile/import. Cache invalidation is not complete if the execution backend can recreate an already-evaluated URL.
969
+
970
+ Regression coverage: host-module replacement must update already-imported dependents, and explicit `runtime.invalidate()` must cause a side-effecting module to evaluate again on the next import.
971
+
972
+
973
+
974
+ ### 0.0.6 — mutable HTML runtime roots and append targets
975
+
976
+ Decision: HTML runtime controllers now expose `setRoot()`, `moveTo()`, and `setAppendTarget()`, plus `root` and `appendTarget` introspection. A connected observer is rebound directly to the new root rather than keeping a document-wide observer and filtering by containment.
977
+
978
+ Reason: applications can reparent an existing observed root without intervention, but replacing a mount/container with a different DOM node would otherwise leave the observer and append destination attached to stale nodes. Explicit mutable boundaries preserve one controller/module graph while allowing UI containers, previews, micro-frontends, and shadow roots to move during application lifetime.
979
+
980
+ Invariant: a root change must not create a second observer, lose ownership records, or race pending scans from the old root. Pending observer work is drained before rebinding. A live root change registers existing scripts in the new root by default; a disconnected root change does not perform discovery unless requested.
981
+
982
+ Regression coverage: the suite verifies same-node reparenting, live root rebinding, skipping existing scripts, disconnected root changes, `moveTo()` synchronization of root/append target, and independent append-target changes.
package/README.md CHANGED
@@ -270,9 +270,71 @@ solid-module → infer from module/src extension
270
270
 
271
271
  `language="js"` or `language="jsx"` may override inference.
272
272
 
273
+
274
+ ### Change the observed root
275
+
276
+ The HTML controller can move to a different discovery root without creating a new runtime:
277
+
278
+ ```ts
279
+ const first = document.querySelector("#first-runtime")!;
280
+ const second = document.querySelector("#second-runtime")!;
281
+
282
+ const html = createHTMLRuntime(runtime, {
283
+ root: first,
284
+ appendTo: first,
285
+ });
286
+
287
+ await html.observe({ registerExisting: false });
288
+
289
+ // Rebind a live observer to a different node. Because the observer is already
290
+ // connected, matching scripts already in the new root are registered by default.
291
+ await html.setRoot(second);
292
+ ```
293
+
294
+ If the controller is disconnected, `setRoot()` only changes configuration unless `registerExisting: true` is passed:
295
+
296
+ ```ts
297
+ await html.setRoot(second, {
298
+ registerExisting: true,
299
+ });
300
+ ```
301
+
302
+ Use `registerExisting: false` when switching a live observer but intentionally ignoring scripts already present in the new root.
303
+
304
+ Moving the **same root node** elsewhere in the DOM does not require `setRoot()`. `MutationObserver` remains attached to that node object even when it is reparented.
305
+
306
+ ### Move root and append target together
307
+
308
+ When the runtime module area itself moves to another container, `moveTo()` changes both boundaries:
309
+
310
+ ```ts
311
+ await html.moveTo(nextContainer, {
312
+ registerExisting: true,
313
+ });
314
+ ```
315
+
316
+ For an `Element`, `DocumentFragment`, or `ShadowRoot`, future `append()` / `addModule()` calls insert into that root. For a `Document`, the controller chooses the document body (or document element fallback) as the append target.
317
+
318
+ ### Change only the append target
319
+
320
+ Observation and insertion can have different boundaries:
321
+
322
+ ```ts
323
+ html.setAppendTarget(document.head);
324
+ ```
325
+
326
+ This does not change the currently observed root. The controller exposes the active values for diagnostics:
327
+
328
+ ```ts
329
+ html.root;
330
+ html.appendTarget;
331
+ ```
332
+
333
+ The `root` may be a `Document`, normal `Element`, `DocumentFragment`, or `ShadowRoot` (which is a `DocumentFragment`). A controller observes the selected root directly rather than observing the whole document and filtering mutations afterward.
334
+
273
335
  ### Addition-only observation
274
336
 
275
- Observation remains addition-only in `0.0.4`.
337
+ Observation remains addition-only in `0.0.6`.
276
338
 
277
339
  Changing the source/attributes of an already owned script or removing it from the DOM does not implicitly update/delete the corresponding runtime module. Use `runtime.update()` / `runtime.invalidate()` for explicit module lifecycle changes.
278
340
 
package/html.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface HTMLModuleScriptElement {
5
5
  textContent?: string | null;
6
6
  baseURI?: string;
7
7
  ownerDocument?: HTMLDocumentLike | null;
8
+ parentNode?: unknown;
8
9
  getAttribute(name: string): string | null;
9
10
  hasAttribute?(name: string): boolean;
10
11
  setAttribute?(name: string, value: string): void;
@@ -119,10 +120,24 @@ export interface HTMLRuntimeOptions extends ObserveHTMLOptions {
119
120
  createElement?: (tagName: string) => HTMLModuleScriptElement;
120
121
  }
121
122
 
123
+ export interface SetHTMLRootOptions extends RegisterHTMLOptions {
124
+ /**
125
+ * Register matching scripts already present in the new root.
126
+ * Defaults to true when rebinding a connected observer and false otherwise.
127
+ */
128
+ registerExisting?: boolean;
129
+ /** Optionally update the controller's default append target at the same time. */
130
+ appendTo?: HTMLAppendTarget;
131
+ }
132
+
122
133
  export interface HTMLRuntimeController {
123
134
  readonly runtime: SolidTagRuntime;
124
135
  readonly scope?: string;
125
136
  readonly connected: boolean;
137
+ /** Current configured/effective discovery root when available. */
138
+ readonly root?: HTMLModuleRoot;
139
+ /** Current effective target used by append()/addModule() when available. */
140
+ readonly appendTarget?: HTMLAppendTarget;
126
141
  /** Result of the observer's initial scan. */
127
142
  readonly initial: RegisterHTMLResult;
128
143
 
@@ -136,6 +151,24 @@ export interface HTMLRuntimeController {
136
151
  flush(options?: RegisterHTMLOptions): Promise<RegisterHTMLResult>;
137
152
  disconnect(): void;
138
153
 
154
+ /**
155
+ * Change the discovery/observation root. A connected observer is rebound to
156
+ * the new node automatically.
157
+ */
158
+ setRoot(
159
+ root: HTMLModuleRoot,
160
+ options?: SetHTMLRootOptions,
161
+ ): Promise<RegisterHTMLResult>;
162
+
163
+ /** Change both the discovery root and append target to the selected root. */
164
+ moveTo(
165
+ root: HTMLModuleRoot,
166
+ options?: SetHTMLRootOptions,
167
+ ): Promise<RegisterHTMLResult>;
168
+
169
+ /** Change only where append()/addModule() insert future elements. */
170
+ setAppendTarget(target: HTMLAppendTarget): HTMLRuntimeController;
171
+
139
172
  /** Define one element without auto-running an entry. */
140
173
  defineElement(
141
174
  element: HTMLModuleScriptElement,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-tag-runtime",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Runtime module system for JSX modules compiled with solid-tag and executed through @solidjs/html",
5
5
  "type": "module",
6
6
  "exports": {
package/src/html.js CHANGED
@@ -61,6 +61,7 @@ export function createHTMLRuntime(runtime, options = {}) {
61
61
  bindings: new Map(),
62
62
  observer: undefined,
63
63
  observerRoot: undefined,
64
+ observerOptions: undefined,
64
65
  ignoredExisting: new WeakSet(),
65
66
  queue: Promise.resolve(),
66
67
  lastError: undefined,
@@ -81,10 +82,19 @@ export function createHTMLRuntime(runtime, options = {}) {
81
82
  get initial() {
82
83
  return controller.initial;
83
84
  },
85
+ get root() {
86
+ return controller.root ?? globalThis.document;
87
+ },
88
+ get appendTarget() {
89
+ return getAppendTarget(controller);
90
+ },
84
91
  register,
85
92
  observe,
86
93
  flush,
87
94
  disconnect,
95
+ setRoot,
96
+ moveTo,
97
+ setAppendTarget,
88
98
  defineElement,
89
99
  registerElement,
90
100
  append,
@@ -120,47 +130,14 @@ export function createHTMLRuntime(runtime, options = {}) {
120
130
  async function observe(callOptions = {}) {
121
131
  if (controller.connected) return api;
122
132
 
123
- const merged = mergeOptions(controller, callOptions);
124
- const root = getRoot(merged, "HTML runtime observe");
125
- const MutationObserverImpl = callOptions.MutationObserver ?? controller.MutationObserver ?? globalThis.MutationObserver;
126
-
127
- if (typeof MutationObserverImpl !== "function") {
128
- throw new TypeError(
129
- "observe() requires MutationObserver support or a MutationObserver option.",
130
- );
131
- }
132
-
133
- controller.observerRoot = root;
134
- controller.connected = true;
135
- controller.lastError = undefined;
136
- controller.ignoredExisting = new WeakSet();
137
-
138
- if (callOptions.registerExisting === false) {
139
- for (const element of Array.from(root.querySelectorAll(callOptions.selector ?? controller.selector))) {
140
- if (acceptsDiscoveredElement(controller, element, callOptions)) {
141
- controller.ignoredExisting.add(element);
142
- }
143
- }
144
- controller.initial = emptyRegistrationResult();
145
- }
146
-
147
- const observer = new MutationObserverImpl(() => {
148
- void enqueueObserverScan(controller, callOptions).catch(() => {});
149
- });
150
-
151
- controller.observer = observer;
152
- observer.observe(root, {
153
- childList: true,
154
- subtree: callOptions.subtree ?? controller.subtree,
155
- });
133
+ const root = getRoot(mergeOptions(controller, callOptions), "HTML runtime observe");
134
+ controller.observerOptions = { ...callOptions };
156
135
 
157
136
  try {
158
- if (callOptions.registerExisting !== false) {
159
- controller.initial = await enqueueObserverScan(controller, {
160
- ...callOptions,
161
- origin: "observer-initial",
162
- });
163
- }
137
+ controller.initial = await connectObserver(controller, root, callOptions, {
138
+ registerExisting: callOptions.registerExisting !== false,
139
+ initial: true,
140
+ });
164
141
  } catch (error) {
165
142
  disconnect();
166
143
  throw error;
@@ -184,11 +161,80 @@ export function createHTMLRuntime(runtime, options = {}) {
184
161
  }
185
162
 
186
163
  function disconnect() {
187
- if (!controller.connected) return;
188
- controller.connected = false;
189
- controller.observer?.disconnect();
190
- controller.observer = undefined;
191
- controller.observerRoot = undefined;
164
+ stopObserver(controller);
165
+ }
166
+
167
+ /**
168
+ * Change the discovery/observation root. If observation is active, it is
169
+ * rebound to the new root and existing matching scripts are registered by
170
+ * default. When disconnected, changing the root is configuration-only unless
171
+ * registerExisting:true is requested.
172
+ */
173
+ async function setRoot(root, callOptions = {}) {
174
+ const nextRoot = getRoot({ root }, "HTML runtime setRoot");
175
+
176
+ // Finish work scheduled against the old root before rebinding observer state.
177
+ await controller.queue;
178
+ if (controller.lastError) {
179
+ const error = controller.lastError;
180
+ controller.lastError = undefined;
181
+ throw error;
182
+ }
183
+
184
+ const wasConnected = controller.connected;
185
+ const previousObserverOptions = controller.observerOptions ?? {};
186
+
187
+ if (wasConnected) stopObserver(controller, { preserveOptions: true });
188
+
189
+ controller.root = nextRoot;
190
+ if (Object.prototype.hasOwnProperty.call(callOptions, "appendTo")) {
191
+ controller.appendTo = callOptions.appendTo;
192
+ }
193
+
194
+ const registerExisting = callOptions.registerExisting ?? wasConnected;
195
+
196
+ if (wasConnected) {
197
+ const observeOptions = {
198
+ ...previousObserverOptions,
199
+ ...callOptions,
200
+ root: nextRoot,
201
+ registerExisting,
202
+ };
203
+ controller.observerOptions = { ...observeOptions };
204
+ try {
205
+ return await connectObserver(controller, nextRoot, observeOptions, {
206
+ registerExisting,
207
+ initial: false,
208
+ });
209
+ } catch (error) {
210
+ stopObserver(controller, { preserveOptions: true });
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ if (registerExisting) {
216
+ return register({ ...callOptions, root: nextRoot });
217
+ }
218
+
219
+ return emptyRegistrationResult();
220
+ }
221
+
222
+ /** Change both observation root and default append target to the same node. */
223
+ async function moveTo(root, callOptions = {}) {
224
+ const nextRoot = getRoot({ root }, "HTML runtime moveTo");
225
+ return setRoot(nextRoot, {
226
+ ...callOptions,
227
+ appendTo: callOptions.appendTo ?? defaultAppendTargetForRoot(nextRoot),
228
+ });
229
+ }
230
+
231
+ /** Change only where append()/addModule() insert future script elements. */
232
+ function setAppendTarget(target) {
233
+ if (!target || (typeof target.append !== "function" && typeof target.appendChild !== "function")) {
234
+ throw new TypeError("setAppendTarget() requires a target with append() or appendChild().");
235
+ }
236
+ controller.appendTo = target;
237
+ return api;
192
238
  }
193
239
 
194
240
  /** Define one element without automatically executing an entry module. */
@@ -326,6 +372,59 @@ export async function defineScript(runtime, element, options = {}) {
326
372
  export const htmlModuleSelector = DEFAULT_SELECTOR;
327
373
  export const htmlRuntimeScopeAttribute = RUNTIME_SCOPE_ATTRIBUTE;
328
374
 
375
+ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
376
+ const MutationObserverImpl = callOptions.MutationObserver ?? controller.MutationObserver ?? globalThis.MutationObserver;
377
+
378
+ if (typeof MutationObserverImpl !== "function") {
379
+ throw new TypeError(
380
+ "observe() requires MutationObserver support or a MutationObserver option.",
381
+ );
382
+ }
383
+
384
+ controller.observerRoot = root;
385
+ controller.connected = true;
386
+ controller.lastError = undefined;
387
+ controller.ignoredExisting = new WeakSet();
388
+
389
+ const registerExisting = mode.registerExisting !== false;
390
+ if (!registerExisting) {
391
+ for (const element of Array.from(root.querySelectorAll(callOptions.selector ?? controller.selector))) {
392
+ if (acceptsDiscoveredElement(controller, element, callOptions)) {
393
+ controller.ignoredExisting.add(element);
394
+ }
395
+ }
396
+ }
397
+
398
+ const observer = new MutationObserverImpl(() => {
399
+ void enqueueObserverScan(controller, callOptions).catch(() => {});
400
+ });
401
+
402
+ controller.observer = observer;
403
+ observer.observe(root, {
404
+ childList: true,
405
+ subtree: callOptions.subtree ?? controller.subtree,
406
+ });
407
+
408
+ const result = registerExisting
409
+ ? await enqueueObserverScan(controller, {
410
+ ...callOptions,
411
+ origin: mode.initial ? "observer-initial" : "observer-root-change",
412
+ })
413
+ : emptyRegistrationResult();
414
+
415
+ if (mode.initial) controller.initial = result;
416
+ return result;
417
+ }
418
+
419
+ function stopObserver(controller, options = {}) {
420
+ if (!controller.connected && !controller.observer) return;
421
+ controller.connected = false;
422
+ controller.observer?.disconnect();
423
+ controller.observer = undefined;
424
+ controller.observerRoot = undefined;
425
+ if (!options.preserveOptions) controller.observerOptions = undefined;
426
+ }
427
+
329
428
  async function enqueueObserverScan(controller, callOptions = {}) {
330
429
  const task = controller.queue.then(async () => {
331
430
  if (!controller.connected) return emptyRegistrationResult();
@@ -669,6 +768,10 @@ function getAppendTarget(controller, options = {}) {
669
768
  if (explicit) return explicit;
670
769
 
671
770
  const root = options.root ?? controller.root ?? globalThis.document;
771
+ return defaultAppendTargetForRoot(root);
772
+ }
773
+
774
+ function defaultAppendTargetForRoot(root) {
672
775
  if (root?.body) return root.body;
673
776
  if (root?.documentElement && root.documentElement !== root) return root.documentElement;
674
777
  return root;
@@ -23,10 +23,17 @@ export function createBlobModuleUrlBackend() {
23
23
  }
24
24
 
25
25
  export function createDataModuleUrlBackend() {
26
+ let sequence = 0;
27
+
26
28
  return {
27
29
  kind: "data",
28
30
  create(code) {
29
- return `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`;
31
+ // Native ESM caches modules by URL. Recreating the same data: URL after
32
+ // invalidation would otherwise return the previously evaluated module.
33
+ // Give every backend create() call a fresh module identity, matching the
34
+ // naturally unique identity of URL.createObjectURL() in the blob backend.
35
+ const identity = ++sequence;
36
+ return `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}#solid-tag-runtime-${identity}`;
30
37
  },
31
38
  revoke() {},
32
39
  };