npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

kopular

v0.15.0

Published

Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container

Downloads

2,834

Readme

Kopular

Kopular is a small component framework for KopScript, built to give Angular's separation of concerns — components own UI, services own logic, a router owns navigation — without Angular's steepest learning-curve pieces: no RxJS, no dependency-injection container, and templates that are real, compiled, type-checked KopScript rather than a separate interpreted template language.

Generating Kopular code with an AI coding assistant? Point it at LLM.md — a dense, complete reference designed to be loaded straight into an LLM's context.

Highlights

  • Component, with real vdom diffing: a base class with virtual Render() (describes the current state as a VElement tree — a lightweight description of a DOM element, not a real one) and Update() (diffs the new tree against the previous one and patches only what changed, reusing a real DOM node wherever its tag stays the same — not a full subtree rebuild). Render() is provided as a real, separate markup file compiled by KopScript's template from (see "Templates" below), or written by hand building a VElement tree against kopular/velement, the way you'd write careful vanilla-JS UI code — your choice, and both compile to the exact same thing. An optional virtual RenderError(message) renders a fallback instead of a hard crash if Render() throws — purely additive; not overriding it keeps today's exact (uncaught) behavior.
  • Templates, compiled and type-checked, not interpreted: markup lives in its own .html file — interpolation ({{ }}), event/property bindings ((click)="...", [prop]="..."), and *if/*for structural directives — desugared by the KopScript compiler into the exact same code a hand-written Render() would produce, with automatic Subscribe/Update() wiring for state<T> fields referenced directly in the markup. See "Templates" below.
  • Reactive state, no RxJS: components hold state<number>/state<string>/... (a KopScript language feature — see the KopScript repo) and subscribe once, in their constructor, to call Update() on change. No Observables, no operators, no manual unsubscribe bookkeeping.
  • Services, no DI container: "injecting" a service is just passing it as a constructor argument. No injector hierarchy, no provider tokens, no decorators — and a service stays fully testable with zero Component/DOM involvement, since it's just a class.
  • Router: real URLs (/about, not #/about) via the History API (pushState/popstate), with route registration as plain method calls, not a config DSL. See "Router, and deploying it" below — every deployment target needs its own SPA-fallback config, not just local dev.
  • Structural directives: *ngIf/*ngFor/*ngSwitch's job — build a subtree conditionally, repeat one per item, pick one of several cases. In a template, that's *if/*for (real if/for under the hood — see "Templates"); in a hand-written Render(), the same job is a plain function call (If(...)) or existing KopScript expression (array.ForEach(...), match) — no special syntax needed there either way. See "Structural directives" below.
  • Http: a thin, static wrapper over the real Fetch API (Http.Get(url), Http.Post(url, jsonBody), ...) — no HttpClient injection tokens, no RxJS observables/operators. See "HTTP" below.
  • FormField<T>: a single input's value/error/touched state, built on state<T> — no two-way-binding magic, no FormGroup config object. See "Forms" below.

What's here

  • src/dom.ks — ambient DOM bindings (document, Element, Event, window, location) that component.ks/router.ks/directives.ks are built on.
  • src/velement.ksVElement, the lightweight description of a DOM element Render() returns.
  • src/vdom.ks — the diff/patch engine (Materialize/Patch/PatchChildren) behind real vdom diffing — see "Component" above.
  • src/component.ks — the Component base class.
  • src/router.ks — the Router.
  • src/directives.ksIf(), the structural-directive equivalents' one genuinely new piece (see below).
  • src/http.ksHttp, a thin wrapper over fetch (see below). src/http_runtime.js is its one companion file — the single hand-written (not compiled from .ks) file in Kopular, and why is explained in its own header comment.
  • src/forms.ksFormField<T> and Validators (see "Forms" below).
  • src/testing.jsrunKopularApp/runKopularFixture (see "Testing your own app" below); hand-written for the same reason as http_runtime.js — filesystem/process orchestration isn't a Kopular Component.
  • bin/kp.mjs — the kp new scaffolding CLI (see "Starting a new project" below); also hand-written, same reason.

That's the whole framework — ten files, plus the scaffolding CLI. Everything else (a real app built on top of it) lives in a separate consumer repo, KopularDemo.

Templates

A component's Render() can be a real markup file instead of hand-written imperative DOM code — template from "./x.html"; in the class body, a KopScript language feature (see KopScript's own README/LLM.md for the full syntax reference). Kopular itself needed zero framework code changes for this — the compiler desugars a template straight into calls against the same VElement.Create/.AppendChild/.TextContent/named event fields velement.ks already declares, so a template-generated Render() is indistinguishable from one you'd write by hand:

// counter.ks
class Counter : Component {
  public state<number> Count;
  constructor() : base() { this.Count = state(0); }
  public void Increment() { this.Count.Value = this.Count.Value + 1; }
  template from "./counter.html";
}
<!-- counter.html -->
<button (click)="Increment()">Count: {{ Count.Value }}</button>

Note there's no this.Count.Subscribe(...) anywhere — a state<T> field referenced directly in the template (Count.Value above) gets it wired automatically. The exact same component, hand-written, needs that Subscribe call itself:

class Counter : Component {
  private state<number> Count;

  constructor() : base() {
    this.Count = state(0);
    this.Count.Subscribe((number v) => this.Update());
  }

  public override VElement Render() {
    VElement button = VElement.Create("button");
    button.TextContent = "Count: " + this.Count.Value;
    button.OnClick = (Event e) => {
      this.Count.Value = this.Count.Value + 1;
    };
    return button;
  }
}

Both produce the same Render(), and can be mixed freely across a codebase — nothing about Component, Update(), or any other Kopular API differs between them. The manual Subscribe call is still exactly what you need the moment state is reached indirectly — through a method call, or through an injected service's own state (this.Service.Count, say) — which is why the real, production Counter on the KopularDemo site (it injects a CounterService rather than holding Count itself) uses a template but still has one manual Subscribe. *if/*for in a template are covered under "Structural directives" below, alongside their hand-written-Render() equivalents.

Dependency injection: the composition root pattern

Kopular has no injector because KopScript has nothing for one to hook into — no decorators, no reflection, and no generic functions (KopScript's generics are classes/interfaces only — see the KopScript repo) for a type-safe Resolve<T>(). Instead, the whole app's service/page graph gets built exactly once, by hand, in one place: a plain class with no Component base and no framework code in it at all, sometimes called an app container or (in the wider DI literature) a composition root. Everything else just takes what it needs as constructor arguments and never constructs its own dependencies.

// app_container.ks — the one place that decides what's shared and builds
// the graph, in dependency order.
class AppContainer {
  public Router Nav;

  constructor() {
    // Built once, passed to every page that needs it below — that's the
    // whole mechanism for a shared singleton. A page that constructed its
    // own `new CounterService()` instead would get an independent one; the
    // difference is which variable gets passed in, not a config flag.
    CounterService counter = new CounterService();

    this.Nav = new Router(new NotFoundPage());
    this.Nav.AddRoute("/", new HomePage(this.Nav, counter));
    this.Nav.AddRoute("/about", new AboutPage(this.Nav));
  }
}

// routed_app.ks — the root Component. Takes the already-built graph; never
// builds one of its own.
class RoutedApp : Component {
  private Router Nav;

  constructor(AppContainer services) : base() {
    this.Nav = services.Nav;
  }

  public override VElement Render() {
    return VElement.Create("div");
  }

  // Mounting the Router — a live, nested Component — isn't something a
  // VElement tree can express as data (see "Component" above). AfterRender
  // is called with the real DOM node Render()'s tree just became, once
  // Mount()/Update() has actually materialized/patched it — here, that's
  // the container itself.
  protected override void AfterRender(Element root) {
    this.Nav.Mount(root);
  }
}

RoutedApp app = new RoutedApp(new AppContainer());
app.Mount(document.body);

This is sometimes called "Pure DI" — the same benefits a container gives you (nothing hardcodes its own dependencies, everything is swappable in a test) with none of a container's cost:

  • Compile-time checked. A missing or mistyped dependency is expected N arguments, got M from the KopScript compiler, not a NullInjectorError your users hit at runtime after a container fails to resolve something.
  • Fully legible. The entire dependency graph is ordinary, readable code in one file — grep for new in the composition root and you've read the whole wiring diagram. Nothing is constructed by a framework inspecting metadata behind the scenes.
  • No new concepts. If you already know how to call a constructor, you already know Kopular's DI story — there's no separate injector API, provider syntax, or injection-token vocabulary to learn.

See KopularDemo's src/app_container.ks and src/routed_app.ks for the real, working version this example is drawn from.

Router, and deploying it

Router nav = new Router(new NotFoundPage());   // fallback page required up front — no null route
nav.AddRoute("/", new HomePage(nav));           // pages built once, kept alive for Router's lifetime
nav.AddRoute("/about", new AboutPage(nav));
nav.Mount(document.body);
nav.Navigate("/about");                          // pushState + immediate re-render

Real URLs via the History API (pushState/popstate), not #/about hash routing. AddRoute takes an already-constructed Component, not a factory, so a page's own state<T> survives navigating away and back — see "Dependency injection" above for how the whole page graph typically gets built once, in a composition root.

Dynamic route segments: a path segment written :name (e.g. /dogs/:id) matches any single non-empty segment. With just one per route, its value is captured into Router.Param:

nav.AddRoute("/dogs/:id", new DogPage(nav));
// inside DogPage.Render():
el.TextContent = "Dog #" + this.Nav.Param;

Param is a plain string, deliberately not state<T> — Router's own AfterRender already re-Mount()s the matched page into its outlet on every Navigate()/popstate, which re-runs that page's Render() (reading the fresh Param) with no extra step. No Subscribe() needed on it.

More than one dynamic segment, and a trailing wildcard segment, both work too — read each by name via Router.Params(name) instead (Param above still holds the first captured value either way, so existing single-segment code needs no change):

nav.AddRoute("/dogs/:id/toys/:toyId", new ToyPage(nav));
// inside ToyPage.Render():
el.TextContent = "Dog " + this.Nav.Params("id") + " / Toy " + this.Nav.Params("toyId");

nav.AddRoute("/files/*", new FilesPage(nav));
// "/files/2026/reports/q1.pdf" -> Params("*") == "2026/reports/q1.pdf"

Query strings are always available via Router.Query(key), independent of which route matched (never part of route matching itself):

// "/search?sort=name&order=asc"
this.Nav.Query("sort")   // "name"
this.Nav.Query("missing") // "" — not present

Query values are not percent-decoded — a deliberate v1 cut (no decodeURIComponent binding exists yet); a value containing %20 or + for a space arrives exactly as written in the URL.

Navigation guards: protect a route (or any set of routes) behind a check — SetGuard takes a redirect path plus a single (string) => bool checked before every navigation, including a direct load/refresh:

nav.SetGuard("/login", (string path) => {
  if (path == "/admin") { return authService.IsLoggedIn.Value; }
  return true;
});

One guard for the whole Router, not per-route — the guard function itself decides which paths it cares about, the same "a function, not a config object" style Http/DI already use. Defaults to always-allow when SetGuard is never called. Redirecting updates the URL too (via pushState), so refreshing a blocked path lands on the redirect again rather than back on the page the guard just rejected — pick a redirectPath the guard itself always allows, or it loops.

Deploying a Router-based app needs SPA/history-fallback configured on whatever you deploy to — this is true of every client-side router in every framework, not a Kopular gap. A direct load or a refresh at /about is a plain HTTP request that reaches your host before any JS has run, so nothing client-side (Router included) can intercept it; the host has to serve the app shell itself for any route it has no literal file for. KopularDemo hit exactly this in production (worked when navigated to via a link, 404'd on refresh) before its Cloudflare Workers config had this set:

// wrangler.jsonc
"assets": {
  "directory": "./public",
  "not_found_handling": "single-page-application"
}

Every static host has an equivalent option (Netlify, Vercel, nginx, ...) — search that host's docs for "SPA fallback" or "single-page application routing", the terminology is standard. For local dev, see KopularDemo's scripts/serve.mjs.

Structural directives

Angular's *ngIf/*ngFor/*ngSwitch are template syntax that expands, at compile time, into imperative view-container calls. In a Kopular template, *if/*for are exactly that — real KopScript if/for statements underneath (see "Templates" above), compiled by KopScript itself, not interpreted by Kopular at runtime. In a hand-written Render(), there's no separate directive syntax to reach for: each job maps onto a plain expression, and two of the three need nothing new at all.

| Angular | Kopular template | Kopular hand-written Render() | New code? | | ----------------- | ---------------- | -------------------------------------------- | :-------: | | *ngFor | *for="Type v of expr" | array.ForEach((item) => ...) | none — already a KopScript array method | | *ngSwitch | (not supported — use *if, or switch in the backing class) | match value { ... } | none — already a KopScript expression, and exhaustiveness-checked (*ngSwitch isn't) | | *ngIf / *ngIf-else | *if="expr" | If(condition, () => ..., () => ...) | directives.ks (hand-written form only — a template's *if needs no helper, it's a real if) |

The rest of this section is about the hand-written Render() column above — a template's *if/*for need no further explanation, they're covered under "Templates". *ngIf is the one case in hand-written Render() that needs something new: if is a statement in KopScript, so without a helper you'd need a throwaway mutable local just to get a conditional value out of it. If() is that helper — nothing more than:

VElement If(bool condition, () => VElement whenTrue, () => VElement whenFalse) {
  if (condition) {
    return whenTrue();
  }
  return whenFalse();
}

Both branches are required (same reasoning Router uses for requiring a NotFoundPage up front — see router.ks): v1 has no nullable types, so "render nothing" has no value to hand back. Only the branch actually taken runs — the other lambda is never called, so an explicit empty branch (() => VElement.Create("span")) costs nothing when there's genuinely nothing to show.

All three read the same way, right inside a hand-written Render() — no directive registration, nothing to import beyond the function itself:

public override VElement Render() {
  VElement root = VElement.Create("div");

  // *ngIf
  root.AppendChild(If(this.User.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLoginButton()));

  // *ngFor
  VElement list = VElement.Create("ul");
  this.Items.ForEach((Item item) => { list.AppendChild(this.BuildItemRow(item)); });
  root.AppendChild(list);

  // *ngSwitch
  root.AppendChild(match this.Status {
    "loading" => this.BuildSpinner(),
    "error" => this.BuildError(),
    _ => this.BuildContent()
  });

  return root;
}

Give each Item's VElement a stable .Id (e.g. the item's own id) to make *ngFor trackBy-style row reuse automatic — Update()'s diff engine matches children by Id across a re-render, reusing a matched child's real DOM node (and anything stateful attached to it, like focus) rather than rebuilding it, the same way *ngFor's own trackBy avoids rebuilding unchanged rows. Without a stable Id, a list still renders correctly on reorder, but a given item's real node isn't guaranteed to follow its data.

HTTP

using "./http";

Response r = await Http.Get("/api/dogs");
if (r.ok) {
  string body = await r.text();
  print(body);
}

await Http.Post("/api/dogs", "{\"name\":\"Rex\"}");
await Http.Put("/api/dogs/1", "{\"name\":\"Rexy\"}");
await Http.Patch("/api/dogs/1", "{\"name\":\"Max\"}");
await Http.Delete("/api/dogs/1");

Http is a thin, static wrapper over the real Fetch API — Get/Post/Put/Patch/ Delete, each returning task<Response> (.ok, .status, async text()). No HttpClient to inject, no RxJS Observable/operators, no interceptors — call it from anywhere, including straight out of a service's own methods.

No typed JSON deserializationResponse.text() gets you the raw body, nothing more. This isn't a corner cut for v1; it's a direct consequence of two things KopScript doesn't have: generic functions/methods (KopScript's generics are classes/interfaces only, so there's no safe way to write a general task<T> Get<T>(string url)) and object-literal syntax ({ ... } as a value — see below). If you want a typed response, describe its shape as its own extern class and parse it yourself with a per-shape extern ... as "JSON.parse" declaration — the same trust-based approach extern already uses for everything else, not a new mechanism:

extern class DogDto {
  string name { get; }
};
extern DogDto ParseDog(string json) as "JSON.parse";

string body = await (await Http.Get("/api/dogs/1")).text();
DogDto dog = ParseDog(body);   // unchecked, like a TypeScript `as DogDto` cast

Why Post/Put/Patch/Delete aren't just extern bindings straight to fetch, the way Get is: setting a request method/body/headers means passing fetch a second argument that's a plain JS object literal ({ method, headers, body }) — and KopScript has no object-literal syntax at all, so it can't construct one. src/http_runtime.js is one small hand-written function that does, and Get/Delete-with-no-body skip it entirely (fetch(url) alone needs no options object, so Get binds straight to the real global). It's the one file in this package not compiled from .ks — everywhere else avoids the problem by only wrapping JS APIs that take plain positional arguments (see dom.ks's addEventListener(string, handler), never an options-object-taking API).

Forms

using "./forms";

FormField<string> email = new FormField<string>("", (string v) => {
  string? required = Validators.Required(v);
  if (required != null) { return required; }
  return Validators.Email(v);
});

email.Value.Value = "not-an-email";
print(email.Error.Value);   // "Must be a valid email"
print(email.Valid());       // false

// inside a hand-written Render(), building emailInput as a VElement:
emailInput.OnInput = (Event e) => {
  email.Value.Value = e.target.value;   // revalidates automatically
};
emailInput.OnBlur = (Event e) => { email.Touch(); };

FormField<T> holds one input's value, error, and touched state as three ordinary state<T> boxes — .Value (the input's current value, revalidating on every assignment), .Error (string?, the current validator's message or null), and .Touched (bool, set by calling .Touch() — typically on blur, so a fresh field with an invalid initial value like an empty required field doesn't show an error before the user has typed anything). Subscribe to any of the three from your Component's constructor exactly like Counter's own state<number>, to re-render when they change.

A validator is a plain (T) => string?null means valid, the same convention KopScript's own nullable types use elsewhere. There's no array-of-validators constructor parameter — KopScript has no syntax for an array of function values — so combining more than one check (as email does above) is an if-chain in one lambda, or, for the common case of just chaining a couple of already-built validators with no custom logic of their own, the CombineValidators2/CombineValidators3 free functions:

FormField<string> email = new FormField<string>("",
  CombineValidators2((string v) => Validators.Required(v), (string v) => Validators.Email(v)));

Free functions, not Validators methods, and fixed-arity (2 and 3) rather than a general Validators.All(...) — a class's own static method can't introduce a new type parameter beyond the class's own (see "Generics" in KopScript's own docs), so a generic combinator has to live as a free function instead. Validators ships the handful of checks almost every form needs (Required, MinLength, MaxLength, Email, Min, Max), each returning its own message; write your own validator function for anything more specific.

No two-way data binding in a hand-written Render() — wiring Value to a real <input> is the OnInput assignment shown above, the same manual pattern Counter already uses for its click handler (VElement.Value itself is one-way, host-to-DOM only — reading the current DOM value back out is always the real event's e.target.value, not something Kopular mirrors into Value for you). This is deliberate, not a missing feature: hand-written code already has direct field/handler access, so there's nothing for a magic binding to save you from writing. A template gets real sugar for exactly this — [(value)]="Field" desugars to [value]="Field" + (input)="Field = e.target.value" (see KopScript's own "Templates" docs) — since a markup file has no equivalent direct access to fall back on.

Starting a new project: kp new

Everything in the next section — the extern bindings, plus a vendor/kopular/ copy of this package's browser files and an import map pointing at it (a browser can't resolve a bare specifier like "kopular/component" the way Node's own module resolution does) — is boilerplate every Kopular project needs verbatim. Generate it instead of reconstructing it by hand (or from memory, if you're an AI agent):

npx kp new my-app
cd my-app
npm install
npm start   # builds, vendors kopular's browser files, and serves at :8080

This scaffolds a real, working Component (src/counter.ks — the same Counter shown above), the ambient DOM/Kopular extern bindings it needs (src/kopular_bindings.ks), and a README.md that points an AI agent at this package's own LLM.md before it starts generating code. kp ships from this package (not from kopscript's own ks CLI) since scaffolding a Kopular app is a framework concern, not a language one — ks stays a pure-language tool with no framework knowledge baked in.

Using Kopular from another KopScript project

KopScript's own using "./path"; only resolves relative paths within a project — it has no package-import mechanism yet. Cross-package consumption goes through extern instead, the same way KopScript already describes any other JS/npm dependency (kp new above generates exactly this, if you'd rather not hand-write it):

extern class VElement {
  static VElement Create(string tag);
  string TextContent { get; set; }
} from "kopular/velement";

extern class Component {
  constructor();
  virtual VElement Render();
  void Mount(Element parent);
} from "kopular/component";

extern class Router {
  constructor(Component notFoundPage);
  void AddRoute(string path, Component page);
  void Navigate(string path);
} from "kopular/router";

class MyWidget : Component {
  public override VElement Render() {
    VElement el = VElement.Create("div");
    el.TextContent = "Hello from MyWidget";
    return el;
  }
}

Marking Render() virtual in the extern declaration is what lets a real subclass override it — see KopularDemo for a full working example (components, a service, and routing, all consuming Kopular this way).

extern class can carry its own <T> (kopscript >= 0.5.0), so a generic export like FormField<T> describes the same way a real generic class does — see LLM.md's FormField<T>/Validators section for the full example.

Testing your own app: kopular/testing

A Component/Router graph can only be exercised end-to-end by actually compiling its .ks sources and running the result against a real DOM — there's no way to unit test one otherwise. Doing that by hand is a real ~50-line dance (a fresh temp dir per test, since Node's ESM module cache means re-importing the same compiled path twice never re-runs an ambient extern binding's top-level code — silently binding every later test to the first test's jsdom instance — compiling via kopscript's compileGraph, binding jsdom onto globalThis for document/Element/... to find, then restoring it). Both Kopular's own test suite and KopularDemo's used to hand-roll this independently; kopular/testing is that dance, written once:

import { runKopularApp } from "kopular/testing";

const { window, cleanup } = await runKopularApp(join(__dirname, "..", "src"), "app.ks", {
  includeKopularPackage: true, // your app consumes Kopular via `extern`, not relative `using`
});
try {
  expect(window.document.querySelector("h1")?.textContent).toBe("Hello");
} finally {
  cleanup(); // always — even on a thrown assertion — or the next test inherits these globals
}

jsdom is an optional peer dependency — installing kopular alone doesn't pull it in; only a project that actually calls runKopularApp needs it added too. See Kopular/test/kopular.test.ts (runKopularFixture, the sibling export used for testing Kopular's own source against inline fixtures) and KopularDemo's test/routed_app.test.ts for two real, different call sites.

Getting started (developing Kopular itself)

npm install   # pulls in kopscript as a devDependency
npm run build # compiles src/*.ks -> src/*.js (compiled output is gitignored)
npm test      # runs test/kopular.test.ts against a real DOM via jsdom

kopscript is a real published dependency (^0.1.0) — this repo doesn't need KopScript checked out as a sibling directory or anything else local to build or test.

Status

v1 / hobby-project scope, same as KopScript itself. Update() now diffs and patches real DOM (see "Component" above) rather than replacing a whole subtree on every re-render — but reconciliation is still per-Component, not across nested ones: if a parent Component's own Render() output changes shape around a slot where a nested Component was imperatively Mount()ed (via AfterRender — see Router's own pattern), that nested Component isn't automatically re-Mount()ed or torn down as part of the parent's diff, since a live mounted child isn't something a VElement tree can express as data. Composing independent components into a stable, unchanging slot — the way every real use of AfterRender in this codebase already does, Router's own outlet included — avoids the issue entirely. A list child without a stable VElement.Id similarly still renders correctly across a reorder, but isn't guaranteed to keep the same real DOM node (see "Structural directives" above). Update() called before Mount() is a safe no-op — see LLM.md's Component section for exactly when that happens (sibling pages sharing one injected service's state<T>).