solid-tag-runtime
v0.0.34
Published
Runtime module system for JSX modules compiled with solid-tag and executed through @solidjs/html
Maintainers
Readme
solid-tag-runtime
solid-tag-runtime is a runtime module system for dynamically defined Solid JSX and JavaScript modules. It compiles JSX with solid-tag, links runtime-defined modules, and can optionally integrate with browser HTML for declarative rendering.
source module
↓
solid-tag compiler
↓
pre-link compiled artifact
↓
solid-tag-runtime linker
↓
Solid runtime / browser moduleThe package is split into three layers:
solid-tag-runtime
core runtime, modules, compilation and cache
solid-tag-runtime/solid
high-level Solid family loading and createSolidRuntime()
solid-tag-runtime/html
declarative HTML discovery, rendering and <solid-render>Install
npm install solid-tag-runtime solid-tagQuick start
For the common browser setup, prefer the localStorage compile-cache backend when the compiled cache is expected to fit comfortably in localStorage:
import { createCompileCache } from "solid-tag-runtime";
import { createSolidRuntime } from "solid-tag-runtime/solid";
const runtime = await createSolidRuntime({
compileCache: createCompileCache({
storage: "localstorage",
name: "my-app",
}),
});
await runtime.htmlController.process();This:
- resolves the coordinated Solid runtime family
- creates the runtime
- prepares one idle default HTML controller
- enables persistent compile caching through localStorage
- installs the Solid DOM integration used by declarative rendering
- explicitly performs the initial declarative HTML processing pass
Current browser benchmarks show lower warm-cache lookup/compile latency from localStorage than IndexedDB for the tested compiled-artifact sizes. Prefer IndexedDB when larger storage capacity, asynchronous storage behavior, or application storage policy matters more than minimum lookup latency.
Persistent caching and HTML processing are both opt-in. createSolidRuntime() prepares runtime.htmlController, but it does not scan declarations or start observation until you ask it to:
const runtime = await createSolidRuntime();
// No declarative modules have been processed yet.
await runtime.htmlController.process();For the same one-shot processing during factory creation, use the convenience option:
const runtime = await createSolidRuntime({
processHTML: true,
});
// The same controller remains available for later work.
await runtime.htmlController.observe({ mode: "continuous" });Injecting a compatible HTML tag
createSolidRuntime() can replace only the default export of its coordinated @solidjs/html host namespace with a caller-owned compatible tag:
import { createCompileCache } from "solid-tag-runtime";
import { createSolidRuntime } from "solid-tag-runtime/solid";
import { createLocalStorageHtml } from "solid-html-cache/cachable";
const html = createLocalStorageHtml({
namespace: "my-app",
});
const runtime = await createSolidRuntime({
html,
compileCache: createCompileCache({
storage: "localstorage",
name: "my-app-modules",
}),
processHTML: true,
});Generated runtime modules remain provider-independent and still import:
import html from "@solidjs/html";The runtime linker resolves that static import to this runtime's host bridge, whose default export is the supplied tag. Named exports continue to come from the coordinated official @solidjs/html namespace. Changing only the injected tag does not fragment persistent syntax-compile cache identity.
The supplied tag is caller-owned: disposing the runtime does not clear, disable, flush, prune, or dispose it. Do not also provide modules["@solidjs/html"] when using html; if you need to supply the coordinated official namespace explicitly, use solid.modules. The injected tag should be built against the same compatible Solid family as the runtime.
Define and import runtime modules
runtime.define("/Greeting.jsx", `
export default function Greeting() {
return <p>Hello from runtime JSX</p>;
}
`);
const module = await runtime.import("/Greeting.jsx");
module.default;Runtime modules can import one another using normal relative module syntax.
runtime.define("/Button.jsx", `
export function Button() {
return <button>Hello</button>;
}
`);
runtime.define("/App.jsx", `
import { Button } from "./Button.jsx";
export default function App() {
return <Button />;
}
`);Private module imports
Application-owned values can also be injected as private imports for one source module without registering a runtime-global module:
const result = await fetchData(url);
runtime.define("/ui/hello.tsx", `
import { result } from "#state";
export default function App() {
return <div>Hello {result.name}</div>;
}
`, {
imports: {
"#state": { result },
},
});Private imports are importer-local host namespaces. They preserve exact JavaScript reference identity, are not returned by runtime.import("#state"), do not appear as global entries in runtime.modules(), and are not inherited by child modules. Different source modules may reuse the same private specifier with different values.
Private imports use #-prefixed specifiers in this release. If a runtime-global module has the same name, the private import shadows it only for the defining importer. The explicit resolve() hook remains the highest-priority override.
Inspect configured private imports from the host with:
const imports = runtime.getPrivateImports("/ui/hello.tsx");
imports?.get("#state");
runtime.getModuleInfo("/ui/hello.tsx")?.privateImports;Changing only private bindings causes relinking/evaluation but does not change pre-link compile-cache identity.
Lazy private-import providers
Private imports may also be lazy sync or async providers. Providers are not invoked by define(); they run only when the owning module actually imports that private specifier, and the resolved namespace is cached for the current module generation.
runtime.define("/ui/x.jsx", `
import { message, state } from "#private";
export default function App() {
return <h1>{message}: {state.count()}</h1>;
}
`, {
imports: {
"#private": async ({ import: importModule }) => ({
message: "Helloa",
state: await importModule("@state"),
}),
},
});The provider context contains runtime, importer, and an importer-aware import(specifier) helper. Imports made through that helper are recorded as dependencies of the owning source module. Replacing or invalidating a dependency such as @state therefore invalidates the importer, clears the cached private provider result, and causes the provider to run again on the next import.
Use the context import() helper when dependency tracking matters. A provider can still close over the runtime and call runtime.import() directly, but the runtime does not infer that arbitrary call as a provider dependency. Provider failures reject the owning module import, and private-provider/source dependency cycles fail with CircularDependencyError. Provider resolution remains post-compile/link-time state and does not affect compile-cache identity.
Module readiness
runtime.import(id) remains an immediate resolution operation. When a runtime-owned module will be registered later (for example by htmlController.process()), use the readiness API instead:
const statePromise = runtime.waitForModule("@state");
await runtime.htmlController.process();
const state = await statePromise;waitForModule() waits only for registration in this runtime; it does not fall through to native/browser resolution or repeatedly invoke fallback resolvers while pending. Once the module is registered it is imported/evaluated normally, so real module errors propagate unchanged.
const state = await runtime.waitForModule("@state", {
timeout: 5000,
signal,
});There is no default timeout. A timeout rejects with ModuleWaitTimeoutError; an AbortSignal or runtime disposal also cleans up an outstanding wait.
Private providers expose the tracked equivalent:
imports: {
"#state": async ({ waitForModule }) =>
waitForModule("@state"),
}Provider-context waitForModule() records the resolved runtime module as a dependency of the owning importer, so replacement invalidates the importer and re-runs the provider. For one-time host-side readiness, PromiseLike private definitions are also supported:
imports: {
"#state": runtime.waitForModule("@state"),
}That PromiseLike convenience form does not infer a provider dependency edge. Use the provider-context form when invalidation/relink tracking matters.
The four related operations are:
runtime.import() import now
runtime.waitForModule() wait for runtime registration
providerContext.import() import now + track dependency
providerContext.waitForModule() wait for registration + track dependencyDeclarative HTML
Runtime modules can be declared directly in the document. Start the initial one-shot processing explicitly with the runtime's default controller:
const runtime = await createSolidRuntime();
await runtime.htmlController.process();Configure the idle controller without starting processing through the factory when you need a custom root/scope/style policy:
const runtime = await createSolidRuntime({
htmlController: {
root: document,
scope: "main",
acceptUnscoped: false,
},
});createSolidRuntime({ processHTML: true }) is shorthand for the same initial processing step. The controller remains available afterward for observation, additions, root changes, subscriptions, and other HTML lifecycle operations.
For example, declarations can live directly in the page:
<div id="app"></div>
<script
type="solid-jsx"
module="/App.jsx"
render="#app"
>
export default function App() {
return <h1>Hello</h1>;
}
</script>Bare render mounts in place without adding a wrapper:
<script type="solid-jsx" module="/Message.jsx" render>
export default function Message() {
return <button onClick={() => console.log("clicked")}>Click</button>;
}
</script>For an additional explicitly managed controller, use the HTML entrypoint directly:
import { createHTMLRuntime } from "solid-tag-runtime/html";
const preview = createHTMLRuntime(runtime, {
root: previewRoot,
scope: "preview",
});
await preview.process();Explicit controllers are independent by default. With a low-level createRuntime() instance, pass asDefault: true when that controller should become runtime.htmlController.
<solid-render>
Reusable module instances can be rendered with the custom element:
<script type="solid-jsx" module="/Counter.jsx">
export default function Counter(props) {
return <button>Count: {props.initial}</button>;
}
</script>
<solid-render
module="/Counter.jsx"
prop:initial="{100}"
></solid-render>Always use an explicit closing tag. Custom elements are not HTML void elements.
Declarative props
Plain prop:* values remain strings:
<solid-render prop:label="123"></solid-render>One outer {...} pair opts an individual prop into safe typed-data parsing:
<solid-render
prop:count="{3}"
prop:enabled="{true}"
prop:items="{[1, 2, 3]}"
prop:options="{{ compact: true }}"
></solid-render>Bulk structured props use the safe JSON5-style data syntax:
<solid-render
module="/Counter.jsx"
props="{ initial: 100, options: { theme: 'dark' } }"
></solid-render>Declarative prop syntax is data-only and never evaluates JavaScript expressions. Programmatic .props remains available for functions, signals, services, class instances and other identity-sensitive JavaScript values.
Precedence is:
props
< prop:*
< element.propsAvoiding initial <solid-render> FOUC
The HTML runtime hides pending <solid-render> content automatically after the runtime has initialized. JavaScript cannot prevent already-parsed light-DOM children from painting before the library itself loads.
For strict zero-FOUC initial markup, put the preflight rule in <head>:
<style>
solid-render:not([show-until-ready]):not(:defined),
solid-render:not([show-until-ready])[data-solid-render-state="pending"] {
visibility: hidden;
}
</style>If the application owns that rule, duplicate package style injection can be disabled:
const runtime = await createSolidRuntime({
processHTML: {
injectSolidRenderStyles: false,
},
});show-until-ready intentionally keeps fallback children visible while the component is pending.
HTML processing compatibility names
htmlController.register(), registerHTML(runtime), and createSolidRuntime({ registerHTML: ... }) remain available as deprecated compatibility aliases in 0.0.31. New code should use process(), processHTML(), and processHTML:.
Persistent compile cache
Persistent caching stores pre-link compiler artifacts. Runtime-specific linked URLs and evaluated module namespaces are not persisted.
For typical browser workloads, prefer localStorage when the compiled cache is expected to fit comfortably within its storage limits:
import { createCompileCache } from "solid-tag-runtime";
import { createSolidRuntime } from "solid-tag-runtime/solid";
const runtime = await createSolidRuntime({
compileCache: createCompileCache({
name: "my-app",
version: "2",
storage: "localstorage",
}),
});Current browser benchmarks show lower warm-cache latency for localStorage than IndexedDB across the tested small, large, and stress artifacts. The tradeoff is that localStorage is synchronous and usually offers less capacity. IndexedDB remains useful when larger persistent caches or asynchronous storage behavior are more important.
The shortest convenience form is still available:
const runtime = await createSolidRuntime({
compileCache: true,
});compileCache: true currently uses the existing IndexedDB-backed default for backward compatibility. cache: true is also accepted as a convenience alias, while compileCache remains the canonical option name.
The low-level localStorage configuration is also available:
import { createLocalStorageCompileCache } from "solid-tag-runtime";
const runtime = await createSolidRuntime({
compileCache: {
store: createLocalStorageCompileCache({
prefix: "my-app-runtime:",
}),
namespace: "main",
version: "2",
},
});Supported built-in stores include localStorage, IndexedDB and memory. Memory is the lowest-overhead non-persistent backend. Cache failures remain optimization failures and normal compilation continues whenever possible.
Observation strategies
If runtime declarations may be inserted outside explicit runtime APIs, the HTML controller supports observation strategies:
const html = runtime.htmlController;
// One repeatable one-shot pass, then explicit runtime APIs.
await html.process();
// Observe startup activity, then disconnect after relevant activity settles.
await html.observe({ mode: "bootstrap", idleMs: 50 });
// Observe unknown external DOM mutations throughout the page lifetime.
await html.observe({ mode: "continuous" });Controlled dynamic module loading does not require a persistent MutationObserver. Explicit APIs such as append() and addModule() register exact declarations directly.
For a known raw-DOM mutation when observation is disabled, run another one-shot pass. You can limit it to the changed subtree without changing the controller's configured root:
root.insertAdjacentHTML("beforeend", markup);
await html.process({ root });When an observer is connected, flush() first drains queued observer work and then forces a discovery scan of the observer root.
Scoped HTML runtimes
Multiple runtimes can share a document using scopes:
const html = createHTMLRuntime(runtime, {
scope: "main",
acceptUnscoped: false,
});<script
type="solid-jsx"
data-solid-runtime="main"
module="/App.jsx"
>
...
</script>For <solid-render>, scope is a shorter alias for the same routing target:
<solid-render scope="main" module="/App.jsx"></solid-render>data-solid-runtime remains the canonical cross-element attribute and wins if both are present. An unscoped default controller does not consume declarations explicitly assigned to another scope.
Host modules
Application values can be exposed as normal module namespaces:
const runtime = await createSolidRuntime({
modules: {
"@app/config": {
apiUrl: "/api",
},
},
});Runtime source can then import them:
import { apiUrl } from "@app/config";Low-level runtime
createRuntime() remains synchronous, provider-agnostic and DOM-independent:
import * as Solid from "solid-js";
import * as SolidWeb from "@solidjs/web";
import html from "@solidjs/html";
import { createRuntime } from "solid-tag-runtime";
const runtime = createRuntime({
modules: {
"solid-js": Solid,
"@solidjs/web": SolidWeb,
"@solidjs/html": { default: html },
},
});If you want provider-aware Solid loading but still want to construct the runtime yourself:
import { createRuntime } from "solid-tag-runtime";
import { loadSolidModules } from "solid-tag-runtime/solid";
const solidModules = await loadSolidModules({
source: "auto",
fallbackProvider: "esm.sh",
});
const runtime = createRuntime({
modules: {
...solidModules,
"@app/state": state,
},
});Fallback resolution for unresolved packages
resolve() remains the override hook. fallbackResolve() is a lower-priority hook that runs only for an unresolved bare specifier after registered modules, absolute URLs, and virtual paths have had a chance to resolve.
const runtime = await createSolidRuntime({
fallbackResolve(specifier, importer) {
return `https://esm.sh/${specifier}`;
},
});A runtime module can then use a bare package import without pre-registering it:
import confetti from "canvas-confetti";Registered runtime modules still win, so an existing @state module is never captured by a generic CDN fallback. Absolute URLs such as https://esm.sh/pkg also bypass fallbackResolve() entirely. Missing local imports such as ./Button.jsx and /ui/Button.jsx remain runtime-resolution errors and are never converted into CDN requests.
If fallbackResolve() returns undefined, an unresolved bare specifier proceeds to the normal native/import-map fallback when allowNativeImports is enabled. A fallback result is resolved without recursively invoking either resolver hook.
See Runtime modules and resolution for the complete precedence rules.
Browser import maps
When loading directly in a browser, the package root mapping is enough if you only import solid-tag-runtime itself. If your source uses bare package subpaths such as solid-tag-runtime/solid or solid-tag-runtime/html, add a trailing-slash package-prefix mapping as well:
{
"imports": {
"solid-tag-runtime": "https://esm.sh/[email protected]",
"solid-tag-runtime/": "https://esm.sh/[email protected]/"
}
}That prefix keeps clean source specifiers such as:
import { createSolidRuntime } from "solid-tag-runtime/solid";
import { createHTMLRuntime } from "solid-tag-runtime/html";Subpath mapping and version pinning are separate concerns: the prefix mapping is needed for bare subpath imports, while the explicit @0.0.32 version is only needed when you want a pinned release. When pinning, keep the root and prefix on the same version.
The Solid helper reads existing resolution/import-map information but does not mutate import maps.
Documentation
- Getting started
- Solid runtime setup and providers
- Runtime modules and resolution
- HTML runtime and ownership
- Declarative rendering
- Wrapperless delegation
<solid-render>- Lifecycle events
- Persistent compile cache
- Benchmark methodology
- Core runtime API
- Solid integration API
- HTML runtime API
- Compile-cache API
Engineering invariants and design decisions are documented in ARCHITECTURE.md.
Current limitations
- runtime-defined source-module cycles are not supported yet
- generated browser modules require a compatible CSP for the selected module URL backend
- persistent compile caching is an optimization, not a security sandbox
- already-held namespace/component references are not mutated by module updates
- TypeScript/TSX support depends on the configured compiler
- the built-in provider layer currently supports esm.sh and jsDelivr; unsafe mixed-provider cases fail rather than silently loading a second Solid runtime
0.0.31 component-event compiler integration and package hygiene
- Uses
[email protected]and compiler ABIsolid-tag-runtime:[email protected]:compiler:v1. - Fixes non-inline component
on*expressions such asonChange={handler}after browser conformance showed the previous zero-argument getter form was delivered as the callback itself. - Source distribution includes
examples/getting-started.htmlwith the idiomaticonChange/onReadyAPI restored. - npm/bun pack excludes repository-only examples, benchmarks, architecture, tests, handoff files, and plans; public docs remain published.
- Source ZIP packaging removes historical tarballs and embeds only the current release tarball.
Runtime-local npm package versions (0.0.33 candidate)
Both the synchronous base runtime and /solid support optional exact npm package pins. They do not modify the browser's import map. One root pin implicitly covers subpath imports.
import { createRuntime } from "solid-tag-runtime";
const runtime = createRuntime({
packages: { provider: "esm.sh", versions: { "is-odd": "3.0.1", "lodash-es": "4.17.21" } },
});
runtime.resolve("lodash-es/debounce"); // https://esm.sh/[email protected]/debounceimport { createSolidRuntime } from "solid-tag-runtime/solid";
const runtime = await createSolidRuntime({
solid: { source: "esm.sh", version: "2.0.0-rc.13" },
packages: { provider: "esm.sh", versions: { "is-odd": "3.0.1" } },
});An explicit Solid version no longer silently uses a conflicting host mapping. Pinned Solid currently allows esm.sh only; jsDelivr pinned families and same-document cross-version delegation are not verified. Pinning applies to direct runtime-managed imports, not arbitrary transitive CDN imports. See package version resolution and Solid version resolution.
Package sources and independent runtime versions (0.0.34)
packages.versions accepts plain npm versions and provider/source-prefixed strings per entry:
const a = createRuntime({ packages: { versions: { versioning: "esm.sh:gh/mizulu/[email protected]" } } });
const b = createRuntime({ packages: { versions: { versioning: "esm.sh:gh/mizulu/[email protected]", "lodash-es": "jsdelivr:4.17.21" } } });
const [one, two] = await Promise.all([a.import("versioning"), b.import("versioning")]);
// one.VERSION === "0.0.1", two.VERSION === "0.0.2" (when the CDN imports succeed)See Package sources and versions, or open the Advanced → Runtime examples → Package sources & providers section of examples/getting-started.html.
