solid-tag-runtime 0.0.5 → 0.0.7

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
@@ -38,9 +38,11 @@ The runtime should allow applications to:
38
38
  5. expose host components, signals, callbacks, services, and data as modules
39
39
  6. reuse compiled/evaluated modules through caching
40
40
  7. invalidate and rebuild a module graph when source changes
41
- 8. inspect dependencies and compiled source
42
- 9. support normal JavaScript modules in the same graph
43
- 10. keep the JSX compiler replaceable behind a narrow adapter
41
+ 8. explicitly remove and clear module definitions
42
+ 9. observe runtime lifecycle transitions for tooling/tracing
43
+ 10. inspect dependencies and compiled source
44
+ 11. support normal JavaScript modules in the same graph
45
+ 12. keep the JSX compiler replaceable behind a narrow adapter
44
46
 
45
47
  ## 3. Current non-goals
46
48
 
@@ -235,6 +237,10 @@ html.observe(options?);
235
237
  html.flush(options?);
236
238
  html.disconnect();
237
239
 
240
+ await html.setRoot(root, options?);
241
+ await html.moveTo(root, options?);
242
+ html.setAppendTarget(target);
243
+
238
244
  html.defineElement(element, options?);
239
245
  html.registerElement(element, options?);
240
246
  html.append(element, options?);
@@ -321,6 +327,45 @@ A scoped controller accepts matching scripts. It may also claim unscoped scripts
321
327
 
322
328
  An unscoped controller does not silently claim a script carrying `data-solid-runtime` for another runtime.
323
329
 
330
+
331
+ ### Mutable root and append-target semantics
332
+
333
+ `0.0.6` makes the HTML controller's DOM boundaries mutable without replacing the controller or core runtime.
334
+
335
+ 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`.
336
+
337
+ ```ts
338
+ await html.setRoot(nextRoot, {
339
+ registerExisting: true,
340
+ });
341
+
342
+ await html.moveTo(nextRoot);
343
+ html.setAppendTarget(target);
344
+ ```
345
+
346
+ The two boundaries remain distinct:
347
+
348
+ ```text
349
+ root
350
+ one-shot discovery + MutationObserver target
351
+
352
+ appendTarget
353
+ default destination for append()/addModule()
354
+ ```
355
+
356
+ Important invariants:
357
+
358
+ 1. Reparenting the exact same observed root node does not require rebinding; MutationObserver follows node identity rather than the node's former parent.
359
+ 2. Replacing that root with a different node requires `setRoot()`/`moveTo()` for the controller to observe the new subtree.
360
+ 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.
361
+ 4. Connected root changes register matching scripts already present in the new root by default; `registerExisting: false` opts out.
362
+ 5. Disconnected `setRoot()` is configuration-only by default; `registerExisting: true` explicitly performs a one-shot scan.
363
+ 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.
364
+ 7. `setAppendTarget()` never changes observation.
365
+ 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.
366
+
367
+ 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.
368
+
324
369
  ### User-created script elements
325
370
 
326
371
  Applications may create the element themselves and explicitly associate it with one runtime:
@@ -926,3 +971,63 @@ Invariant: invalidation must result in a fresh executable module identity on the
926
971
 
927
972
  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.
928
973
 
974
+
975
+
976
+ ### 0.0.6 — mutable HTML runtime roots and append targets
977
+
978
+ 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.
979
+
980
+ 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.
981
+
982
+ 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.
983
+
984
+ 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.
985
+
986
+
987
+ ### 0.0.7 — explicit module lifecycle, batching, and HTML-owned replacement
988
+
989
+ Decision: add `defineMany()`, `remove()`, `clear()`, and `subscribe()` to the core runtime, and `updateElement()` / `removeElement()` to `solid-tag-runtime/html`.
990
+
991
+ Reason: long-lived runtime environments need an explicit lifecycle beyond define/update/invalidate. Editors, previews, CMSs, and plugin hosts must be able to install related modules together, remove stale definitions, reset runtime state while preserving host modules, trace module transitions, and keep HTML ownership synchronized with explicit module replacement/removal.
992
+
993
+ Core lifecycle semantics:
994
+
995
+ - `defineMany()` validates the definition list before applying it and rejects duplicate IDs inside one batch. No module evaluation happens during definition, so the full batch is available before an import starts linking. Lifecycle events produced while applying the batch are buffered and published only after every definition has been installed, preventing a subscriber from observing or importing a partially installed batch.
996
+ - `remove(id)` deletes one module record, revokes generated URLs, removes host-registry state when applicable, and invalidates transitive dependents by default.
997
+ - `clear()` removes source/URL modules while preserving host modules by default; `preserveHostModules:false` removes every record.
998
+ - `subscribe(listener)` emits synchronous lifecycle events but isolates subscriber exceptions from runtime execution.
999
+ - public `resolve()` remains the canonical tooling hook for asking how a specifier resolves without evaluating it.
1000
+
1001
+ Lifecycle event contract:
1002
+
1003
+ ```text
1004
+ module-defined
1005
+ module-invalidated
1006
+ module-compiled
1007
+ module-evaluating
1008
+ module-evaluated
1009
+ module-removed
1010
+ module-error
1011
+ runtime-cleared
1012
+ runtime-disposed
1013
+ ```
1014
+
1015
+ HTML lifecycle semantics:
1016
+
1017
+ - `updateElement(element)` only operates on an element already owned by that controller. It re-reads/fetches source and calls `runtime.update()` while preserving logical module identity. Changing `module` identity requires explicit remove + register.
1018
+ - `removeElement(element)` releases the shared WeakMap ownership and per-controller binding. By default it also removes the runtime module and DOM node. `removeModule` and `removeFromDOM` can be controlled independently.
1019
+ - DOM removal by itself still does not imply module removal. Module lifecycle remains explicit.
1020
+ - If ownership is released while the element remains under an active observed root, the controller marks that element ignored so the observer does not immediately reclaim it. An explicit `registerElement()` may claim it again.
1021
+
1022
+ Invariants:
1023
+
1024
+ 1. Removing a module must revoke its executable URL/namespace cache and clean outbound graph edges.
1025
+ 2. Dependents are invalidated before dependency removal unless explicitly disabled.
1026
+ 3. `defineMany()` must not publish lifecycle events until the whole batch has been installed.
1027
+ 4. `clear()` must not silently discard host environment modules under its default configuration.
1028
+ 5. Lifecycle listeners are observational; listener failures cannot alter runtime execution.
1029
+ 6. HTML element ownership and runtime module existence are related but separate lifecycles.
1030
+ 7. `updateElement()` never changes the module identity associated with an owned element.
1031
+ 8. `removeElement()` is the canonical operation for releasing shared WeakMap ownership.
1032
+
1033
+ Regression coverage: package tests cover batch definition and duplicate rejection, dependency removal/invalidation, clear-with-host-preservation, lifecycle event delivery/unsubscribe, owned element updates, identity-change rejection, ownership release, DOM removal, and preserving a runtime module while releasing its HTML element.
package/README.md CHANGED
@@ -102,6 +102,17 @@ The runtime compiles and links the dependency graph automatically.
102
102
 
103
103
  `solid-tag-runtime/html` is the browser/DOM adapter. The core runtime remains DOM-independent.
104
104
 
105
+ Browser import maps must map the subpath explicitly as well as the package root:
106
+
107
+ ```json
108
+ {
109
+ "imports": {
110
+ "solid-tag-runtime": "https://esm.sh/solid-tag-runtime@0.0.7",
111
+ "solid-tag-runtime/html": "https://esm.sh/solid-tag-runtime@0.0.7/html"
112
+ }
113
+ }
114
+ ```
115
+
105
116
  For simple single-runtime use, the original helpers remain available:
106
117
 
107
118
  ```ts
