Status: design · Priority: high · Depends on: File-Based Windows & Pages, manifest (✅ shipped), registry spine (✅ shipped)
Note: This is a design record, not a commitment. It exists so
new Window(RID),navigate(), and internal<a href>get built on answered questions instead of silent guesses. Open questions for the owner are at the bottom.
Shipped → main docs. Mounts, per-instance state, props, and independence are implemented and documented for users in Windows & Routes and `Window` / `useWindow`. This page keeps the internals (contexts, scopes, dispatch, factories) and what remains.
Mounting a route is easy for nodes and hard for state. Today everything is global:
| Subsystem | Today (entry) | Problem for two mounts |
|---|---|---|
morphState |
global __st_* signals |
two windows would share one count |
| Effects | global pool, destroy_all_effects() |
unmounting one window must not kill the other's effects |
mid dispatch |
global setCount(mid,v) switching over global signals |
per-mount signals need per-mount dispatch |
| Shared stores | global app::<ns>:: |
none — global is the correct semantics (see below) |
| Events | global morph::channel registry |
none — app bus stays global |
| Props | entry forbids them; child props bind at compile time | route props arrive as runtime JsObject |
The docs promise "opening the same route twice creates two independent windows (each gets its own state)". That promise forces per-mount state contexts — a second codegen mode next to the entry path, not a tweak to it.
Each mount (a route instance in a window) owns a generated context struct holding its signals and its effects:
// generated per route.mx (e.g. /auth/login)
namespace app::routes::auth_login {
struct Context {
morph::Signal<int> count{0};
morph::Signal<std::string> error{""};
std::vector<morph::EffectNode*> effects; // owned, destroyed on unmount
};
std::shared_ptr<Context> mount(MorphWindow* win, WID wid, const JsObject& props);
void unmount(MorphWindow* win, std::shared_ptr<Context> ctx);
}emit_node_with_state with a different state_map: getter count → ctx->count.get() instead of __st_count.get(). Same emitter, different table.map<WID, MountHandle> where the handle holds the context (type-erased shared_ptr<void> with the route's deleter) plus the RID. Windows never see contexts; the registry stays the single owner — same rule as windows themselves.The runtime already has the primitives (EffectNode::cleanup(), the dead flag skipped by run_pending_effects). Two small additions:
morph::destroy_effect(EffectNode*) — mark dead, unsubscribe, remove from pool/pending, delete. Safe mid-frame (the dead check guards the run path; cleanup() guards the notify path).MountScope during mount():create_effect_scoped(...), which registers into the current scope's effects when set, else the global pool (entry behavior unchanged).create_effect while its window's scope is… no — handlers run outside mount). Rule: effects created during mount() are owned; effects created later from handlers are app-global, exactly like today. No spooky ownership, no leaks beyond today's semantics.Unmount = destroy each owned effect + delete the tree (existing) + drop the context (signals die with it).
mid in routes: per-mount dispatch tables (decided 2026-09-20)mid works in routes — no build error. The existing mid grouping (per (ns, getter), mid → signal switch) is reused mechanically with signals resolved to context members:
namespace app::routes::auth_login {
// same grouping as the global fns, signals are ctx members
void setCount(Context& ctx, uint32_t mid, int v) {
switch (mid) {
case 0: ctx.count_hero.set(v); break;
// ...
}
}
}MID_* index consts stay global and shared (tag→index mapping is deterministic per component definition; dispatch is per-route-namespace so identical indices in two routes never collide). Native (mount, mid) access waits with the rest of deferred instance state — generated route code (handlers capturing ctx) is the v1 caller.
shared means app-level: two login windows see the same cart. That matches the name, needs zero new machinery, and matches how web apps behave across tabs (server state) minus the server. Events (morph::channel) are the app bus for the same reason. Both documented as cross-window in the FAQ when 3b lands.
A function defined in route.mx is a namespaced module binding (app::routes::auth_login::helper), callable from anywhere — same as every other module binding. No per-mount duplication: code is stateless, state lives in the context.
Consequence: route module-level functions, classes, and globals must not reference route state or props — they emit at namespace scope where no ctx exists (a baked ctx-> there is a build error with a clear message, mirroring "entry components must not declare props"). Helpers take explicit parameters instead. Effects, handlers, and node code reference ctx freely — they all emit inside the mount function.
mx-route-state)The budget example hit this live: src/add/route.mx → NavBar → IpBadge.checkIp(), an async helper closing over setIp/setStatus. Entry build is green (setters are globals); both route mounts fail at codegen with `mx-route-state`. The constraint above holds transitively — any helper reachable from a route, however deep the import chain, must be context-free.
Fixed 2026-09-28. Plain-function helpers rewrite into context-taking templates (template <typename __MorphCtx> + shared_ptr context parameter, threaded through mount bodies and helper-to-helper calls — one definition serves every mount, and the shared_ptr keeps a navigated-away mount alive across co_await). Remaining mx-route-state cases: classes/lambdas/consts at module scope and mount-state reads inside keyed-list item templates.
Workarounds (in preference order): inline the logic in the effect body; pass setters as explicit parameters; keep the stateful component in the entry tree and use stateless components plus morphShared stores inside routes.
JsObject in, plain C++ out (decided 2026-09-20, ✅ shipped)User-facing rules: Windows & Routes. Internals: one extraction per declared prop in the mount prologue (the only JsValue touchpoint) via total as_* coercions — never throw, unconvertible yields zero values, strings never parsed as numbers. Composite props keep JsArray/JsObject members. Missing required props log loudly at mount. The prologue is the single choke point for all future callers.
useWindow() lowers to a captured __widThe mount function receives the mounting window's WID; generated code binds it as __wid, and useWindow() (no arg) lowers to that variable. Entry windows bind __wid as their constant WID. One rule, both paths — no ambient context crosses any boundary.
Fixed 2026-09-28. const win = useWindow() in any component body (entry or route, root or child) stays lazy: the const binds to the __morph_current_window() placeholder instead of an eager namespace-scope evaluation, so each use site resolves to its own window. win.navigate/close/show/hide/on on such cross-snippet handles lowers through the same placeholders as same-snippet handles.
// inside mount(auth_login): generated
const WID __wid = wid; // useWindow() → handle for THIS windowbuild.rs already builds the entry graph. For each manifest route, it builds a per-route module graph rooted at that route.mx (shared components compile into each mount function that uses them — binary size grows per route, same as today's per-project codegen, no sharing tricks in v1), then emits mount/unmount into the app TU. The manifest's windowConfig feeds window creation; data feeds props.
unmount: destroy owned effects → delete tree → drop context. The window (chrome, GL context, WID) survives — only the page dies.navigate(WID, RID, props): unmount current (or detach into cache) → mount new. Window identity (size, position, id) untouched.navigation.cache: holds MountHandles (tree + context) detached. Cached effects stay subscribed — their signals are alive in the held context, so nothing dangles and nothing needs suspend/resume machinery. Memory cost is tree + signals only (chrome is freed), as already decided.pump() iterates live windows only, run_pending_effects() runs only enqueued effects, and create_effect_scoped is one predictable null check (always null on the entry path). The only cost is pay-per-fire: a cached effect subscribed to a global signal (shared, event channels) re-runs on every global set() — wasted runs scale as cached-pages × global-signal traffic, so heavy shared subscriptions + large caches are the one combination to watch. Effects on purely local signals can never fire while cached (zero cost). Suspend/resume was rejected: it moves cost to transitions, shows stale UI on restore, and would need a branch on every global effect run — taxing the hot path to save the cold path. Default cache: 0 means nobody pays unless they opt in.v1 routes are driven via props (in) and events (out). Native (rid, mount, name) state access needs instance addressing the C++ API doesn't have yet — follow-up, not v1. app::windows::* (3a) covers window-level control; page-internals control waits.
destroy_effect + create_effect_scoped/MountScope + manager m_mounts + MountHandle.Context + mount/unmount emission, route state_map, per-mount mid dispatch, __wid binding.new Window + navigate + internal <a href> desugar) — one-line RID calls now mounts exist.mid-in-route untested) — cache ❌, native.cpp-driven flow ❌.mid in routes: per-mount tables (not an error) — same grouping, ctx-member signals, global MID_* consts shared.__wid capture for useWindow() — one rule for entry + routes (no objection; proceeding).