kopular 0.17.0 → 0.17.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/component.js CHANGED
@@ -73,6 +73,7 @@ export class Component {
73
73
 
74
74
  Teardown() {
75
75
  UnmountTree(this.Tree);
76
+ this.IsMounted = false;
76
77
  this.OnUnmount();
77
78
  }
78
79
 
@@ -1 +1 @@
1
- {"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\nusing \"./vdom\";\n\n// A minimal component base: subclasses override Render() to build a\n// VElement tree describing the current state, and call the inherited\n// Update() whenever that state changes to re-render. Update() DIFFS the\n// new VElement tree against the previous one (see vdom.ks's Patch) and\n// patches only what changed, reusing real DOM nodes wherever their tag\n// stays the same — replacing a whole subtree is now the exception (a\n// changed tag, or no previous tree at all), not the default on every\n// re-render the way it used to be.\n//\n// Nested composition: a live child Component can be embedded directly in a\n// parent's own VElement tree via `VElement.Mount(child)` (velement.ks) —\n// the vdom engine (vdom.ks's Materialize/Patch/PatchChildren) creates,\n// patches in place, reorders (given a stable .Id), and tears down a\n// mounted child declaratively, the same as any other content mode. This\n// is what MountAsChild/PatchAsChild/Teardown below exist for; app code\n// calls Mount(parent)/Update() as before and never touches those three\n// directly.\nclass Component : Flushable, Mountable {\n protected VElement Tree;\n private Element ParentElement;\n // Set true only once Mount() actually runs. A page Component is commonly\n // constructed eagerly (e.g. Router.AddRoute takes an already-built\n // instance — see Router's own header comment) long before it's ever\n // Mount()ed, and two sibling pages sharing one injected service's\n // state<T> (Pure DI — both Subscribe() the same field) both get notified\n // on any change regardless of which one is actually the currently-routed,\n // mounted page. Update() guards on this so that notification is a safe\n // no-op for the unmounted one, instead of a crash (see Update() below).\n private bool IsMounted;\n\n constructor() {\n this.IsMounted = false;\n }\n\n public virtual VElement Render() {\n return VElement.Create(\"div\");\n }\n\n // Overridden to render a fallback UI when Render() throws — a page bug,\n // an unhandled rejected Http call, anything — instead of leaving\n // Mount()/Update() to propagate the exception uncaught, which would\n // otherwise crash whatever triggered the render (a click handler, a\n // Router navigation) with nothing shown to the user at all. Default just\n // re-throws, so anything that doesn't override this keeps today's exact\n // behavior — this is purely additive, opt-in error recovery, not a\n // behavior change for existing components.\n protected virtual VElement RenderError(string message) {\n throw message;\n }\n\n private VElement SafeRender() {\n try {\n return this.Render();\n } catch (string message) {\n return this.RenderError(message);\n }\n }\n\n // Called after Mount()/Update() has materialized/patched this\n // component's own VElement tree into `root` — a hook for a component\n // that needs to do additional, imperative work against its OWN\n // now-real root element once it exists. Default is a no-op, so every\n // component that doesn't need this is unaffected. Router still uses\n // this today for its outlet (a single, always-present slot it manages\n // by hand) — a dynamic parent/child relationship (a list of children,\n // conditional presence) is better served by embedding the child via\n // `VElement.Mount(child)` instead (see the class comment above), which\n // gets real reconciliation for free instead of needing this hook at all.\n protected virtual void AfterRender(Element root) {\n }\n\n public void Mount(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree, parent);\n parent.appendChild(root);\n this.IsMounted = true;\n this.AfterRender(root);\n }\n\n // A no-op, not an error, when called before Mount() — see the class-level\n // comment on IsMounted for exactly when this happens for real (a\n // shared-service state change reaching a sibling page that isn't the one\n // currently routed/mounted). Nothing is lost: SafeRender() would only be\n // thrown away unread since there's no live DOM parent to put it in, and\n // Mount() itself always calls SafeRender() fresh whenever this component\n // does become the routed page, picking up whatever the current state is\n // at that point.\n //\n // Batching: while a real DOM event handler Kopular itself attached is\n // still running (Batching.IsActive() — see vdom.ks; every OnClick/\n // OnInput/OnBlur/OnChange listener Materialize/Patch attaches is wrapped\n // in Batching.Run), Update() doesn't render immediately — it registers\n // this component with Batching.Defer and returns. A handler that touches\n // more than one piece of state (or a state<T> write followed by a plain\n // field write Render() also reads) then only ever renders ONCE, once the\n // handler finishes, reading every field's FINAL value for that handler —\n // not once per state<T> write, reading whatever was true at that specific\n // moment. A handler's own statement order no longer matters for what a\n // re-render sees. Fully synchronous — no microtask: by the time the real\n // DOM's own dispatchEvent call returns, every affected component has\n // already re-rendered, the same guarantee React's own synthetic-event\n // batching gives a test's very next assertion. A state<T> write from\n // OUTSIDE a wrapped handler (a setTimeout/setInterval callback, an\n // awaited Http/task continuation, a direct top-level call) is never\n // inside a batch, so Update() still renders immediately there, exactly\n // as before batching existed.\n protected void Update() {\n if (!this.IsMounted) {\n return;\n }\n if (Batching.IsActive()) {\n Batching.Defer(this);\n return;\n }\n this.FlushUpdate();\n }\n\n // Shared by FlushUpdate and PatchAsChild below — re-renders and patches\n // this component's own tree in place against its CURRENT this.ParentElement\n // (Mount()/MountAsChild() already recorded whichever real element that\n // is, top-level or nested), returning the resulting real root node.\n private Element RenderAndPatch() {\n VElement newTree = this.SafeRender();\n Element root = Patch(this.ParentElement, this.Tree, newTree);\n this.Tree = newTree;\n this.AfterRender(root);\n return root;\n }\n\n // The real re-render Update() performs immediately outside a batch, or\n // that Batching.Run flushes once for every component deferred during one.\n // Public only because implementing the Flushable interface (see vdom.ks)\n // requires it — KopScript has no \"internal\" visibility narrower than\n // public, and structural interface conformance requires a public match.\n // Not the intended way to trigger a render from outside this class:\n // calling it directly bypasses batching entirely. Update() is still the\n // real, documented entry point for that.\n public void FlushUpdate() {\n this.RenderAndPatch();\n }\n\n // Mountable's own entry points (velement.ks) — called by the vdom engine\n // (vdom.ks's Materialize/Patch) when this Component is embedded as a\n // live child in an ANCESTOR's own VElement tree via VElement.Mount(this),\n // not meant to be called directly by app code (public only for interface\n // conformance, same reason FlushUpdate is). Mirrors Mount(parent) minus\n // the internal appendChild — the caller (Materialize/Patch) inserts the\n // returned node itself, the same as every other VElement content mode.\n public Element MountAsChild(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree, parent);\n this.IsMounted = true;\n this.AfterRender(root);\n return root;\n }\n\n public Element PatchAsChild() {\n return this.RenderAndPatch();\n }\n\n // Mountable's teardown entry point — NOT itself meant to be overridden\n // (see OnUnmount below, the real app-facing extension point). Always\n // cascades into this component's own last-rendered tree first, via\n // UnmountTree (vdom.ks), so removing ONE component transitively tears\n // down everything IT mounted too — however many Mounted levels deep —\n // before calling the one real, overridable hook. Public only for the\n // same structural-conformance reason as MountAsChild/PatchAsChild above.\n public void Teardown() {\n UnmountTree(this.Tree);\n this.OnUnmount();\n }\n\n // Override this — not Teardown — to release anything THIS component\n // holds onto when it's removed from its parent's tree: a state<T>\n // Subscribe made in its own constructor (see state<T>'s own returned\n // unsubscribe handle), a timer, anything else that would otherwise keep\n // running for this component's remaining, now-detached lifetime. Called\n // once, automatically, from Teardown() above — Teardown's own cascade\n // into nested Mounted children still happens regardless of whether (or\n // how) a subclass overrides this. Default is a no-op: purely additive,\n // no existing subclass is affected unless it opts in.\n protected virtual void OnUnmount() {\n }\n}\n"],"names":[],"mappings":";;;;AAqBA;EAaE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAcc;;;EAGX;IACc;IACT;IACV;IACkB;IACH;IACC;;;EA8BR;IACR;MACE;;IAEF;MACgB;MACd;;IAEc;;;EAOV;IACN;IACA;IACU;IACM;IAChB;;;EAWK;IACc;;;EAUd;IACc;IACT;IACV;IACe;IACC;IAChB;;;EAGK;IACL;;;EAUK;IACM;IACG;;;EAYE"}
1
+ {"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\nusing \"./vdom\";\n\n// A minimal component base: subclasses override Render() to build a\n// VElement tree describing the current state, and call the inherited\n// Update() whenever that state changes to re-render. Update() DIFFS the\n// new VElement tree against the previous one (see vdom.ks's Patch) and\n// patches only what changed, reusing real DOM nodes wherever their tag\n// stays the same — replacing a whole subtree is now the exception (a\n// changed tag, or no previous tree at all), not the default on every\n// re-render the way it used to be.\n//\n// Nested composition: a live child Component can be embedded directly in a\n// parent's own VElement tree via `VElement.Mount(child)` (velement.ks) —\n// the vdom engine (vdom.ks's Materialize/Patch/PatchChildren) creates,\n// patches in place, reorders (given a stable .Id), and tears down a\n// mounted child declaratively, the same as any other content mode. This\n// is what MountAsChild/PatchAsChild/Teardown below exist for; app code\n// calls Mount(parent)/Update() as before and never touches those three\n// directly.\nclass Component : Flushable, Mountable {\n protected VElement Tree;\n private Element ParentElement;\n // Set true only once Mount()/MountAsChild() actually runs, and back to\n // false once Teardown() does (a mounted-child slot this component\n // occupied was replaced or removed — see velement.ks/vdom.ks). A page\n // Component is commonly constructed eagerly (e.g. Router.AddRoute takes\n // an already-built instance — see Router's own header comment) long\n // before it's ever Mount()ed, and two sibling pages sharing one injected\n // service's state<T> (Pure DI — both Subscribe() the same field) both\n // get notified on any change regardless of which one is actually the\n // currently-routed, mounted page. Update() guards on this so that\n // notification is a safe no-op for the unmounted one, instead of a\n // crash (see Update() below) — and the same guard now also covers a\n // component notified again sometime after being torn down.\n private bool IsMounted;\n\n constructor() {\n this.IsMounted = false;\n }\n\n public virtual VElement Render() {\n return VElement.Create(\"div\");\n }\n\n // Overridden to render a fallback UI when Render() throws — a page bug,\n // an unhandled rejected Http call, anything — instead of leaving\n // Mount()/Update() to propagate the exception uncaught, which would\n // otherwise crash whatever triggered the render (a click handler, a\n // Router navigation) with nothing shown to the user at all. Default just\n // re-throws, so anything that doesn't override this keeps today's exact\n // behavior — this is purely additive, opt-in error recovery, not a\n // behavior change for existing components.\n protected virtual VElement RenderError(string message) {\n throw message;\n }\n\n private VElement SafeRender() {\n try {\n return this.Render();\n } catch (string message) {\n return this.RenderError(message);\n }\n }\n\n // Called after Mount()/Update() has materialized/patched this\n // component's own VElement tree into `root` — a hook for a component\n // that needs to do additional, imperative work against its OWN\n // now-real root element once it exists. Default is a no-op, so every\n // component that doesn't need this is unaffected. Router still uses\n // this today for its outlet (a single, always-present slot it manages\n // by hand) — a dynamic parent/child relationship (a list of children,\n // conditional presence) is better served by embedding the child via\n // `VElement.Mount(child)` instead (see the class comment above), which\n // gets real reconciliation for free instead of needing this hook at all.\n protected virtual void AfterRender(Element root) {\n }\n\n public void Mount(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree, parent);\n parent.appendChild(root);\n this.IsMounted = true;\n this.AfterRender(root);\n }\n\n // A no-op, not an error, when called before Mount() — see the class-level\n // comment on IsMounted for exactly when this happens for real (a\n // shared-service state change reaching a sibling page that isn't the one\n // currently routed/mounted). Nothing is lost: SafeRender() would only be\n // thrown away unread since there's no live DOM parent to put it in, and\n // Mount() itself always calls SafeRender() fresh whenever this component\n // does become the routed page, picking up whatever the current state is\n // at that point.\n //\n // Batching: while a real DOM event handler Kopular itself attached is\n // still running (Batching.IsActive() — see vdom.ks; every OnClick/\n // OnInput/OnBlur/OnChange listener Materialize/Patch attaches is wrapped\n // in Batching.Run), Update() doesn't render immediately — it registers\n // this component with Batching.Defer and returns. A handler that touches\n // more than one piece of state (or a state<T> write followed by a plain\n // field write Render() also reads) then only ever renders ONCE, once the\n // handler finishes, reading every field's FINAL value for that handler —\n // not once per state<T> write, reading whatever was true at that specific\n // moment. A handler's own statement order no longer matters for what a\n // re-render sees. Fully synchronous — no microtask: by the time the real\n // DOM's own dispatchEvent call returns, every affected component has\n // already re-rendered, the same guarantee React's own synthetic-event\n // batching gives a test's very next assertion. A state<T> write from\n // OUTSIDE a wrapped handler (a setTimeout/setInterval callback, an\n // awaited Http/task continuation, a direct top-level call) is never\n // inside a batch, so Update() still renders immediately there, exactly\n // as before batching existed.\n protected void Update() {\n if (!this.IsMounted) {\n return;\n }\n if (Batching.IsActive()) {\n Batching.Defer(this);\n return;\n }\n this.FlushUpdate();\n }\n\n // Shared by FlushUpdate and PatchAsChild below — re-renders and patches\n // this component's own tree in place against its CURRENT this.ParentElement\n // (Mount()/MountAsChild() already recorded whichever real element that\n // is, top-level or nested), returning the resulting real root node.\n private Element RenderAndPatch() {\n VElement newTree = this.SafeRender();\n Element root = Patch(this.ParentElement, this.Tree, newTree);\n this.Tree = newTree;\n this.AfterRender(root);\n return root;\n }\n\n // The real re-render Update() performs immediately outside a batch, or\n // that Batching.Run flushes once for every component deferred during one.\n // Public only because implementing the Flushable interface (see vdom.ks)\n // requires it — KopScript has no \"internal\" visibility narrower than\n // public, and structural interface conformance requires a public match.\n // Not the intended way to trigger a render from outside this class:\n // calling it directly bypasses batching entirely. Update() is still the\n // real, documented entry point for that.\n public void FlushUpdate() {\n this.RenderAndPatch();\n }\n\n // Mountable's own entry points (velement.ks) — called by the vdom engine\n // (vdom.ks's Materialize/Patch) when this Component is embedded as a\n // live child in an ANCESTOR's own VElement tree via VElement.Mount(this),\n // not meant to be called directly by app code (public only for interface\n // conformance, same reason FlushUpdate is). Mirrors Mount(parent) minus\n // the internal appendChild — the caller (Materialize/Patch) inserts the\n // returned node itself, the same as every other VElement content mode.\n public Element MountAsChild(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree, parent);\n this.IsMounted = true;\n this.AfterRender(root);\n return root;\n }\n\n public Element PatchAsChild() {\n return this.RenderAndPatch();\n }\n\n // Mountable's teardown entry point — NOT itself meant to be overridden\n // (see OnUnmount below, the real app-facing extension point). Always\n // cascades into this component's own last-rendered tree first, via\n // UnmountTree (vdom.ks), so removing ONE component transitively tears\n // down everything IT mounted too — however many Mounted levels deep —\n // before calling the one real, overridable hook. Public only for the\n // same structural-conformance reason as MountAsChild/PatchAsChild above.\n public void Teardown() {\n UnmountTree(this.Tree);\n this.IsMounted = false;\n this.OnUnmount();\n }\n\n // Override this — not Teardown — to release anything THIS component\n // holds onto when it's removed from its parent's tree: a state<T>\n // Subscribe made in its own constructor (see state<T>'s own returned\n // unsubscribe handle), a timer, anything else that would otherwise keep\n // running for this component's remaining, now-detached lifetime. Called\n // once, automatically, from Teardown() above — Teardown's own cascade\n // into nested Mounted children still happens regardless of whether (or\n // how) a subclass overrides this. Default is a no-op: purely additive,\n // no existing subclass is affected unless it opts in.\n protected virtual void OnUnmount() {\n }\n}\n"],"names":[],"mappings":";;;;AAqBA;EAiBE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAcc;;;EAGX;IACc;IACT;IACV;IACkB;IACH;IACC;;;EA8BR;IACR;MACE;;IAEF;MACgB;MACd;;IAEc;;;EAOV;IACN;IACA;IACU;IACM;IAChB;;;EAWK;IACc;;;EAUd;IACc;IACT;IACV;IACe;IACC;IAChB;;;EAGK;IACL;;;EAUK;IACM;IACI;IACD;;;EAYE"}
package/src/component.ks CHANGED
@@ -22,14 +22,18 @@ using "./vdom";
22
22
  class Component : Flushable, Mountable {
23
23
  protected VElement Tree;
24
24
  private Element ParentElement;
25
- // Set true only once Mount() actually runs. A page Component is commonly
26
- // constructed eagerly (e.g. Router.AddRoute takes an already-built
27
- // instance see Router's own header comment) long before it's ever
28
- // Mount()ed, and two sibling pages sharing one injected service's
29
- // state<T> (Pure DIboth Subscribe() the same field) both get notified
30
- // on any change regardless of which one is actually the currently-routed,
31
- // mounted page. Update() guards on this so that notification is a safe
32
- // no-op for the unmounted one, instead of a crash (see Update() below).
25
+ // Set true only once Mount()/MountAsChild() actually runs, and back to
26
+ // false once Teardown() does (a mounted-child slot this component
27
+ // occupied was replaced or removed see velement.ks/vdom.ks). A page
28
+ // Component is commonly constructed eagerly (e.g. Router.AddRoute takes
29
+ // an already-built instancesee Router's own header comment) long
30
+ // before it's ever Mount()ed, and two sibling pages sharing one injected
31
+ // service's state<T> (Pure DI both Subscribe() the same field) both
32
+ // get notified on any change regardless of which one is actually the
33
+ // currently-routed, mounted page. Update() guards on this so that
34
+ // notification is a safe no-op for the unmounted one, instead of a
35
+ // crash (see Update() below) — and the same guard now also covers a
36
+ // component notified again sometime after being torn down.
33
37
  private bool IsMounted;
34
38
 
35
39
  constructor() {
@@ -173,6 +177,7 @@ class Component : Flushable, Mountable {
173
177
  // same structural-conformance reason as MountAsChild/PatchAsChild above.
174
178
  public void Teardown() {
175
179
  UnmountTree(this.Tree);
180
+ this.IsMounted = false;
176
181
  this.OnUnmount();
177
182
  }
178
183
 
package/src/router.js CHANGED
@@ -133,17 +133,13 @@ export class Router extends Component {
133
133
  }
134
134
 
135
135
  Render() {
136
+ let path = (location.pathname + location.search);
137
+ let page = this.Match(path);
136
138
  let outlet = VElement.Create("div");
137
139
  outlet.ClassName = "router-outlet";
140
+ outlet.AppendChild(VElement.Mount(page));
138
141
  return outlet;
139
142
  }
140
-
141
- AfterRender(root) {
142
- let path = (location.pathname + location.search);
143
- let page = this.Match(path);
144
- root.textContent = "";
145
- page.Mount(root);
146
- }
147
143
  }
148
144
 
149
145
  //# sourceMappingURL=router.js.map
package/src/router.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"router.js","sources":["router.ks"],"sourcesContent":["using \"./dom\";\nusing \"./component\";\nusing \"./velement\";\n\n// History-API routing (real paths — `/about`, not `#/about`) via\n// `history.pushState`/the browser's native `popstate` event. This needs a\n// server that falls back to the app shell for any path it doesn't\n// recognize as a real file (see KopularDemo's scripts/serve.mjs) — a plain\n// static file server has nothing to serve at `/about` on a direct\n// visit/refresh otherwise, unlike hash-based routing, which never leaves\n// the one HTML file the server actually has. Route registration is\n// imperative (`AddRoute` calls), not a config object — one thing to learn,\n// not a routing DSL.\n//\n// Routes are registered as already-constructed Component instances, not\n// factories — `Component[]`, not `(() => Component)[]` (KopScript's type\n// grammar has no way to write \"array of function type\" in v1, since a\n// parenthesized function type isn't a general grouping construct). This\n// turns out to be a genuine feature, not just a workaround: each page\n// Component is built once and kept alive for the Router's own lifetime, so\n// a page's own state<T> fields survive navigating away and back — no state\n// gets reset just because a route wasn't showing for a while.\n//\n// No nullable types in v1 means \"no route matched\" can't be represented as\n// null — a `NotFoundPage` is required up front instead, the same way a\n// `match` expression requires its own `_` wildcard arm.\nclass Router : Component {\n private string[] Paths;\n private Component[] Pages;\n private Component NotFoundPage;\n\n // The FIRST captured dynamic segment's value (e.g. \"42\" for a\n // \"/dogs/:id\" route matching \"/dogs/42\"), or \"\" if the matched route has\n // none — kept exactly as-is (a plain string, not the general Params()\n // lookup below) so every existing single-dynamic-segment consumer keeps\n // working unchanged. For a route with more than one captured segment,\n // use Params(name) instead; this still holds the first one either way.\n //\n // A plain field, deliberately **not** state<T> — Render() below always\n // rebuilds a fresh outlet and re-Mount()s the matched page into it on\n // every Navigate()/popstate, which already re-runs that page's own\n // Render() (reading the fresh Param) with no extra step. Subscribing to\n // it, the way a page reacts to its *own* state<T>, would fire during\n // Match() itself — before Render() has finished swapping the matched page\n // in — which crashes the very first time a route no page has been\n // Mount()ed into yet matches, since a not-yet-mounted Component's\n // inherited Update() has no ParentElement to replaceChild into.\n public string Param;\n\n // Every named dynamic segment captured by whatever route just matched\n // (\":id\"/\":toyId\" for \"/dogs/:id/toys/:toyId\"), plus the pseudo-name \"*\"\n // for a trailing wildcard segment's captured remainder — see Params()\n // below. Parallel arrays (ParamNames[i] <-> ParamValues[i]), the same\n // convention Paths/Pages above already use — not a Dictionary type,\n // since KopScript has none.\n private string[] ParamNames;\n private string[] ParamValues;\n\n // The current URL's query string, parsed once per navigation/popstate —\n // see Query() below. Never part of route *matching* itself in v1: every\n // route sees whatever query string is actually in the URL, matched or\n // not. Values are NOT percent-decoded (a deliberate v1 cut — no\n // decodeURIComponent binding exists yet); a value containing `%20` or\n // `+` for a space arrives exactly as written in the URL.\n private string[] QueryKeys;\n private string[] QueryValues;\n\n // Checked before every navigation, including a direct load/refresh — not\n // just Navigate()/popstate. Deliberately one guard for the whole Router,\n // not per-route: the guard function itself decides which paths it cares\n // about (typically `path.StartsWith(\"/admin\")`-style checks), the same\n // \"no config object, just a function\" style Http/DI already use, rather\n // than a parallel array of per-route guards (which also can't be written\n // as a type anyway — see AddRoute's own comment on array-of-function\n // types). Defaults to always-allow, not null — a nullable function TYPE\n // has the same \"can't parenthesize for a postfix `?`\" problem as an\n // array of one, so \"no guard configured\" is a real function that always\n // returns true, not a null check.\n private (string) => bool Guard;\n private string RedirectPath;\n\n constructor(Component notFoundPage) : base() {\n this.Paths = [];\n this.Pages = [];\n this.NotFoundPage = notFoundPage;\n this.Param = \"\";\n this.ParamNames = [];\n this.ParamValues = [];\n this.QueryKeys = [];\n this.QueryValues = [];\n this.Guard = (string path) => true;\n this.RedirectPath = \"\";\n // 'popstate' only fires on browser back/forward (or history.go/back/\n // forward) — never on pushState itself, unlike hashchange firing\n // whenever location.hash is set. Navigate() below calls Update()\n // directly for that reason; this listener only covers the back/forward\n // case, where the URL changes without any of our own code running.\n window.addEventListener(\"popstate\", (Event e) => this.Update());\n }\n\n // A path segment written \":name\" (e.g. \"dogs/:id\") matches any single\n // non-empty segment; every other segment must match literally, except a\n // trailing \"*\" segment (e.g. \"files/*\"), which matches one or more\n // remaining segments as a single captured group (see Params(\"*\")).\n // Comparing segment-by-segment (rather than the whole string at once) is\n // what makes a static route's own matching still exactly as strict as a\n // plain `==` was before — same segment count, same literal text\n // throughout (a trailing \"*\" route aside, which only ever needs *at\n // least* as many path segments as its static prefix).\n public void AddRoute(string path, Component page) {\n this.Paths = this.Paths.Push(path);\n this.Pages = this.Pages.Push(page);\n }\n\n // `guard` is called with the path being navigated to; returning false\n // redirects to `redirectPath` instead (updating the URL via pushState, so\n // a refresh on the blocked path lands on the redirect too, not back on\n // the page the guard just rejected). `redirectPath` itself is never\n // guard-checked — pick one the guard always allows, or it'll loop.\n public void SetGuard(string redirectPath, (string) => bool guard) {\n this.RedirectPath = redirectPath;\n this.Guard = guard;\n }\n\n public void Navigate(string path) {\n history.pushState(\"\", \"\", path);\n this.Update();\n }\n\n // The captured value for a named dynamic segment (e.g. \"toyId\" for\n // \":toyId\" in \"/dogs/:id/toys/:toyId\") or \"*\" for a trailing wildcard\n // segment's matched remainder — \"\" if `name` wasn't captured by whatever\n // route just matched, the same \"empty string, not null\" convention\n // `Param` itself already uses.\n public string Params(string name) {\n for (number i = 0; i < this.ParamNames.Length; i = i + 1) {\n if (this.ParamNames[i] == name) {\n return this.ParamValues[i];\n }\n }\n return \"\";\n }\n\n // The current URL's query-string value for `key` (e.g. \"name\" for\n // \"?sort=name\") — \"\" if `key` isn't present. See QueryKeys/QueryValues'\n // own comment for the percent-decoding cut.\n public string Query(string key) {\n for (number i = 0; i < this.QueryKeys.Length; i = i + 1) {\n if (this.QueryKeys[i] == key) {\n return this.QueryValues[i];\n }\n }\n return \"\";\n }\n\n // KopScript strings have no index-based substring/slice method (only\n // arrays do — see Array.Slice), so splitting on the literal separator and\n // taking the piece after it is the idiomatic way to strip a known\n // single-character prefix here, both for \"?\" (query string) and \":\"\n // (a pattern segment's own leading marker, used below in Match).\n private void ParseQuery(string pathWithQuery) {\n this.QueryKeys = [];\n this.QueryValues = [];\n string[] afterMark = pathWithQuery.Split(\"?\");\n if (afterMark.Length < 2) { return; }\n string rawQuery = afterMark[1];\n if (rawQuery.Length == 0) { return; }\n string[] pairs = rawQuery.Split(\"&\");\n foreach (string pair in pairs) {\n string[] kv = pair.Split(\"=\");\n if (kv.Length == 2) {\n this.QueryKeys = this.QueryKeys.Push(kv[0]);\n this.QueryValues = this.QueryValues.Push(kv[1]);\n }\n }\n }\n\n private Component Match(string fullPath) {\n string effectivePath = fullPath;\n if (!this.Guard(fullPath)) {\n history.pushState(\"\", \"\", this.RedirectPath);\n effectivePath = this.RedirectPath;\n }\n\n this.ParseQuery(effectivePath);\n string[] pathAndQuery = effectivePath.Split(\"?\");\n string[] pathSegments = pathAndQuery[0].Split(\"/\");\n\n Component found = this.NotFoundPage;\n string[] foundNames = [];\n string[] foundValues = [];\n\n for (number i = 0; i < this.Paths.Length; i = i + 1) {\n string[] patternSegments = this.Paths[i].Split(\"/\");\n bool hasWildcard = patternSegments.Length > 0 && patternSegments[patternSegments.Length - 1] == \"*\";\n\n number staticCount = patternSegments.Length;\n if (hasWildcard) {\n staticCount = patternSegments.Length - 1;\n }\n\n if (hasWildcard) {\n if (pathSegments.Length < staticCount) { continue; }\n } else {\n if (patternSegments.Length != pathSegments.Length) { continue; }\n }\n\n bool matched = true;\n string[] names = [];\n string[] values = [];\n for (number j = 0; j < staticCount; j = j + 1) {\n if (patternSegments[j].StartsWith(\":\")) {\n names = names.Push(patternSegments[j].Split(\":\")[1]);\n values = values.Push(pathSegments[j]);\n } else if (patternSegments[j] != pathSegments[j]) {\n matched = false;\n break;\n }\n }\n\n if (matched && hasWildcard) {\n names = names.Push(\"*\");\n values = values.Push(pathSegments.Slice(staticCount, pathSegments.Length).Join(\"/\"));\n }\n\n if (matched) {\n found = this.Pages[i];\n foundNames = names;\n foundValues = values;\n break;\n }\n }\n\n this.ParamNames = foundNames;\n this.ParamValues = foundValues;\n this.Param = \"\";\n if (foundValues.Length > 0) {\n this.Param = foundValues[0];\n }\n return found;\n }\n\n // The outlet itself is a plain, unchanging `<div>` — its tag/className\n // never change between navigations, so Component's own diffing (see\n // vdom.ks) reuses the exact same real outlet element across every\n // navigation now, rather than rebuilding it. What goes *inside* it (the\n // matched page) is handled imperatively in AfterRender below, not via\n // VElement.Mount (component.ks/velement.ks) — a single, always-present\n // slot has nothing to reorder or conditionally remove, so declarative\n // reconciliation wouldn't add anything here; this predates that\n // mechanism and there's no real reason to migrate it. The page instance\n // itself is always freshly Mount()ed on every navigation (matching this\n // class's original pre-diffing behavior exactly) — a page's own\n // re-renders, once mounted, still go through the normal diffed Update()\n // path when its own state changes; only the *outer* page-switch itself\n // stays a full remount.\n public override VElement Render() {\n VElement outlet = VElement.Create(\"div\");\n outlet.ClassName = \"router-outlet\";\n return outlet;\n }\n\n protected override void AfterRender(Element root) {\n string path = location.pathname + location.search;\n Component page = this.Match(path);\n root.textContent = \"\";\n page.Mount(root);\n }\n}\n"],"names":[],"mappings":";;;;AA0BA;EAuDE;;IACa;IACA;IACO;IACP;IACK;IACC;IACF;IACE;IACN;IACO;IAMK;;;EAYlB;IACM;IACA;;;EAQN;IACa;IACP;;;EAGN;IACY;IACN;;;EAQN;IACL;MACE;QACE;;;IAGJ;;;EAMK;IACL;MACE;QACE;;;IAGJ;;;EAQM;IACS;IACE;IACjB;IACA;MAA4B;;IAC5B;IACA;MAA4B;;IAC5B;IACA;MACE;MACA;QACiB;QACE;;;;;EAKf;IACN;IACA;MACmB;MACH;;IAGD;IACf;IACA;IAEA;IACA;IACA;IAEA;MACE;MACA;MAEA;MACA;QACc;;MAGd;QACE;UAAyC;;;QAEzC;UAAqD;;;MAGvD;MACA;MACA;MACA;QACE;UACQ;UACC;;UAEC;UACR;;;MAIJ;QACQ;QACC;;MAGT;QACQ;QACK;QACC;QACZ;;;IAIY;IACC;IACN;IACX;MACa;;IAEb;;;EAiBc;IACd;IACiB;IACjB;;;EAGiB;IACjB;IACA;IACiB;IACP"}
1
+ {"version":3,"file":"router.js","sources":["router.ks"],"sourcesContent":["using \"./dom\";\nusing \"./component\";\nusing \"./velement\";\n\n// History-API routing (real paths — `/about`, not `#/about`) via\n// `history.pushState`/the browser's native `popstate` event. This needs a\n// server that falls back to the app shell for any path it doesn't\n// recognize as a real file (see KopularDemo's scripts/serve.mjs) — a plain\n// static file server has nothing to serve at `/about` on a direct\n// visit/refresh otherwise, unlike hash-based routing, which never leaves\n// the one HTML file the server actually has. Route registration is\n// imperative (`AddRoute` calls), not a config object — one thing to learn,\n// not a routing DSL.\n//\n// Routes are registered as already-constructed Component instances, not\n// factories — `Component[]`, not `(() => Component)[]` (KopScript's type\n// grammar has no way to write \"array of function type\" in v1, since a\n// parenthesized function type isn't a general grouping construct). This\n// turns out to be a genuine feature, not just a workaround: each page\n// Component is built once and kept alive for the Router's own lifetime, so\n// a page's own state<T> fields survive navigating away and back — no state\n// gets reset just because a route wasn't showing for a while.\n//\n// No nullable types in v1 means \"no route matched\" can't be represented as\n// null — a `NotFoundPage` is required up front instead, the same way a\n// `match` expression requires its own `_` wildcard arm.\nclass Router : Component {\n private string[] Paths;\n private Component[] Pages;\n private Component NotFoundPage;\n\n // The FIRST captured dynamic segment's value (e.g. \"42\" for a\n // \"/dogs/:id\" route matching \"/dogs/42\"), or \"\" if the matched route has\n // none — kept exactly as-is (a plain string, not the general Params()\n // lookup below) so every existing single-dynamic-segment consumer keeps\n // working unchanged. For a route with more than one captured segment,\n // use Params(name) instead; this still holds the first one either way.\n //\n // A plain field, deliberately **not** state<T> — Render() below always\n // rebuilds a fresh outlet and re-Mount()s the matched page into it on\n // every Navigate()/popstate, which already re-runs that page's own\n // Render() (reading the fresh Param) with no extra step. Subscribing to\n // it, the way a page reacts to its *own* state<T>, would fire during\n // Match() itself — before Render() has finished swapping the matched page\n // in — which crashes the very first time a route no page has been\n // Mount()ed into yet matches, since a not-yet-mounted Component's\n // inherited Update() has no ParentElement to replaceChild into.\n public string Param;\n\n // Every named dynamic segment captured by whatever route just matched\n // (\":id\"/\":toyId\" for \"/dogs/:id/toys/:toyId\"), plus the pseudo-name \"*\"\n // for a trailing wildcard segment's captured remainder — see Params()\n // below. Parallel arrays (ParamNames[i] <-> ParamValues[i]), the same\n // convention Paths/Pages above already use — not a Dictionary type,\n // since KopScript has none.\n private string[] ParamNames;\n private string[] ParamValues;\n\n // The current URL's query string, parsed once per navigation/popstate —\n // see Query() below. Never part of route *matching* itself in v1: every\n // route sees whatever query string is actually in the URL, matched or\n // not. Values are NOT percent-decoded (a deliberate v1 cut — no\n // decodeURIComponent binding exists yet); a value containing `%20` or\n // `+` for a space arrives exactly as written in the URL.\n private string[] QueryKeys;\n private string[] QueryValues;\n\n // Checked before every navigation, including a direct load/refresh — not\n // just Navigate()/popstate. Deliberately one guard for the whole Router,\n // not per-route: the guard function itself decides which paths it cares\n // about (typically `path.StartsWith(\"/admin\")`-style checks), the same\n // \"no config object, just a function\" style Http/DI already use, rather\n // than a parallel array of per-route guards (which also can't be written\n // as a type anyway — see AddRoute's own comment on array-of-function\n // types). Defaults to always-allow, not null — a nullable function TYPE\n // has the same \"can't parenthesize for a postfix `?`\" problem as an\n // array of one, so \"no guard configured\" is a real function that always\n // returns true, not a null check.\n private (string) => bool Guard;\n private string RedirectPath;\n\n constructor(Component notFoundPage) : base() {\n this.Paths = [];\n this.Pages = [];\n this.NotFoundPage = notFoundPage;\n this.Param = \"\";\n this.ParamNames = [];\n this.ParamValues = [];\n this.QueryKeys = [];\n this.QueryValues = [];\n this.Guard = (string path) => true;\n this.RedirectPath = \"\";\n // 'popstate' only fires on browser back/forward (or history.go/back/\n // forward) — never on pushState itself, unlike hashchange firing\n // whenever location.hash is set. Navigate() below calls Update()\n // directly for that reason; this listener only covers the back/forward\n // case, where the URL changes without any of our own code running.\n window.addEventListener(\"popstate\", (Event e) => this.Update());\n }\n\n // A path segment written \":name\" (e.g. \"dogs/:id\") matches any single\n // non-empty segment; every other segment must match literally, except a\n // trailing \"*\" segment (e.g. \"files/*\"), which matches one or more\n // remaining segments as a single captured group (see Params(\"*\")).\n // Comparing segment-by-segment (rather than the whole string at once) is\n // what makes a static route's own matching still exactly as strict as a\n // plain `==` was before — same segment count, same literal text\n // throughout (a trailing \"*\" route aside, which only ever needs *at\n // least* as many path segments as its static prefix).\n public void AddRoute(string path, Component page) {\n this.Paths = this.Paths.Push(path);\n this.Pages = this.Pages.Push(page);\n }\n\n // `guard` is called with the path being navigated to; returning false\n // redirects to `redirectPath` instead (updating the URL via pushState, so\n // a refresh on the blocked path lands on the redirect too, not back on\n // the page the guard just rejected). `redirectPath` itself is never\n // guard-checked — pick one the guard always allows, or it'll loop.\n public void SetGuard(string redirectPath, (string) => bool guard) {\n this.RedirectPath = redirectPath;\n this.Guard = guard;\n }\n\n public void Navigate(string path) {\n history.pushState(\"\", \"\", path);\n this.Update();\n }\n\n // The captured value for a named dynamic segment (e.g. \"toyId\" for\n // \":toyId\" in \"/dogs/:id/toys/:toyId\") or \"*\" for a trailing wildcard\n // segment's matched remainder — \"\" if `name` wasn't captured by whatever\n // route just matched, the same \"empty string, not null\" convention\n // `Param` itself already uses.\n public string Params(string name) {\n for (number i = 0; i < this.ParamNames.Length; i = i + 1) {\n if (this.ParamNames[i] == name) {\n return this.ParamValues[i];\n }\n }\n return \"\";\n }\n\n // The current URL's query-string value for `key` (e.g. \"name\" for\n // \"?sort=name\") — \"\" if `key` isn't present. See QueryKeys/QueryValues'\n // own comment for the percent-decoding cut.\n public string Query(string key) {\n for (number i = 0; i < this.QueryKeys.Length; i = i + 1) {\n if (this.QueryKeys[i] == key) {\n return this.QueryValues[i];\n }\n }\n return \"\";\n }\n\n // KopScript strings have no index-based substring/slice method (only\n // arrays do — see Array.Slice), so splitting on the literal separator and\n // taking the piece after it is the idiomatic way to strip a known\n // single-character prefix here, both for \"?\" (query string) and \":\"\n // (a pattern segment's own leading marker, used below in Match).\n private void ParseQuery(string pathWithQuery) {\n this.QueryKeys = [];\n this.QueryValues = [];\n string[] afterMark = pathWithQuery.Split(\"?\");\n if (afterMark.Length < 2) { return; }\n string rawQuery = afterMark[1];\n if (rawQuery.Length == 0) { return; }\n string[] pairs = rawQuery.Split(\"&\");\n foreach (string pair in pairs) {\n string[] kv = pair.Split(\"=\");\n if (kv.Length == 2) {\n this.QueryKeys = this.QueryKeys.Push(kv[0]);\n this.QueryValues = this.QueryValues.Push(kv[1]);\n }\n }\n }\n\n private Component Match(string fullPath) {\n string effectivePath = fullPath;\n if (!this.Guard(fullPath)) {\n history.pushState(\"\", \"\", this.RedirectPath);\n effectivePath = this.RedirectPath;\n }\n\n this.ParseQuery(effectivePath);\n string[] pathAndQuery = effectivePath.Split(\"?\");\n string[] pathSegments = pathAndQuery[0].Split(\"/\");\n\n Component found = this.NotFoundPage;\n string[] foundNames = [];\n string[] foundValues = [];\n\n for (number i = 0; i < this.Paths.Length; i = i + 1) {\n string[] patternSegments = this.Paths[i].Split(\"/\");\n bool hasWildcard = patternSegments.Length > 0 && patternSegments[patternSegments.Length - 1] == \"*\";\n\n number staticCount = patternSegments.Length;\n if (hasWildcard) {\n staticCount = patternSegments.Length - 1;\n }\n\n if (hasWildcard) {\n if (pathSegments.Length < staticCount) { continue; }\n } else {\n if (patternSegments.Length != pathSegments.Length) { continue; }\n }\n\n bool matched = true;\n string[] names = [];\n string[] values = [];\n for (number j = 0; j < staticCount; j = j + 1) {\n if (patternSegments[j].StartsWith(\":\")) {\n names = names.Push(patternSegments[j].Split(\":\")[1]);\n values = values.Push(pathSegments[j]);\n } else if (patternSegments[j] != pathSegments[j]) {\n matched = false;\n break;\n }\n }\n\n if (matched && hasWildcard) {\n names = names.Push(\"*\");\n values = values.Push(pathSegments.Slice(staticCount, pathSegments.Length).Join(\"/\"));\n }\n\n if (matched) {\n found = this.Pages[i];\n foundNames = names;\n foundValues = values;\n break;\n }\n }\n\n this.ParamNames = foundNames;\n this.ParamValues = foundValues;\n this.Param = \"\";\n if (foundValues.Length > 0) {\n this.Param = foundValues[0];\n }\n return found;\n }\n\n // The outlet itself is a plain, unchanging `<div>` — its tag/className\n // never change between navigations, so Component's own diffing (see\n // vdom.ks) reuses the exact same real outlet element across every\n // navigation, rather than rebuilding it. What goes *inside* it (the\n // matched page) is embedded via VElement.Mount — a single child, so it\n // needs no .Id (there's never more than one to key against; positional\n // matching is unambiguous). Navigating to a DIFFERENT page than last\n // time is a different Mountable instance in the slot, so Patch() tears\n // the old one down (Teardown() — its OnUnmount fires, IsMounted resets)\n // and mounts the new one fresh — matching this class's original\n // behavior exactly (every navigation always freshly Mount()s the\n // matched page; a page's own re-renders, once mounted, still go through\n // the normal diffed Update() path when its own state changes). The one\n // real change from before: a page navigated AWAY from now actually gets\n // torn down (OnUnmount fires) instead of being silently abandoned with\n // its DOM ripped out from under it and IsMounted left permanently true.\n public override VElement Render() {\n string path = location.pathname + location.search;\n Component page = this.Match(path);\n\n VElement outlet = VElement.Create(\"div\");\n outlet.ClassName = \"router-outlet\";\n outlet.AppendChild(VElement.Mount(page));\n return outlet;\n }\n}\n"],"names":[],"mappings":";;;;AA0BA;EAuDE;;IACa;IACA;IACO;IACP;IACK;IACC;IACF;IACE;IACN;IACO;IAMK;;;EAYlB;IACM;IACA;;;EAQN;IACa;IACP;;;EAGN;IACY;IACN;;;EAQN;IACL;MACE;QACE;;;IAGJ;;;EAMK;IACL;MACE;QACE;;;IAGJ;;;EAQM;IACS;IACE;IACjB;IACA;MAA4B;;IAC5B;IACA;MAA4B;;IAC5B;IACA;MACE;MACA;QACiB;QACE;;;;;EAKf;IACN;IACA;MACmB;MACH;;IAGD;IACf;IACA;IAEA;IACA;IACA;IAEA;MACE;MACA;MAEA;MACA;QACc;;MAGd;QACE;UAAyC;;;QAEzC;UAAqD;;;MAGvD;MACA;MACA;MACA;QACE;UACQ;UACC;;UAEC;UACR;;;MAIJ;QACQ;QACC;;MAGT;QACQ;QACK;QACC;QACZ;;;IAIY;IACC;IACN;IACX;MACa;;IAEb;;;EAmBc;IACd;IACA;IAEA;IACiB;IACC;IAClB"}
package/src/router.ks CHANGED
@@ -243,27 +243,26 @@ class Router : Component {
243
243
  // The outlet itself is a plain, unchanging `<div>` — its tag/className
244
244
  // never change between navigations, so Component's own diffing (see
245
245
  // vdom.ks) reuses the exact same real outlet element across every
246
- // navigation now, rather than rebuilding it. What goes *inside* it (the
247
- // matched page) is handled imperatively in AfterRender below, not via
248
- // VElement.Mount (component.ks/velement.ks) a single, always-present
249
- // slot has nothing to reorder or conditionally remove, so declarative
250
- // reconciliation wouldn't add anything here; this predates that
251
- // mechanism and there's no real reason to migrate it. The page instance
252
- // itself is always freshly Mount()ed on every navigation (matching this
253
- // class's original pre-diffing behavior exactly) a page's own
254
- // re-renders, once mounted, still go through the normal diffed Update()
255
- // path when its own state changes; only the *outer* page-switch itself
256
- // stays a full remount.
246
+ // navigation, rather than rebuilding it. What goes *inside* it (the
247
+ // matched page) is embedded via VElement.Mount a single child, so it
248
+ // needs no .Id (there's never more than one to key against; positional
249
+ // matching is unambiguous). Navigating to a DIFFERENT page than last
250
+ // time is a different Mountable instance in the slot, so Patch() tears
251
+ // the old one down (Teardown() its OnUnmount fires, IsMounted resets)
252
+ // and mounts the new one fresh matching this class's original
253
+ // behavior exactly (every navigation always freshly Mount()s the
254
+ // matched page; a page's own re-renders, once mounted, still go through
255
+ // the normal diffed Update() path when its own state changes). The one
256
+ // real change from before: a page navigated AWAY from now actually gets
257
+ // torn down (OnUnmount fires) instead of being silently abandoned with
258
+ // its DOM ripped out from under it and IsMounted left permanently true.
257
259
  public override VElement Render() {
260
+ string path = location.pathname + location.search;
261
+ Component page = this.Match(path);
262
+
258
263
  VElement outlet = VElement.Create("div");
259
264
  outlet.ClassName = "router-outlet";
265
+ outlet.AppendChild(VElement.Mount(page));
260
266
  return outlet;
261
267
  }
262
-
263
- protected override void AfterRender(Element root) {
264
- string path = location.pathname + location.search;
265
- Component page = this.Match(path);
266
- root.textContent = "";
267
- page.Mount(root);
268
- }
269
268
  }