@@ -270,11 +281,73 @@ solid-module → infer from module/src extension
270
281
 
271
282
  `language="js"` or `language="jsx"` may override inference.
272
283
 
284
+
285
+ ### Change the observed root
286
+
287
+ The HTML controller can move to a different discovery root without creating a new runtime:
288
+
289
+ ```ts
290
+ const first = document.querySelector("#first-runtime")!;
291
+ const second = document.querySelector("#second-runtime")!;
292
+
293
+ const html = createHTMLRuntime(runtime, {
294
+ root: first,
295
+ appendTo: first,
296
+ });
297
+
298
+ await html.observe({ registerExisting: false });
299
+
300
+ // Rebind a live observer to a different node. Because the observer is already
301
+ // connected, matching scripts already in the new root are registered by default.
302
+ await html.setRoot(second);
303
+ ```
304
+
305
+ If the controller is disconnected, `setRoot()` only changes configuration unless `registerExisting: true` is passed:
306
+
307
+ ```ts
308
+ await html.setRoot(second, {
309
+ registerExisting: true,
310
+ });
311
+ ```
312
+
313
+ Use `registerExisting: false` when switching a live observer but intentionally ignoring scripts already present in the new root.
314
+
315
+ 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.
316
+
317
+ ### Move root and append target together
318
+
319
+ When the runtime module area itself moves to another container, `moveTo()` changes both boundaries:
320
+
321
+ ```ts
322
+ await html.moveTo(nextContainer, {
323
+ registerExisting: true,
324
+ });
325
+ ```
326
+
327
+ 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.
328
+
329
+ ### Change only the append target
330
+
331
+ Observation and insertion can have different boundaries:
332
+
333
+ ```ts
334
+ html.setAppendTarget(document.head);
335
+ ```
336
+
337
+ This does not change the currently observed root. The controller exposes the active values for diagnostics:
338
+
339
+ ```ts
340
+ html.root;
341
+ html.appendTarget;
342
+ ```
343
+
344
+ 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.
345
+
273
346
  ### Addition-only observation
274
347
 
275
- Observation remains addition-only in `0.0.5`.
348
+ Observation remains addition-oriented.
276
349
 
277
- 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.
350
+ Changing the source/attributes of an already owned script or removing it from the DOM does not implicitly update/delete the corresponding runtime module. In `0.0.7`, use `html.updateElement()` and `html.removeElement()` when the lifecycle should follow an owned script explicitly, or use the core `runtime.update()` / `runtime.remove()` APIs directly.
278
351
 
279
352
  The HTML adapter does **not** reinterpret arbitrary document HTML as JSX and does not currently assign component semantics to `<template>`.
280
353
 
@@ -448,6 +521,120 @@ Updating a module invalidates its compiled URL and its dependent runtime modules
448
521
 
449
522
  Existing references to an older module namespace/component are not mutated. Applications that implement live editing should import the updated entry module again.
450
523
 
524
+ ### Define several source modules together
525
+
526
+ ```ts
527
+ runtime.defineMany([
528
+ {
529
+ id: "/ui/Button.jsx",
530
+ source: buttonSource,
531
+ },
532
+ {
533
+ id: "/App.jsx",
534
+ source: appSource,
535
+ },
536
+ ]);
537
+ ```
538
+
539
+ `defineMany()` validates the whole definition list before applying it and rejects duplicate module IDs inside the batch. It is useful when a group of modules should become available before anything is imported. Lifecycle notifications from the batch are published only after every definition has been installed.
540
+
541
+ ### Remove modules
542
+
543
+ ```ts
544
+ runtime.remove("/ui/Button.jsx");
545
+ ```
546
+
547
+ Removal invalidates transitive dependents by default. A later import of a dependent will fail resolution until the missing module is defined again.
548
+
549
+ If you intentionally want to leave currently linked dependents untouched:
550
+
551
+ ```ts
552
+ runtime.remove("/ui/Button.jsx", {
553
+ invalidateDependents: false,
554
+ });
555
+ ```
556
+
557
+ ### Clear a runtime
558
+
559
+ ```ts
560
+ runtime.clear();
561
+ ```
562
+
563
+ By default `clear()` removes source and URL modules while preserving host modules registered with `defineModule()`. This is convenient for editor/preview resets where the host environment should remain installed.
564
+
565
+ ```ts
566
+ runtime.clear({
567
+ preserveHostModules: false,
568
+ });
569
+ ```
570
+
571
+ removes everything. The returned array contains the IDs that were removed.
572
+
573
+ ### Lifecycle events
574
+
575
+ ```ts
576
+ const unsubscribe = runtime.subscribe(event => {
577
+ console.log(event.type, event);
578
+ });
579
+
580
+ // later
581
+ unsubscribe();
582
+ ```
583
+
584
+ Events currently include:
585
+
586
+ - `module-defined`
587
+ - `module-invalidated`
588
+ - `module-compiled`
589
+ - `module-evaluating`
590
+ - `module-evaluated`
591
+ - `module-removed`
592
+ - `module-error`
593
+ - `runtime-cleared`
594
+ - `runtime-disposed`
595
+
596
+ Subscriber exceptions are isolated from runtime execution. This makes `subscribe()` suitable for tracing, developer tools, and editor diagnostics.
597
+
598
+ ## HTML-owned module updates and removal
599
+
600
+ When a script element is already owned by an HTML runtime controller, update the runtime module from the current element contents with:
601
+
602
+ ```ts
603
+ script.textContent = `
604
+ export function Card() {
605
+ return <div>updated</div>;
606
+ }
607
+ `;
608
+
609
+ await html.updateElement(script);
610
+ ```
611
+
612
+ `updateElement()` keeps the existing logical module ID and calls `runtime.update()` underneath. Changing the element's logical `module` identity is intentionally rejected; remove and register it again instead.
613
+
614
+ Release an owned element explicitly with:
615
+
616
+ ```ts
617
+ await html.removeElement(script);
618
+ ```
619
+
620
+ By default this:
621
+
622
+ 1. removes the runtime module,
623
+ 2. invalidates its dependents,
624
+ 3. releases HTML ownership, and
625
+ 4. removes the script element from the DOM.
626
+
627
+ The two lifecycles can be controlled independently:
628
+
629
+ ```ts
630
+ await html.removeElement(script, {
631
+ removeModule: false,
632
+ removeFromDOM: true,
633
+ });
634
+ ```
635
+
636
+ This is intentionally explicit: ordinary DOM removal does not silently delete a runtime module.
637
+
451
638
  ## Custom resolution
452
639
 
453
640
  ```ts
@@ -486,10 +673,12 @@ Set it to `false` when all module dependencies must be explicitly registered wit
486
673
 
487
674
  ```ts
488
675
  runtime.invalidate("/App.jsx");
676
+ runtime.remove("/Unused.jsx");
677
+ runtime.clear();
489
678
  runtime.dispose();
490
679
  ```
491
680
 
492
- `dispose()` revokes generated module URLs and clears the host-module registry for that runtime instance.
681
+ `invalidate()` forces fresh compilation/evaluation without deleting the module definition. `remove()` deletes one definition, `clear()` resets a group of definitions, and `dispose()` permanently tears down the runtime instance.
493
682
 
494
683
  ## Current limitations
495
684
 
package/html.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface HTMLModuleScriptElement {
5
5
  textContent?: string | null;
6
6
  baseURI?: string;
7
7
  ownerDocument?: HTMLDocumentLike | null;
8
+ parentNode?: { removeChild?(element: any): unknown } | null;
9
+ remove?(): void;
8
10
  getAttribute(name: string): string | null;
9
11
  hasAttribute?(name: string): boolean;
10
12
  setAttribute?(name: string, value: string): void;
@@ -75,6 +77,22 @@ export interface HTMLModuleDefinition {
75
77
  element: HTMLModuleScriptElement;
76
78
  }
77
79
 
80
+ export interface RemoveHTMLElementOptions {
81
+ /** Remove the runtime module as well as releasing HTML ownership. Default: true. */
82
+ removeModule?: boolean;
83
+ /** Remove the script element from the DOM. Default: true. */
84
+ removeFromDOM?: boolean;
85
+ /** Invalidate runtime dependents when removing the module. Default: true. */
86
+ invalidateDependents?: boolean;
87
+ }
88
+
89
+ export interface RemoveHTMLElementResult {
90
+ id: string;
91
+ element: HTMLModuleScriptElement;
92
+ moduleRemoved: boolean;
93
+ elementRemoved: boolean;
94
+ }
95
+
78
96
  export interface RegisterHTMLOptions extends DefineScriptOptions {
79
97
  selector?: string;
80
98
  executeEntries?: boolean;
@@ -119,10 +137,24 @@ export interface HTMLRuntimeOptions extends ObserveHTMLOptions {
119
137
  createElement?: (tagName: string) => HTMLModuleScriptElement;
120
138
  }
121
139
 
140
+ export interface SetHTMLRootOptions extends RegisterHTMLOptions {
141
+ /**
142
+ * Register matching scripts already present in the new root.
143
+ * Defaults to true when rebinding a connected observer and false otherwise.
144
+ */
145
+ registerExisting?: boolean;
146
+ /** Optionally update the controller's default append target at the same time. */
147
+ appendTo?: HTMLAppendTarget;
148
+ }
149
+
122
150
  export interface HTMLRuntimeController {
123
151
  readonly runtime: SolidTagRuntime;
124
152
  readonly scope?: string;
125
153
  readonly connected: boolean;
154
+ /** Current configured/effective discovery root when available. */
155
+ readonly root?: HTMLModuleRoot;
156
+ /** Current effective target used by append()/addModule() when available. */
157
+ readonly appendTarget?: HTMLAppendTarget;
126
158
  /** Result of the observer's initial scan. */
127
159
  readonly initial: RegisterHTMLResult;
128
160
 
@@ -136,6 +168,24 @@ export interface HTMLRuntimeController {
136
168
  flush(options?: RegisterHTMLOptions): Promise<RegisterHTMLResult>;
137
169
  disconnect(): void;
138
170
 
171
+ /**
172
+ * Change the discovery/observation root. A connected observer is rebound to
173
+ * the new node automatically.
174
+ */
175
+ setRoot(
176
+ root: HTMLModuleRoot,
177
+ options?: SetHTMLRootOptions,
178
+ ): Promise<RegisterHTMLResult>;
179
+
180
+ /** Change both the discovery root and append target to the selected root. */
181
+ moveTo(
182
+ root: HTMLModuleRoot,
183
+ options?: SetHTMLRootOptions,
184
+ ): Promise<RegisterHTMLResult>;
185
+
186
+ /** Change only where append()/addModule() insert future elements. */
187
+ setAppendTarget(target: HTMLAppendTarget): HTMLRuntimeController;
188
+
139
189
  /** Define one element without auto-running an entry. */
140
190
  defineElement(
141
191
  element: HTMLModuleScriptElement,
@@ -148,6 +198,18 @@ export interface HTMLRuntimeController {
148
198
  options?: RegisterHTMLOptions,
149
199
  ): Promise<HTMLModuleDefinition>;
150
200
 
201
+ /** Re-read and replace source for an element already owned by this controller. */
202
+ updateElement(
203
+ element: HTMLModuleScriptElement,
204
+ options?: RegisterHTMLOptions,
205
+ ): Promise<HTMLModuleDefinition>;
206
+
207
+ /** Release an owned element and optionally remove its module and DOM node. */
208
+ removeElement(
209
+ element: HTMLModuleScriptElement,
210
+ options?: RemoveHTMLElementOptions,
211
+ ): Promise<RemoveHTMLElementResult>;
212
+
151
213
  /** Claim first, append a user-created element, then register it directly. */
152
214
  append(
153
215
  element: HTMLModuleScriptElement,
package/index.d.ts CHANGED
@@ -76,8 +76,35 @@ export interface CompileResult {
76
76
  diagnostics: unknown[];
77
77
  }
78
78
 
79
+ export interface SourceModuleDefinition extends DefineOptions {
80
+ id: string;
81
+ source: string;
82
+ }
83
+
84
+ export interface RemoveModuleOptions {
85
+ /** Invalidate transitive dependents before removal. Default: true. */
86
+ invalidateDependents?: boolean;
87
+ }
88
+
89
+ export interface ClearRuntimeOptions {
90
+ /** Keep host namespace modules registered with defineModule(). Default: true. */
91
+ preserveHostModules?: boolean;
92
+ }
93
+
94
+ export type RuntimeLifecycleEvent =
95
+ | { runtimeId: string; timestamp: number; type: "module-defined"; id: string; kind: ModuleInfo["kind"]; version: number; operation: "define" | "defineMany" | "update" | "defineModule" | "defineUrl"; replaced: boolean; module: ModuleInfo }
96
+ | { runtimeId: string; timestamp: number; type: "module-invalidated"; id: string; kind: ModuleInfo["kind"]; version: number; reason: string }
97
+ | { runtimeId: string; timestamp: number; type: "module-compiled"; id: string; kind: ModuleInfo["kind"]; version: number; module: ModuleInfo }
98
+ | { runtimeId: string; timestamp: number; type: "module-evaluating"; id: string; kind: ModuleInfo["kind"]; version: number }
99
+ | { runtimeId: string; timestamp: number; type: "module-evaluated"; id: string; kind: ModuleInfo["kind"]; version: number; module: ModuleInfo }
100
+ | { runtimeId: string; timestamp: number; type: "module-removed"; id: string; kind: ModuleInfo["kind"]; version: number; invalidateDependents: boolean }
101
+ | { runtimeId: string; timestamp: number; type: "module-error"; id?: string; kind?: ModuleInfo["kind"]; version?: number; phase: string; error: unknown }
102
+ | { runtimeId: string; timestamp: number; type: "runtime-cleared"; ids: string[]; preserveHostModules: boolean }
103
+ | { runtimeId: string; timestamp: number; type: "runtime-disposed" };
104
+
79
105
  export interface SolidTagRuntime {
80
106
  define(id: string, source: string, options?: DefineOptions): string;
107
+ defineMany(definitions: SourceModuleDefinition[]): string[];
81
108
  update(id: string, source: string, options?: DefineOptions): string;
82
109
  defineModule(id: string, namespace: ModuleNamespaceLike | Function): string;
83
110
  defineUrl(id: string, url: string): string;
@@ -87,6 +114,9 @@ export interface SolidTagRuntime {
87
114
  toComponent<T extends Function = Function>(source: string, options?: ToComponentOptions): Promise<T>;
88
115
  has(id: string): boolean;
89
116
  invalidate(id: string, options?: { dependents?: boolean }): string[];
117
+ remove(id: string, options?: RemoveModuleOptions): boolean;
118
+ clear(options?: ClearRuntimeOptions): string[];
119
+ subscribe(listener: (event: RuntimeLifecycleEvent) => void): () => boolean;
90
120
  modules(): ModuleInfo[];
91
121
  dependencies(id: string): string[];
92
122
  dependents(id: string): string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-tag-runtime",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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,12 +82,23 @@ 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,
100
+ updateElement,
101
+ removeElement,
90
102
  append,
91
103
  addModule,
92
104
  owns,
@@ -120,47 +132,14 @@ export function createHTMLRuntime(runtime, options = {}) {
120
132
  async function observe(callOptions = {}) {
121
133
  if (controller.connected) return api;
122
134
 
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
- });
135
+ const root = getRoot(mergeOptions(controller, callOptions), "HTML runtime observe");
136
+ controller.observerOptions = { ...callOptions };
156
137
 
157
138
  try {
158
- if (callOptions.registerExisting !== false) {
159
- controller.initial = await enqueueObserverScan(controller, {
160
- ...callOptions,
161
- origin: "observer-initial",
162
- });
163
- }
139
+ controller.initial = await connectObserver(controller, root, callOptions, {
140
+ registerExisting: callOptions.registerExisting !== false,
141
+ initial: true,
142
+ });
164
143
  } catch (error) {
165
144
  disconnect();
166
145
  throw error;
@@ -184,11 +163,80 @@ export function createHTMLRuntime(runtime, options = {}) {
184
163
  }
185
164
 
186
165
  function disconnect() {
187
- if (!controller.connected) return;
188
- controller.connected = false;
189
- controller.observer?.disconnect();
190
- controller.observer = undefined;
191
- controller.observerRoot = undefined;
166
+ stopObserver(controller);
167
+ }
168
+
169
+ /**
170
+ * Change the discovery/observation root. If observation is active, it is
171
+ * rebound to the new root and existing matching scripts are registered by
172
+ * default. When disconnected, changing the root is configuration-only unless
173
+ * registerExisting:true is requested.
174
+ */
175
+ async function setRoot(root, callOptions = {}) {
176
+ const nextRoot = getRoot({ root }, "HTML runtime setRoot");
177
+
178
+ // Finish work scheduled against the old root before rebinding observer state.
179
+ await controller.queue;
180
+ if (controller.lastError) {
181
+ const error = controller.lastError;
182
+ controller.lastError = undefined;
183
+ throw error;
184
+ }
185
+
186
+ const wasConnected = controller.connected;
187
+ const previousObserverOptions = controller.observerOptions ?? {};
188
+
189
+ if (wasConnected) stopObserver(controller, { preserveOptions: true });
190
+
191
+ controller.root = nextRoot;
192
+ if (Object.prototype.hasOwnProperty.call(callOptions, "appendTo")) {
193
+ controller.appendTo = callOptions.appendTo;
194
+ }
195
+
196
+ const registerExisting = callOptions.registerExisting ?? wasConnected;
197
+
198
+ if (wasConnected) {
199
+ const observeOptions = {
200
+ ...previousObserverOptions,
201
+ ...callOptions,
202
+ root: nextRoot,
203
+ registerExisting,
204
+ };
205
+ controller.observerOptions = { ...observeOptions };
206
+ try {
207
+ return await connectObserver(controller, nextRoot, observeOptions, {
208
+ registerExisting,
209
+ initial: false,
210
+ });
211
+ } catch (error) {
212
+ stopObserver(controller, { preserveOptions: true });
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ if (registerExisting) {
218
+ return register({ ...callOptions, root: nextRoot });
219
+ }
220
+
221
+ return emptyRegistrationResult();
222
+ }
223
+
224
+ /** Change both observation root and default append target to the same node. */
225
+ async function moveTo(root, callOptions = {}) {
226
+ const nextRoot = getRoot({ root }, "HTML runtime moveTo");
227
+ return setRoot(nextRoot, {
228
+ ...callOptions,
229
+ appendTo: callOptions.appendTo ?? defaultAppendTargetForRoot(nextRoot),
230
+ });
231
+ }
232
+
233
+ /** Change only where append()/addModule() insert future script elements. */
234
+ function setAppendTarget(target) {
235
+ if (!target || (typeof target.append !== "function" && typeof target.appendChild !== "function")) {
236
+ throw new TypeError("setAppendTarget() requires a target with append() or appendChild().");
237
+ }
238
+ controller.appendTo = target;
239
+ return api;
192
240
  }
193
241
 
194
242
  /** Define one element without automatically executing an entry module. */
@@ -213,6 +261,97 @@ export function createHTMLRuntime(runtime, options = {}) {
213
261
  return result.modules[0] ?? definitionFromOwnedElement(controller, element);
214
262
  }
215
263
 
264
+ /**
265
+ * Re-read an owned script element and replace its runtime module source. The
266
+ * logical module id is stable; changing `module`/src-derived identity requires
267
+ * removeElement() + registerElement() instead.
268
+ */
269
+ async function updateElement(element, callOptions = {}) {
270
+ const record = requireOwnedRecord(controller, element, "updateElement");
271
+ const metadata = inspectElement(controller, element, {
272
+ ...callOptions,
273
+ existingId: record.moduleId,
274
+ });
275
+ const nextModuleId = normalizeModuleId(runtime, metadata.id);
276
+
277
+ if (nextModuleId !== record.moduleId) {
278
+ throw new HTMLModuleError(
279
+ `updateElement() cannot change module identity from ${JSON.stringify(record.moduleId)} to ${JSON.stringify(nextModuleId)}. Remove and register the element again instead.`,
280
+ { element, moduleId: record.moduleId, sourceUrl: metadata.sourceUrl },
281
+ );
282
+ }
283
+
284
+ const candidate = {
285
+ ...record,
286
+ format: metadata.format,
287
+ entry: metadata.entry,
288
+ sourceUrl: metadata.sourceUrl,
289
+ inline: !metadata.sourceUrl,
290
+ };
291
+
292
+ const { source } = await prepareRecordSource(controller, candidate, callOptions);
293
+ runtime.update(record.moduleId, source, { format: candidate.format });
294
+
295
+ record.format = candidate.format;
296
+ record.entry = candidate.entry;
297
+ record.sourceUrl = candidate.sourceUrl;
298
+ record.inline = candidate.inline;
299
+ record.state = "defined";
300
+ record.error = undefined;
301
+ record.definition = definitionFromRecord(record);
302
+
303
+ 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
+ }
313
+ }
314
+
315
+ return record.definition;
316
+ }
317
+
318
+ /**
319
+ * Release an element from this HTML controller. By default both the runtime
320
+ * module and DOM element are removed. DOM removal and module removal can be
321
+ * controlled independently.
322
+ */
323
+ async function removeElement(element, callOptions = {}) {
324
+ const record = requireOwnedRecord(controller, element, "removeElement");
325
+ const removeModule = callOptions.removeModule !== false;
326
+ const removeFromDOM = callOptions.removeFromDOM !== false;
327
+ const invalidateDependents = callOptions.invalidateDependents !== false;
328
+
329
+ let moduleRemoved = false;
330
+ if (removeModule) {
331
+ moduleRemoved = runtime.remove(record.moduleId, { invalidateDependents });
332
+ }
333
+
334
+ controller.bindings.delete(record.moduleId);
335
+ elementOwners.delete(element);
336
+ record.state = "removed";
337
+
338
+ let elementRemoved = false;
339
+ if (removeFromDOM) {
340
+ elementRemoved = removeDOMElement(element);
341
+ } else {
342
+ // Keep a released element in the observed tree from being immediately
343
+ // rediscovered. Explicit registerElement() can still claim it again.
344
+ controller.ignoredExisting.add(element);
345
+ }
346
+
347
+ return {
348
+ id: record.moduleId,
349
+ element,
350
+ moduleRemoved,
351
+ elementRemoved,
352
+ };
353
+ }
354
+
216
355
  /**
217
356
  * Claim a user-created script before DOM insertion, append it to this
218
357
  * controller's target, then register it directly. The observer will see the
@@ -326,6 +465,59 @@ export async function defineScript(runtime, element, options = {}) {
326
465
  export const htmlModuleSelector = DEFAULT_SELECTOR;
327
466
  export const htmlRuntimeScopeAttribute = RUNTIME_SCOPE_ATTRIBUTE;
328
467
 
468
+ async function connectObserver(controller, root, callOptions = {}, mode = {}) {
469
+ const MutationObserverImpl = callOptions.MutationObserver ?? controller.MutationObserver ?? globalThis.MutationObserver;
470
+
471
+ if (typeof MutationObserverImpl !== "function") {
472
+ throw new TypeError(
473
+ "observe() requires MutationObserver support or a MutationObserver option.",
474
+ );
475
+ }
476
+
477
+ controller.observerRoot = root;
478
+ controller.connected = true;
479
+ controller.lastError = undefined;
480
+ controller.ignoredExisting = new WeakSet();
481
+
482
+ const registerExisting = mode.registerExisting !== false;
483
+ if (!registerExisting) {
484
+ for (const element of Array.from(root.querySelectorAll(callOptions.selector ?? controller.selector))) {
485
+ if (acceptsDiscoveredElement(controller, element, callOptions)) {
486
+ controller.ignoredExisting.add(element);
487
+ }
488
+ }
489
+ }
490
+
491
+ const observer = new MutationObserverImpl(() => {
492
+ void enqueueObserverScan(controller, callOptions).catch(() => {});
493
+ });
494
+
495
+ controller.observer = observer;
496
+ observer.observe(root, {
497
+ childList: true,
498
+ subtree: callOptions.subtree ?? controller.subtree,
499
+ });
500
+
501
+ const result = registerExisting
502
+ ? await enqueueObserverScan(controller, {
503
+ ...callOptions,
504
+ origin: mode.initial ? "observer-initial" : "observer-root-change",
505
+ })
506
+ : emptyRegistrationResult();
507
+
508
+ if (mode.initial) controller.initial = result;
509
+ return result;
510
+ }
511
+
512
+ function stopObserver(controller, options = {}) {
513
+ if (!controller.connected && !controller.observer) return;
514
+ controller.connected = false;
515
+ controller.observer?.disconnect();
516
+ controller.observer = undefined;
517
+ controller.observerRoot = undefined;
518
+ if (!options.preserveOptions) controller.observerOptions = undefined;
519
+ }
520
+
329
521
  async function enqueueObserverScan(controller, callOptions = {}) {
330
522
  const task = controller.queue.then(async () => {
331
523
  if (!controller.connected) return emptyRegistrationResult();
@@ -550,7 +742,7 @@ function inspectElement(controller, element, options = {}) {
550
742
  sourceUrl,
551
743
  });
552
744
 
553
- const id = explicitId ?? sourceUrl ?? (entry ? createAnonymousId(format) : undefined);
745
+ const id = explicitId ?? sourceUrl ?? (entry ? options.existingId ?? createAnonymousId(format) : undefined);
554
746
  if (!id) {
555
747
  throw new HTMLModuleError(
556
748
  "Inline solid runtime module scripts require a `module` attribute unless they are marked `entry`.",
@@ -561,6 +753,35 @@ function inspectElement(controller, element, options = {}) {
561
753
  return { id, format, entry, sourceUrl };
562
754
  }
563
755
 
756
+ function requireOwnedRecord(controller, element, caller) {
757
+ assertElement(element, caller);
758
+ const record = elementOwners.get(element);
759
+ if (!record) {
760
+ throw new HTMLModuleOwnershipError(
761
+ `${caller}() requires an element already owned by this HTML runtime.`,
762
+ { element, requestedScope: controller.scope },
763
+ );
764
+ }
765
+ if (record.owner !== controller) throw ownershipError(controller, element, record);
766
+ return record;
767
+ }
768
+
769
+ function removeDOMElement(element) {
770
+ if (typeof element?.remove === "function") {
771
+ const wasConnected = Boolean(element.parentNode);
772
+ element.remove();
773
+ return wasConnected || !element.parentNode;
774
+ }
775
+
776
+ const parent = element?.parentNode;
777
+ if (parent && typeof parent.removeChild === "function") {
778
+ parent.removeChild(element);
779
+ return true;
780
+ }
781
+
782
+ return false;
783
+ }
784
+
564
785
  function definitionFromRecord(record) {
565
786
  return {
566
787
  id: record.moduleId,
@@ -669,6 +890,10 @@ function getAppendTarget(controller, options = {}) {
669
890
  if (explicit) return explicit;
670
891
 
671
892
  const root = options.root ?? controller.root ?? globalThis.document;
893
+ return defaultAppendTargetForRoot(root);
894
+ }
895
+
896
+ function defaultAppendTargetForRoot(root) {
672
897
  if (root?.body) return root.body;
673
898
  if (root?.documentElement && root.documentElement !== root) return root.documentElement;
674
899
  return root;
package/src/runtime.js CHANGED
@@ -27,6 +27,9 @@ export function createRuntime(options = {}) {
27
27
  const records = new Map();
28
28
  let anonymousSequence = 0;
29
29
  let disposed = false;
30
+ const subscribers = new Set();
31
+ let eventBufferDepth = 0;
32
+ const eventBuffer = [];
30
33
 
31
34
  const globalRegistry = getHostRegistry();
32
35
  const hostNamespaces = new Map();
@@ -34,6 +37,7 @@ export function createRuntime(options = {}) {
34
37
 
35
38
  const api = {
36
39
  define,
40
+ defineMany,
37
41
  defineModule,
38
42
  defineUrl,
39
43
  update,
@@ -43,6 +47,9 @@ export function createRuntime(options = {}) {
43
47
  toComponent,
44
48
  has,
45
49
  invalidate,
50
+ remove,
51
+ clear,
52
+ subscribe,
46
53
  modules: listModules,
47
54
  dependencies,
48
55
  dependents,
@@ -61,11 +68,57 @@ export function createRuntime(options = {}) {
61
68
  }
62
69
 
63
70
  function define(id, source, defineOptions = {}) {
71
+ return defineSource(id, source, defineOptions, "define");
72
+ }
73
+
74
+ function defineMany(definitions) {
75
+ assertActive();
76
+ if (!Array.isArray(definitions)) {
77
+ throw new TypeError("defineMany(definitions) expects an array of source module definitions.");
78
+ }
79
+
80
+ const prepared = definitions.map((definition, index) => {
81
+ if (!definition || typeof definition !== "object") {
82
+ throw new TypeError(`defineMany() item ${index} must be an object with id and source.`);
83
+ }
84
+ if (definition.id == null) {
85
+ throw new TypeError(`defineMany() item ${index} is missing id.`);
86
+ }
87
+ const id = normalizeDefinedId(definition.id);
88
+ return {
89
+ id,
90
+ source: String(definition.source ?? ""),
91
+ format: definition.format ?? "jsx",
92
+ };
93
+ });
94
+
95
+ const seen = new Set();
96
+ for (const item of prepared) {
97
+ if (seen.has(item.id)) {
98
+ throw new SolidTagRuntimeError(`defineMany() contains duplicate module id ${JSON.stringify(item.id)}.`);
99
+ }
100
+ seen.add(item.id);
101
+ }
102
+
103
+ eventBufferDepth += 1;
104
+ try {
105
+ return prepared.map(item => defineSource(item.id, item.source, { format: item.format }, "defineMany"));
106
+ } finally {
107
+ eventBufferDepth -= 1;
108
+ if (eventBufferDepth === 0) flushEventBuffer();
109
+ }
110
+ }
111
+
112
+ function update(id, source, updateOptions = {}) {
113
+ return defineSource(id, source, updateOptions, "update");
114
+ }
115
+
116
+ function defineSource(id, source, defineOptions = {}, operation = "define") {
64
117
  assertActive();
65
118
  const normalized = normalizeDefinedId(id);
66
119
  const existing = records.get(normalized);
67
120
 
68
- if (existing) invalidate(normalized, { dependents: true });
121
+ if (existing) invalidate(normalized, { dependents: true, reason: operation });
69
122
 
70
123
  const record = {
71
124
  id: normalized,
@@ -84,18 +137,23 @@ export function createRuntime(options = {}) {
84
137
  };
85
138
 
86
139
  records.set(normalized, record);
140
+ emit({
141
+ type: "module-defined",
142
+ id: normalized,
143
+ kind: record.kind,
144
+ version: record.version,
145
+ operation,
146
+ replaced: Boolean(existing),
147
+ module: toModuleInfo(record),
148
+ });
87
149
  return normalized;
88
150
  }
89
151
 
90
- function update(id, source, updateOptions = {}) {
91
- return define(id, source, updateOptions);
92
- }
93
-
94
152
  function defineModule(id, namespace) {
95
153
  assertActive();
96
154
  const normalized = normalizeDefinedId(id);
97
155
  const existing = records.get(normalized);
98
- if (existing) invalidate(normalized, { dependents: true });
156
+ if (existing) invalidate(normalized, { dependents: true, reason: "defineModule" });
99
157
 
100
158
  if ((typeof namespace !== "object" || namespace === null) && typeof namespace !== "function") {
101
159
  throw new TypeError("defineModule(id, namespace) expects an object, function, or ES module namespace-like value.");
@@ -103,7 +161,7 @@ export function createRuntime(options = {}) {
103
161
 
104
162
  hostNamespaces.set(normalized, namespace);
105
163
 
106
- records.set(normalized, {
164
+ const record = {
107
165
  id: normalized,
108
166
  kind: "host",
109
167
  namespace,
@@ -112,6 +170,16 @@ export function createRuntime(options = {}) {
112
170
  dependencies: new Set(),
113
171
  dependents: existing?.dependents ?? new Set(),
114
172
  url: undefined,
173
+ };
174
+ records.set(normalized, record);
175
+ emit({
176
+ type: "module-defined",
177
+ id: normalized,
178
+ kind: record.kind,
179
+ version: record.version,
180
+ operation: "defineModule",
181
+ replaced: Boolean(existing),
182
+ module: toModuleInfo(record),
115
183
  });
116
184
 
117
185
  return normalized;
@@ -121,10 +189,10 @@ export function createRuntime(options = {}) {
121
189
  assertActive();
122
190
  const normalized = normalizeDefinedId(id);
123
191
  const existing = records.get(normalized);
124
- if (existing) invalidate(normalized, { dependents: true });
192
+ if (existing) invalidate(normalized, { dependents: true, reason: "defineUrl" });
125
193
 
126
194
  const href = String(url);
127
- records.set(normalized, {
195
+ const record = {
128
196
  id: normalized,
129
197
  kind: "url",
130
198
  externalUrl: href,
@@ -134,6 +202,16 @@ export function createRuntime(options = {}) {
134
202
  dependents: existing?.dependents ?? new Set(),
135
203
  importPromise: undefined,
136
204
  namespace: undefined,
205
+ };
206
+ records.set(normalized, record);
207
+ emit({
208
+ type: "module-defined",
209
+ id: normalized,
210
+ kind: record.kind,
211
+ version: record.version,
212
+ operation: "defineUrl",
213
+ replaced: Boolean(existing),
214
+ module: toModuleInfo(record),
137
215
  });
138
216
 
139
217
  return normalized;
@@ -155,23 +233,36 @@ export function createRuntime(options = {}) {
155
233
 
156
234
  if (record.kind === "url") {
157
235
  if (!record.importPromise) {
236
+ record.state = "evaluating";
237
+ emit({ type: "module-evaluating", id: record.id, kind: record.kind, version: record.version });
158
238
  record.importPromise = import(record.externalUrl).then(namespace => {
159
239
  record.namespace = namespace;
160
240
  record.state = "ready";
241
+ emit({ type: "module-evaluated", id: record.id, kind: record.kind, version: record.version, module: toModuleInfo(record) });
161
242
  return namespace;
243
+ }).catch(error => {
244
+ record.importPromise = undefined;
245
+ record.state = "error";
246
+ emitError(record, "evaluate", error);
247
+ throw error;
162
248
  });
163
249
  }
164
250
  return record.importPromise;
165
251
  }
166
252
 
167
253
  if (!record.importPromise) {
254
+ record.state = "evaluating";
255
+ emit({ type: "module-evaluating", id: record.id, kind: record.kind, version: record.version });
168
256
  record.importPromise = ensureSourceUrl(record, []).then(url => import(url)).then(namespace => {
169
257
  record.namespace = namespace;
170
258
  record.state = "ready";
259
+ emit({ type: "module-evaluated", id: record.id, kind: record.kind, version: record.version, module: toModuleInfo(record) });
171
260
  return namespace;
172
261
  }).catch(error => {
173
262
  record.importPromise = undefined;
263
+ const alreadyReported = record.state === "error";
174
264
  record.state = "error";
265
+ if (!alreadyReported) emitError(record, "evaluate", error);
175
266
  throw error;
176
267
  });
177
268
  }
@@ -241,6 +332,7 @@ export function createRuntime(options = {}) {
241
332
  assertActive();
242
333
  const normalized = normalizeLookupId(id);
243
334
  const includeDependents = invalidateOptions.dependents ?? true;
335
+ const reason = invalidateOptions.reason ?? "explicit";
244
336
  const seen = new Set();
245
337
 
246
338
  invalidateOne(normalized);
@@ -271,7 +363,71 @@ export function createRuntime(options = {}) {
271
363
  record.namespace = undefined;
272
364
  record.state = "defined";
273
365
  }
366
+
367
+ emit({
368
+ type: "module-invalidated",
369
+ id: record.id,
370
+ kind: record.kind,
371
+ version: record.version,
372
+ reason,
373
+ });
374
+ }
375
+ }
376
+
377
+ function remove(id, removeOptions = {}) {
378
+ assertActive();
379
+ const normalized = normalizeLookupId(id);
380
+ const record = records.get(normalized);
381
+ if (!record) return false;
382
+
383
+ if (removeOptions.invalidateDependents !== false) {
384
+ for (const dependent of [...record.dependents]) {
385
+ invalidate(dependent, { dependents: true, reason: `dependency-removed:${normalized}` });
386
+ }
387
+ }
388
+
389
+ for (const dependency of record.dependencies) {
390
+ records.get(dependency)?.dependents.delete(normalized);
391
+ }
392
+
393
+ revokeRecordUrl(record);
394
+ if (record.kind === "host") hostNamespaces.delete(normalized);
395
+ records.delete(normalized);
396
+
397
+ emit({
398
+ type: "module-removed",
399
+ id: normalized,
400
+ kind: record.kind,
401
+ version: record.version,
402
+ invalidateDependents: removeOptions.invalidateDependents !== false,
403
+ });
404
+ return true;
405
+ }
406
+
407
+ function clear(clearOptions = {}) {
408
+ assertActive();
409
+ const preserveHostModules = clearOptions.preserveHostModules !== false;
410
+ const ids = [...records.values()]
411
+ .filter(record => !(preserveHostModules && record.kind === "host"))
412
+ .map(record => record.id);
413
+
414
+ for (const id of ids) remove(id, { invalidateDependents: false });
415
+
416
+ emit({
417
+ type: "runtime-cleared",
418
+ ids: [...ids],
419
+ preserveHostModules,
420
+ });
421
+ return ids;
422
+ }
423
+
424
+ function subscribe(listener) {
425
+ assertActive();
426
+ if (typeof listener !== "function") {
427
+ throw new TypeError("subscribe(listener) expects a function.");
274
428
  }
429
+ subscribers.add(listener);
430
+ return () => subscribers.delete(listener);
275
431
  }
276
432
 
277
433
  function listModules() {
@@ -296,11 +452,13 @@ export function createRuntime(options = {}) {
296
452
  function dispose() {
297
453
  if (disposed) return;
298
454
  disposed = true;
455
+ emit({ type: "runtime-disposed" });
299
456
 
300
457
  for (const record of records.values()) revokeRecordUrl(record);
301
458
  records.clear();
302
459
  hostNamespaces.clear();
303
460
  globalRegistry.delete(runtimeId);
461
+ subscribers.clear();
304
462
  }
305
463
 
306
464
  function resolvePublic(specifier, importer) {
@@ -323,7 +481,9 @@ export function createRuntime(options = {}) {
323
481
  analysis = compiler.analyze(record.source, { id: record.id, format: record.format });
324
482
  } catch (error) {
325
483
  record.state = "error";
326
- throw new ModuleCompileError(record.id, error);
484
+ const wrapped = new ModuleCompileError(record.id, error);
485
+ emitError(record, "analyze", wrapped);
486
+ throw wrapped;
327
487
  }
328
488
 
329
489
  const resolvedImports = [];
@@ -394,11 +554,20 @@ export function createRuntime(options = {}) {
394
554
  }
395
555
  } catch (error) {
396
556
  record.state = "error";
397
- throw new ModuleCompileError(record.id, error);
557
+ const wrapped = new ModuleCompileError(record.id, error);
558
+ emitError(record, "transform", wrapped);
559
+ throw wrapped;
398
560
  }
399
561
 
400
562
  record.url = moduleUrlBackend.create(appendSourceUrl(record.compiledCode, record.id));
401
563
  record.state = "compiled";
564
+ emit({
565
+ type: "module-compiled",
566
+ id: record.id,
567
+ kind: record.kind,
568
+ version: record.version,
569
+ module: toModuleInfo(record),
570
+ });
402
571
  return record.url;
403
572
  }
404
573
 
@@ -472,6 +641,47 @@ export function createRuntime(options = {}) {
472
641
  }
473
642
  }
474
643
 
644
+ function emit(event) {
645
+ if (subscribers.size === 0) return;
646
+ const payload = {
647
+ runtimeId,
648
+ timestamp: Date.now(),
649
+ ...event,
650
+ };
651
+ if (eventBufferDepth > 0) {
652
+ eventBuffer.push(payload);
653
+ return;
654
+ }
655
+ dispatchEvent(payload);
656
+ }
657
+
658
+ function flushEventBuffer() {
659
+ if (eventBuffer.length === 0) return;
660
+ const pending = eventBuffer.splice(0, eventBuffer.length);
661
+ for (const event of pending) dispatchEvent(event);
662
+ }
663
+
664
+ function dispatchEvent(payload) {
665
+ for (const listener of [...subscribers]) {
666
+ try {
667
+ listener(payload);
668
+ } catch (error) {
669
+ globalThis.console?.error?.("solid-tag-runtime lifecycle subscriber error", error);
670
+ }
671
+ }
672
+ }
673
+
674
+ function emitError(record, phase, error) {
675
+ emit({
676
+ type: "module-error",
677
+ id: record?.id,
678
+ kind: record?.kind,
679
+ version: record?.version,
680
+ phase,
681
+ error,
682
+ });
683
+ }
684
+
475
685
  function toModuleInfo(record) {
476
686
  return {
477
687
  id: record.id,