kopular 0.16.1 → 0.17.0

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/LLM.md CHANGED
@@ -239,18 +239,42 @@ w.Bump(); // re-render + diff + patch
239
239
  batched — `Update()` still renders immediately there, exactly as before batching existed.
240
240
  - `AfterRender(root)`: `virtual`, a no-op by default, called at the end of both `Mount()`
241
241
  and `Update()` with the real, now-materialized/patched root `Element`. For a component
242
- that needs to do further imperative work against its own real DOM — most commonly,
243
- mounting a *nested* `Component` into a slot `Render()`'s tree just describes as data,
244
- since a live mounted child isn't something `VElement` can express (see `Router`'s own
245
- use of this to mount its matched page into the outlet, and "Dependency injection" below
246
- for `RoutedApp` doing the same with `Router` itself).
247
- - **Known limitation**: reconciliation is per-`Component`, not across nested ones if a
248
- *parent* component's own `Render()` output changes shape around a slot where a nested
249
- Component was `Mount()`ed via `AfterRender`, that nested Component isn't automatically
250
- re-`Mount()`ed or torn down as part of the parent's diff. Compose independent components
251
- into a stable, unchanging slot (see `Router`'s own outlet, which never changes tag or
252
- position) to avoid this rather than nesting components whose *surrounding* structure
253
- also changes.
242
+ that needs to do further imperative work against its own real DOM — `Router`'s own use
243
+ of this to mount its matched page into a single, always-present outlet is the one real
244
+ user today (see "Dependency injection" below for `RoutedApp` doing the same with `Router`
245
+ itself); for a *dynamic* parent/child relationship, use `VElement.Mount` below instead.
246
+ - **Nested component composition `VElement.Mount(component)`**: embeds a live child
247
+ `Component` directly as a `VElement` tree's own content, so a parent's re-render creates/
248
+ patches/reorders/tears it down declaratively, the same as any other content mode:
249
+ ```ks
250
+ public override VElement Render() {
251
+ VElement ul = VElement.Create("ul");
252
+ this.Items.ForEach((TodoItem item) => {
253
+ VElement slot = VElement.Mount(item); // item is a Component
254
+ slot.Id = item.Id; // stable key, same convention as any list
255
+ ul.AppendChild(slot);
256
+ });
257
+ return ul;
258
+ }
259
+ ```
260
+ The SAME `Mountable` instance still in a slot across a re-render is patched in place
261
+ (`Update()` inside that child re-renders just its own subtree, siblings untouched); a
262
+ DIFFERENT instance (or the slot disappearing) tears the old one down first — calling its
263
+ `OnUnmount()` — then mounts the new one fresh. Removing a component that itself mounted
264
+ further children tears the whole subtree down, however many levels deep; a middle
265
+ component overriding its own `OnUnmount()` doesn't skip its children's.
266
+ - `OnUnmount()`: `virtual`, a no-op by default, called once when a mounted component is
267
+ removed or replaced. Override it to release anything held onto that would otherwise
268
+ outlive the removal — most commonly, calling the unsubscribe handle `state<T>.Subscribe`
269
+ now returns (see `kopscript`'s own `LLM.md`) for a subscription made in the constructor.
270
+ A component built once at app startup and never removed (every `Router` page, most real
271
+ apps' top-level structure) never needs this at all.
272
+ - `Router`'s own outlet still uses the older `AfterRender` pattern above — a single,
273
+ always-present slot has nothing to reorder or remove, so this mechanism wouldn't add
274
+ anything there. Reach for `VElement.Mount` for anything genuinely dynamic: a list of
275
+ components, a modal that comes and goes.
276
+ - Hand-written `Render()` only — there's no template (`.html`) syntax yet for embedding a
277
+ child component the way `*if`/`*for` embed structural logic.
254
278
  - **`Update()` before `Mount()` is a safe no-op**, not an error. This matters for two
255
279
  sibling `Component`s (e.g. two `Router` pages) that share one injected service's
256
280
  `state<T>` and both `Subscribe()` it — every route's page is constructed eagerly (see
@@ -568,9 +592,9 @@ class RoutedApp : Component {
568
592
  public override VElement Render() {
569
593
  return VElement.Create("div");
570
594
  }
571
- // Mounting Routera live, nested Componentisn't something a VElement
572
- // tree can express as data; AfterRender gets the real, now-materialized
573
- // root Element instead (see "Component" above).
595
+ // A single, always-present slot nothing to reorder or remove so the
596
+ // older AfterRender pattern is fine here; VElement.Mount (see "Component"
597
+ // above) is for anything genuinely dynamic instead.
574
598
  protected override void AfterRender(Element root) {
575
599
  this.Nav.Mount(root);
576
600
  }
@@ -626,11 +650,12 @@ class CounterService {
626
650
  of the tree's own data, not wired against a live DOM node until `Materialize`/`Patch`
627
651
  runs) — an event Kopular doesn't have a named field for isn't reachable from `Render()`
628
652
  at all yet.
629
- - **Reconciliation doesn't cross a `Mount()`ed-via-`AfterRender` boundary.** Real vdom
630
- diffing patches one `Component`'s own subtree; a *nested* Component mounted into a slot
631
- via `AfterRender` (see `Router`'s outlet, or `RoutedApp` mounting `Router` itself) isn't
632
- automatically re-`Mount()`ed if the *parent's* surrounding tree shape changes — see
633
- "Component"'s own "Known limitation" above.
653
+ - **A `Component` embedded via `AfterRender`/`Mount()` (imperative, `Router`'s own outlet
654
+ pattern) is NOT the same as one embedded via `VElement.Mount()` (declarative, part of
655
+ the tree)** only the latter gets real reconciliation (patch in place, reorder, tear
656
+ down). If you find yourself calling `.Mount()`/`.Update()` on a child by hand from
657
+ `AfterRender` for anything that needs to be added, removed, or reordered, use
658
+ `VElement.Mount(component)` in `Render()` instead — see "Component" above.
634
659
 
635
660
  ## Does not exist
636
661
 
@@ -640,8 +665,8 @@ imperative `Render()` code as the hand-written form, checked at compile time, no
640
665
  interpreted at runtime (see "Templates" above) · two-way binding in a hand-written
641
666
  `Render()`, or on anything but `value` even in a template (`[(value)]="Field"` exists —
642
667
  see "Templates" above — but it's `value`-only, and templates-only) ·
643
- reconciliation across a nested-Component boundary (see "Common mistakes" above real
644
- vdom diffing exists *within* one Component's own subtree, via `Update()`/`vdom.ks`) ·
668
+ embedding a child Component from a *template* (`.html`)`VElement.Mount(component)`
669
+ works only from a hand-written `Render()` today, see "Component" above ·
645
670
  a generic/arbitrary `VElement` event binding — only `click`/`input`/`blur`/`change` ·
646
671
  pipes · animations · typed/generic HTTP responses (`Http` returns raw text — see above) ·
647
672
  SSR.
package/README.md CHANGED
@@ -383,6 +383,91 @@ attached to it, like focus) rather than rebuilding it, the same way `*ngFor`'s o
383
383
  `trackBy` avoids rebuilding unchanged rows. Without a stable `Id`, a list still renders
384
384
  correctly on reorder, but a given item's real node isn't guaranteed to follow its data.
385
385
 
386
+ ## Nested component composition
387
+
388
+ Everything above builds *content* into a `VElement` tree — a real, independent child
389
+ `Component` (its own fields, its own `Render()`, its own reactive `state<T>`) is a
390
+ different thing to embed than a plain element. `VElement.Mount(component)` wraps one as a
391
+ slot the diff engine treats like any other content: created on first render, patched in
392
+ place (not torn down and rebuilt) across a re-render as long as the *same* instance still
393
+ occupies that slot, reordered by `.Id` exactly like any other keyed child, and torn down
394
+ — calling `OnUnmount()` — the moment it's replaced or removed:
395
+
396
+ ```ks
397
+ class TodoItem : Component {
398
+ public string Id;
399
+ private string Text;
400
+ constructor(string id, string text) : base() {
401
+ this.Id = id;
402
+ this.Text = text;
403
+ }
404
+
405
+ public override VElement Render() {
406
+ VElement li = VElement.Create("li");
407
+ li.TextContent = this.Text;
408
+ return li;
409
+ }
410
+ }
411
+
412
+ class TodoList : Component {
413
+ private TodoItem[] Items;
414
+ constructor() : base() { this.Items = []; }
415
+
416
+ public override VElement Render() {
417
+ VElement ul = VElement.Create("ul");
418
+ this.Items.ForEach((TodoItem item) => {
419
+ VElement slot = VElement.Mount(item);
420
+ slot.Id = item.Id; // stable key — the same convention as any other list, above
421
+ ul.AppendChild(slot);
422
+ });
423
+ return ul;
424
+ }
425
+
426
+ public void Add(string id, string text) {
427
+ this.Items = this.Items.Push(new TodoItem(id, text));
428
+ this.Update();
429
+ }
430
+
431
+ public void Remove(string id) {
432
+ this.Items = this.Items.Filter((TodoItem i) => i.Id != id);
433
+ this.Update(); // the removed TodoItem's OnUnmount() fires here
434
+ }
435
+ }
436
+ ```
437
+
438
+ Each `TodoItem` is a genuinely independent `Component` — it can hold its own local
439
+ `state<T>`, subscribe to a shared service, or mount further children of its own (`Update()`
440
+ called from inside one re-renders just that item's own subtree, without disturbing its
441
+ siblings, exactly like the plain-`VElement` keyed-list case above). Override `OnUnmount()`
442
+ to release anything a removed instance was holding onto — most commonly, calling the
443
+ unsubscribe handle `state<T>.Subscribe` now returns (see `kopscript`'s own `LLM.md`) for a
444
+ subscription made in the constructor:
445
+
446
+ ```ks
447
+ class TodoItem : Component {
448
+ private () => void UnsubscribeShared;
449
+ constructor(SharedService shared) : base() {
450
+ this.UnsubscribeShared = shared.Count.Subscribe((number v) => this.Update());
451
+ }
452
+ protected override void OnUnmount() {
453
+ this.UnsubscribeShared();
454
+ }
455
+ }
456
+ ```
457
+
458
+ A component built once at app startup and never removed (every page `Router` manages
459
+ today, most real apps' top-level structure) never needs `OnUnmount()` at all — it's there
460
+ for the case a real dynamic list like `TodoList` above actually needs: a subscription that
461
+ must stop, not just outlive an app that was going to unload anyway. Removing a component
462
+ that itself mounted further children tears the *whole* subtree down, however many levels
463
+ deep — a middle component overriding its own `OnUnmount()` doesn't skip its children's.
464
+
465
+ `Router`'s own outlet still uses the older `AfterRender`-based pattern (see "Router, and
466
+ deploying it" below) rather than this — a single, always-present slot has no reordering or
467
+ removal to get right, so there's nothing this mechanism would add there. Reach for
468
+ `VElement.Mount` for anything with real add/remove/reorder: a list of components, a modal
469
+ that comes and goes, anything genuinely dynamic.
470
+
386
471
  ## HTTP
387
472
 
388
473
  ```ks
@@ -612,17 +697,16 @@ checked out as a sibling directory or anything else local to build or test.
612
697
 
613
698
  ## Status
614
699
 
615
- v1 / hobby-project scope, same as KopScript itself. `Update()` now diffs and patches
616
- real DOM (see "Component" above) rather than replacing a whole subtree on every
617
- re-render but reconciliation is still per-`Component`, not across nested ones: if a
618
- *parent* Component's own `Render()` output changes shape around a slot where a *nested*
619
- Component was imperatively `Mount()`ed (via `AfterRender` see `Router`'s own pattern),
620
- that nested Component isn't automatically re-`Mount()`ed or torn down as part of the
621
- parent's diff, since a live mounted child isn't something a `VElement` tree can express
622
- as data. Composing independent components into a stable, unchanging slot the way every
623
- real use of `AfterRender` in this codebase already does, `Router`'s own outlet included
624
- avoids the issue entirely. A list child without a stable `VElement.Id` similarly still
625
- renders correctly across a reorder, but isn't guaranteed to keep the same real DOM node
626
- (see "Structural directives" above). `Update()` called before `Mount()` is a safe no-op
627
- see `LLM.md`'s `Component` section for exactly when that happens (sibling pages
628
- sharing one injected service's `state<T>`).
700
+ v1 / hobby-project scope, same as KopScript itself. `Update()` diffs and patches real DOM
701
+ (see "Component" above) rather than replacing a whole subtree on every re-render, and a
702
+ live child Component can be embedded directly in a parent's own tree via
703
+ `VElement.Mount(child)` see "Nested component composition" above so a parent's
704
+ re-render really can create/patch/reorder/tear down nested children declaratively now,
705
+ closing what used to be documented here as the framework's biggest reconciliation gap.
706
+ The one real limit that remains: this only works from a hand-written `Render()` today
707
+ there's no template (`.html`) syntax yet for embedding a child component the way `*if`/
708
+ `*for` embed structural logic. A list child (`Mounted` or plain) without a stable
709
+ `VElement.Id` still renders correctly across a reorder, but isn't guaranteed to keep the
710
+ same real DOM node (see "Structural directives" above). `Update()` called before `Mount()`
711
+ is a safe no-op — see `LLM.md`'s `Component` section for exactly when that happens
712
+ (sibling pages sharing one injected service's `state<T>`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
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",
@@ -60,7 +60,7 @@
60
60
  "@types/jsdom": "^30.0.0",
61
61
  "@types/node": "^20.14.0",
62
62
  "jsdom": "^25.0.1",
63
- "kopscript": "^0.21.0",
63
+ "kopscript": "^0.22.0",
64
64
  "typescript": "^5.5.0",
65
65
  "vitest": "^4.1.11"
66
66
  },
package/src/component.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Event, Element, Document, Location, History, Window, document, location, history, window } from "./dom.js";
2
2
  import { VElement, NoOpEventHandler } from "./velement.js";
3
- import { Batching, Materialize, Patch, PatchChildren } from "./vdom.js";
3
+ import { Batching, Materialize, Patch, UnmountPrevious, PatchChildren, UnmountTree } from "./vdom.js";
4
4
 
5
5
  export class Component {
6
6
  constructor() {
@@ -29,7 +29,7 @@ export class Component {
29
29
  Mount(parent) {
30
30
  this.ParentElement = parent;
31
31
  this.Tree = this.SafeRender();
32
- let root = Materialize(this.Tree);
32
+ let root = Materialize(this.Tree, parent);
33
33
  parent.appendChild(root);
34
34
  this.IsMounted = true;
35
35
  this.AfterRender(root);
@@ -46,11 +46,37 @@ export class Component {
46
46
  this.FlushUpdate();
47
47
  }
48
48
 
49
- FlushUpdate() {
49
+ RenderAndPatch() {
50
50
  let newTree = this.SafeRender();
51
51
  let root = Patch(this.ParentElement, this.Tree, newTree);
52
52
  this.Tree = newTree;
53
53
  this.AfterRender(root);
54
+ return root;
55
+ }
56
+
57
+ FlushUpdate() {
58
+ this.RenderAndPatch();
59
+ }
60
+
61
+ MountAsChild(parent) {
62
+ this.ParentElement = parent;
63
+ this.Tree = this.SafeRender();
64
+ let root = Materialize(this.Tree, parent);
65
+ this.IsMounted = true;
66
+ this.AfterRender(root);
67
+ return root;
68
+ }
69
+
70
+ PatchAsChild() {
71
+ return this.RenderAndPatch();
72
+ }
73
+
74
+ Teardown() {
75
+ UnmountTree(this.Tree);
76
+ this.OnUnmount();
77
+ }
78
+
79
+ OnUnmount() {
54
80
  }
55
81
  }
56
82
 
@@ -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// Known limitation: if a *parent* component's own Render() re-runs (i.e.\n// something calls Update() on the parent) while it has mounted children,\n// those children are not automatically re-mounted into the parent's new\n// tree this base class only handles a single component's own re-render\n// cycle, not parent/child reconciliation across one. Composing independent\n// components (each mounted into its own stable slot, as in app.kop) avoids\n// the issue entirely.\nclass Component : Flushable {\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 is the one\n // real user of this today: mounting/re-mounting its matched child PAGE\n // into the outlet div its own Render() just describes, since a nested\n // Component's own mount lifecycle isn't something a VElement tree can\n // express as data (see the class comment above on parent/child\n // reconciliation being a known, deliberately out-of-scope limitation —\n // this hook is the documented way around it, not a fix to it).\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);\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 // 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 VElement newTree = this.SafeRender();\n Element root = Patch(this.ParentElement, this.Tree, newTree);\n this.Tree = newTree;\n this.AfterRender(root);\n }\n}\n"],"names":[],"mappings":";;;;AAoBA;EAaE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAec;;;EAGX;IACc;IACT;IACV;IACkB;IACH;IACC;;;EA8BR;IACR;MACE;;IAEF;MACgB;MACd;;IAEc;;;EAWX;IACL;IACA;IACU;IACM"}
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"}
package/src/component.ks CHANGED
@@ -11,14 +11,15 @@ using "./vdom";
11
11
  // changed tag, or no previous tree at all), not the default on every
12
12
  // re-render the way it used to be.
13
13
  //
14
- // Known limitation: if a *parent* component's own Render() re-runs (i.e.
15
- // something calls Update() on the parent) while it has mounted children,
16
- // those children are not automatically re-mounted into the parent's new
17
- // tree this base class only handles a single component's own re-render
18
- // cycle, not parent/child reconciliation across one. Composing independent
19
- // components (each mounted into its own stable slot, as in app.kop) avoids
20
- // the issue entirely.
21
- class Component : Flushable {
14
+ // Nested composition: a live child Component can be embedded directly in a
15
+ // parent's own VElement tree via `VElement.Mount(child)` (velement.ks)
16
+ // the vdom engine (vdom.ks's Materialize/Patch/PatchChildren) creates,
17
+ // patches in place, reorders (given a stable .Id), and tears down a
18
+ // mounted child declaratively, the same as any other content mode. This
19
+ // is what MountAsChild/PatchAsChild/Teardown below exist for; app code
20
+ // calls Mount(parent)/Update() as before and never touches those three
21
+ // directly.
22
+ class Component : Flushable, Mountable {
22
23
  protected VElement Tree;
23
24
  private Element ParentElement;
24
25
  // Set true only once Mount() actually runs. A page Component is commonly
@@ -63,20 +64,19 @@ class Component : Flushable {
63
64
  // component's own VElement tree into `root` — a hook for a component
64
65
  // that needs to do additional, imperative work against its OWN
65
66
  // now-real root element once it exists. Default is a no-op, so every
66
- // component that doesn't need this is unaffected. Router is the one
67
- // real user of this today: mounting/re-mounting its matched child PAGE
68
- // into the outlet div its own Render() just describes, since a nested
69
- // Component's own mount lifecycle isn't something a VElement tree can
70
- // express as data (see the class comment above on parent/child
71
- // reconciliation being a known, deliberately out-of-scope limitation
72
- // this hook is the documented way around it, not a fix to it).
67
+ // component that doesn't need this is unaffected. Router still uses
68
+ // this today for its outlet (a single, always-present slot it manages
69
+ // by hand) a dynamic parent/child relationship (a list of children,
70
+ // conditional presence) is better served by embedding the child via
71
+ // `VElement.Mount(child)` instead (see the class comment above), which
72
+ // gets real reconciliation for free instead of needing this hook at all.
73
73
  protected virtual void AfterRender(Element root) {
74
74
  }
75
75
 
76
76
  public void Mount(Element parent) {
77
77
  this.ParentElement = parent;
78
78
  this.Tree = this.SafeRender();
79
- Element root = Materialize(this.Tree);
79
+ Element root = Materialize(this.Tree, parent);
80
80
  parent.appendChild(root);
81
81
  this.IsMounted = true;
82
82
  this.AfterRender(root);
@@ -120,6 +120,18 @@ class Component : Flushable {
120
120
  this.FlushUpdate();
121
121
  }
122
122
 
123
+ // Shared by FlushUpdate and PatchAsChild below — re-renders and patches
124
+ // this component's own tree in place against its CURRENT this.ParentElement
125
+ // (Mount()/MountAsChild() already recorded whichever real element that
126
+ // is, top-level or nested), returning the resulting real root node.
127
+ private Element RenderAndPatch() {
128
+ VElement newTree = this.SafeRender();
129
+ Element root = Patch(this.ParentElement, this.Tree, newTree);
130
+ this.Tree = newTree;
131
+ this.AfterRender(root);
132
+ return root;
133
+ }
134
+
123
135
  // The real re-render Update() performs immediately outside a batch, or
124
136
  // that Batching.Run flushes once for every component deferred during one.
125
137
  // Public only because implementing the Flushable interface (see vdom.ks)
@@ -129,9 +141,50 @@ class Component : Flushable {
129
141
  // calling it directly bypasses batching entirely. Update() is still the
130
142
  // real, documented entry point for that.
131
143
  public void FlushUpdate() {
132
- VElement newTree = this.SafeRender();
133
- Element root = Patch(this.ParentElement, this.Tree, newTree);
134
- this.Tree = newTree;
144
+ this.RenderAndPatch();
145
+ }
146
+
147
+ // Mountable's own entry points (velement.ks) — called by the vdom engine
148
+ // (vdom.ks's Materialize/Patch) when this Component is embedded as a
149
+ // live child in an ANCESTOR's own VElement tree via VElement.Mount(this),
150
+ // not meant to be called directly by app code (public only for interface
151
+ // conformance, same reason FlushUpdate is). Mirrors Mount(parent) minus
152
+ // the internal appendChild — the caller (Materialize/Patch) inserts the
153
+ // returned node itself, the same as every other VElement content mode.
154
+ public Element MountAsChild(Element parent) {
155
+ this.ParentElement = parent;
156
+ this.Tree = this.SafeRender();
157
+ Element root = Materialize(this.Tree, parent);
158
+ this.IsMounted = true;
135
159
  this.AfterRender(root);
160
+ return root;
161
+ }
162
+
163
+ public Element PatchAsChild() {
164
+ return this.RenderAndPatch();
165
+ }
166
+
167
+ // Mountable's teardown entry point — NOT itself meant to be overridden
168
+ // (see OnUnmount below, the real app-facing extension point). Always
169
+ // cascades into this component's own last-rendered tree first, via
170
+ // UnmountTree (vdom.ks), so removing ONE component transitively tears
171
+ // down everything IT mounted too — however many Mounted levels deep —
172
+ // before calling the one real, overridable hook. Public only for the
173
+ // same structural-conformance reason as MountAsChild/PatchAsChild above.
174
+ public void Teardown() {
175
+ UnmountTree(this.Tree);
176
+ this.OnUnmount();
177
+ }
178
+
179
+ // Override this — not Teardown — to release anything THIS component
180
+ // holds onto when it's removed from its parent's tree: a state<T>
181
+ // Subscribe made in its own constructor (see state<T>'s own returned
182
+ // unsubscribe handle), a timer, anything else that would otherwise keep
183
+ // running for this component's remaining, now-detached lifetime. Called
184
+ // once, automatically, from Teardown() above — Teardown's own cascade
185
+ // into nested Mounted children still happens regardless of whether (or
186
+ // how) a subclass overrides this. Default is a no-op: purely additive,
187
+ // no existing subclass is affected unless it opts in.
188
+ protected virtual void OnUnmount() {
136
189
  }
137
190
  }
package/src/forms.js CHANGED
@@ -10,6 +10,7 @@ class __KopState {
10
10
  }
11
11
  Subscribe(listener) {
12
12
  this._listeners.push(listener);
13
+ return () => { this._listeners = this._listeners.filter((l) => l !== listener); };
13
14
  }
14
15
  }
15
16
 
package/src/forms.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"forms.js","sources":["forms.ks"],"sourcesContent":["// FormField<T>: a single form input's value, validation state, and touched\n// flag, built on the same state<T> reactivity Component already uses — no\n// new reactive primitive, and deliberately no DOM binding of its own\n// (wiring Value to a real <input> is one addEventListener call in your own\n// Render(), the same as Counter's own click handler — see Kopular's README).\n//\n// A validator is a plain (T) => string? function: null means valid, the\n// same \"null means nothing to report\" convention KopScript's nullable\n// types already use elsewhere. There's no array of validators — KopScript\n// has no syntax for an array of function values — so combining more than\n// one check is either an if-chain in one lambda, hand-written:\n//\n// FormField<string> name = new FormField<string>(\"\", (string v) => {\n// string? required = Validators.Required(v);\n// if (required != null) { return required; }\n// return Validators.MaxLength(v, 40);\n// });\n//\n// or the CombineValidators2/CombineValidators3 free functions below, for\n// the common case of just chaining a couple of already-built validators\n// with no custom logic of their own:\n//\n// FormField<string> name = new FormField<string>(\"\",\n// CombineValidators2((string v) => Validators.Required(v), (string v) => Validators.MaxLength(v, 40)));\n//\n// Free functions, not static Validators methods, and fixed-arity (2 and 3),\n// not a general array-taking Validators.All(...): KopScript has no\n// array-of-function-values type to accept a variable-length list with, and\n// a generic function can only be a *free* function in v1 — a class's own\n// static method can't introduce a new type parameter of its own beyond the\n// class's (Validators itself isn't generic) — see KopScript's README/LLM.md\n// \"Generics\" section for exactly that cut. Add CombineValidators4 etc. the\n// same way if a real form ever needs to chain more than three.\n(T) => string? CombineValidators2<T>((T) => string? v1, (T) => string? v2) {\n return (T value) => {\n string? r1 = v1(value);\n if (r1 != null) { return r1; }\n return v2(value);\n };\n}\n\n(T) => string? CombineValidators3<T>((T) => string? v1, (T) => string? v2, (T) => string? v3) {\n return (T value) => {\n string? r1 = v1(value);\n if (r1 != null) { return r1; }\n string? r2 = v2(value);\n if (r2 != null) { return r2; }\n return v3(value);\n };\n}\nclass FormField<T> {\n public state<T> Value;\n public state<string?> Error;\n public state<bool> Touched;\n\n constructor(T initial, (T) => string? validate) {\n this.Value = state(initial);\n this.Error = state(validate(initial));\n this.Touched = state(false);\n this.Value.Subscribe((T v) => { this.Error.Value = validate(v); });\n }\n\n // Call on blur — separate from Error so a fresh, untouched field with an\n // invalid initial value (e.g. Required on an empty string) doesn't show\n // an error message before the user has had a chance to type anything.\n public void Touch() {\n this.Touched.Value = true;\n }\n\n public bool Valid() {\n return this.Error.Value == null;\n }\n}\n\n// A small set of common checks, each returning an error message or null —\n// not a validation framework, just the handful of checks almost every form\n// needs, so most fields don't have to hand-write string-length arithmetic.\nclass Validators {\n public static string? Required(string value) {\n if (value.Trim().Length == 0) { return \"Required\"; }\n return null;\n }\n\n public static string? MinLength(string value, number min) {\n if (value.Length < min) { return $\"Must be at least {min} characters\"; }\n return null;\n }\n\n public static string? MaxLength(string value, number max) {\n if (value.Length > max) { return $\"Must be at most {max} characters\"; }\n return null;\n }\n\n public static string? Email(string value) {\n return match value {\n r\"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$\" => null,\n _ => \"Must be a valid email\"\n };\n }\n\n public static string? Min(number value, number min) {\n if (value < min) { return $\"Must be at least {min}\"; }\n return null;\n }\n\n public static string? Max(number value, number max) {\n if (value > max) { return $\"Must be at most {max}\"; }\n return null;\n }\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;AAiCA;EACE;EACE;EACA;IAAkB;;EAClB;;;AAIJ;EACE;EACE;EACA;IAAkB;;EAClB;EACA;IAAkB;;EAClB;;;AAGJ;EAKE;IACa;IACA;IACE;IACO;EAA6B;;;;EAM5C;IACc;;;EAGd;IACL;;;AAOJ;EACgB;IACZ;MAAgC;;IAChC;;;EAGY;IACZ;MAA0B;;IAC1B;;;EAGY;IACZ;MAA0B;;IAC1B;;;EAGY;IACZ;;EACE;;;EACA;;;;;;EAIU;IACZ;MAAmB;;IACnB;;;EAGY;IACZ;MAAmB;;IACnB"}
1
+ {"version":3,"file":"forms.js","sources":["forms.ks"],"sourcesContent":["// FormField<T>: a single form input's value, validation state, and touched\n// flag, built on the same state<T> reactivity Component already uses — no\n// new reactive primitive, and deliberately no DOM binding of its own\n// (wiring Value to a real <input> is one addEventListener call in your own\n// Render(), the same as Counter's own click handler — see Kopular's README).\n//\n// A validator is a plain (T) => string? function: null means valid, the\n// same \"null means nothing to report\" convention KopScript's nullable\n// types already use elsewhere. There's no array of validators — KopScript\n// has no syntax for an array of function values — so combining more than\n// one check is either an if-chain in one lambda, hand-written:\n//\n// FormField<string> name = new FormField<string>(\"\", (string v) => {\n// string? required = Validators.Required(v);\n// if (required != null) { return required; }\n// return Validators.MaxLength(v, 40);\n// });\n//\n// or the CombineValidators2/CombineValidators3 free functions below, for\n// the common case of just chaining a couple of already-built validators\n// with no custom logic of their own:\n//\n// FormField<string> name = new FormField<string>(\"\",\n// CombineValidators2((string v) => Validators.Required(v), (string v) => Validators.MaxLength(v, 40)));\n//\n// Free functions, not static Validators methods, and fixed-arity (2 and 3),\n// not a general array-taking Validators.All(...): KopScript has no\n// array-of-function-values type to accept a variable-length list with, and\n// a generic function can only be a *free* function in v1 — a class's own\n// static method can't introduce a new type parameter of its own beyond the\n// class's (Validators itself isn't generic) — see KopScript's README/LLM.md\n// \"Generics\" section for exactly that cut. Add CombineValidators4 etc. the\n// same way if a real form ever needs to chain more than three.\n(T) => string? CombineValidators2<T>((T) => string? v1, (T) => string? v2) {\n return (T value) => {\n string? r1 = v1(value);\n if (r1 != null) { return r1; }\n return v2(value);\n };\n}\n\n(T) => string? CombineValidators3<T>((T) => string? v1, (T) => string? v2, (T) => string? v3) {\n return (T value) => {\n string? r1 = v1(value);\n if (r1 != null) { return r1; }\n string? r2 = v2(value);\n if (r2 != null) { return r2; }\n return v3(value);\n };\n}\nclass FormField<T> {\n public state<T> Value;\n public state<string?> Error;\n public state<bool> Touched;\n\n constructor(T initial, (T) => string? validate) {\n this.Value = state(initial);\n this.Error = state(validate(initial));\n this.Touched = state(false);\n this.Value.Subscribe((T v) => { this.Error.Value = validate(v); });\n }\n\n // Call on blur — separate from Error so a fresh, untouched field with an\n // invalid initial value (e.g. Required on an empty string) doesn't show\n // an error message before the user has had a chance to type anything.\n public void Touch() {\n this.Touched.Value = true;\n }\n\n public bool Valid() {\n return this.Error.Value == null;\n }\n}\n\n// A small set of common checks, each returning an error message or null —\n// not a validation framework, just the handful of checks almost every form\n// needs, so most fields don't have to hand-write string-length arithmetic.\nclass Validators {\n public static string? Required(string value) {\n if (value.Trim().Length == 0) { return \"Required\"; }\n return null;\n }\n\n public static string? MinLength(string value, number min) {\n if (value.Length < min) { return $\"Must be at least {min} characters\"; }\n return null;\n }\n\n public static string? MaxLength(string value, number max) {\n if (value.Length > max) { return $\"Must be at most {max} characters\"; }\n return null;\n }\n\n public static string? Email(string value) {\n return match value {\n r\"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$\" => null,\n _ => \"Must be a valid email\"\n };\n }\n\n public static string? Min(number value, number min) {\n if (value < min) { return $\"Must be at least {min}\"; }\n return null;\n }\n\n public static string? Max(number value, number max) {\n if (value > max) { return $\"Must be at most {max}\"; }\n return null;\n }\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAiCA;EACE;EACE;EACA;IAAkB;;EAClB;;;AAIJ;EACE;EACE;EACA;IAAkB;;EAClB;EACA;IAAkB;;EAClB;;;AAGJ;EAKE;IACa;IACA;IACE;IACO;EAA6B;;;;EAM5C;IACc;;;EAGd;IACL;;;AAOJ;EACgB;IACZ;MAAgC;;IAChC;;;EAGY;IACZ;MAA0B;;IAC1B;;;EAGY;IACZ;MAA0B;;IAC1B;;;EAGY;IACZ;;EACE;;;EACA;;;;;;EAIU;IACZ;MAAmB;;IACnB;;;EAGY;IACZ;MAAmB;;IACnB"}
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 is a\n // nested Component (the matched page), which isn't something a VElement\n // tree can describe as data — handled imperatively in AfterRender below\n // instead, once the outlet is real. The page instance itself is always\n // freshly Mount()ed into it on every navigation (matching this class's\n // pre-diffing behavior exactly) — a page's own re-renders, once mounted,\n // still go through the normal diffed Update() path when its own state\n // changes; only the *outer* page-switch itself stays a full remount, the\n // same documented \"parent/child reconciliation is out of scope\" trade-off\n // Component's own class comment already makes.\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;;;EAgBc;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 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"}
package/src/router.ks CHANGED
@@ -243,16 +243,17 @@ 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 is a
247
- // nested Component (the matched page), which isn't something a VElement
248
- // tree can describe as data handled imperatively in AfterRender below
249
- // instead, once the outlet is real. The page instance itself is always
250
- // freshly Mount()ed into it on every navigation (matching this class's
251
- // pre-diffing behavior exactly) a page's own re-renders, once mounted,
252
- // still go through the normal diffed Update() path when its own state
253
- // changes; only the *outer* page-switch itself stays a full remount, the
254
- // same documented "parent/child reconciliation is out of scope" trade-off
255
- // Component's own class comment already makes.
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.
256
257
  public override VElement Render() {
257
258
  VElement outlet = VElement.Create("div");
258
259
  outlet.ClassName = "router-outlet";
package/src/vdom.js CHANGED
@@ -32,13 +32,20 @@ export class Batching {
32
32
  }
33
33
  }
34
34
  }
35
- export function Materialize(tree) {
35
+ export function Materialize(tree, parent) {
36
+ let maybeMounted = tree.Mounted;
37
+ if ((maybeMounted !== null)) {
38
+ let m = maybeMounted;
39
+ let mountedRoot = m.MountAsChild(parent);
40
+ tree.RealNode = mountedRoot;
41
+ return mountedRoot;
42
+ }
36
43
  let el = document.createElement(tree.Tag);
37
44
  if ((tree.RawHtml.length > 0)) {
38
45
  el.innerHTML = tree.RawHtml;
39
46
  } else if ((tree.Children.length > 0)) {
40
47
  for (const child of tree.Children) {
41
- el.appendChild(Materialize(child));
48
+ el.appendChild(Materialize(child, el));
42
49
  }
43
50
  } else {
44
51
  el.textContent = tree.TextContent;
@@ -73,13 +80,41 @@ export function Materialize(tree) {
73
80
  return el;
74
81
  }
75
82
  export function Patch(parent, old, updated) {
83
+ let newMounted = updated.Mounted;
84
+ let oldMounted = null;
85
+ if ((old !== null)) {
86
+ let oldTreeForMount = old;
87
+ oldMounted = oldTreeForMount.Mounted;
88
+ }
89
+ if ((newMounted !== null)) {
90
+ let nm = newMounted;
91
+ if ((oldMounted !== null)) {
92
+ let om = oldMounted;
93
+ if ((om === nm)) {
94
+ let reused = nm.PatchAsChild();
95
+ updated.RealNode = reused;
96
+ return reused;
97
+ }
98
+ }
99
+ UnmountPrevious(parent, old, oldMounted);
100
+ let created = nm.MountAsChild(parent);
101
+ parent.appendChild(created);
102
+ updated.RealNode = created;
103
+ return created;
104
+ }
105
+ if ((oldMounted !== null)) {
106
+ UnmountPrevious(parent, old, oldMounted);
107
+ let created = Materialize(updated, parent);
108
+ parent.appendChild(created);
109
+ return created;
110
+ }
76
111
  if ((old !== null)) {
77
112
  let oldTree = old;
78
113
  let maybeOldNode = oldTree.RealNode;
79
114
  if ((maybeOldNode !== null)) {
80
115
  let realNode = maybeOldNode;
81
116
  if ((oldTree.Tag !== updated.Tag)) {
82
- let created = Materialize(updated);
117
+ let created = Materialize(updated, parent);
83
118
  parent.replaceChild(created, realNode);
84
119
  return created;
85
120
  } else {
@@ -147,16 +182,30 @@ export function Patch(parent, old, updated) {
147
182
  return realNode;
148
183
  }
149
184
  } else {
150
- let created = Materialize(updated);
185
+ let created = Materialize(updated, parent);
151
186
  parent.appendChild(created);
152
187
  return created;
153
188
  }
154
189
  } else {
155
- let created = Materialize(updated);
190
+ let created = Materialize(updated, parent);
156
191
  parent.appendChild(created);
157
192
  return created;
158
193
  }
159
194
  }
195
+ export function UnmountPrevious(parent, old, oldMounted) {
196
+ if ((oldMounted !== null)) {
197
+ let m = oldMounted;
198
+ m.Teardown();
199
+ if ((old !== null)) {
200
+ let oldTree = old;
201
+ let maybeOldNode = oldTree.RealNode;
202
+ if ((maybeOldNode !== null)) {
203
+ let oldNode = maybeOldNode;
204
+ parent.removeChild(oldNode);
205
+ }
206
+ }
207
+ }
208
+ }
160
209
  export function PatchChildren(parent, oldChildren, newChildren) {
161
210
  let oldConsumed = oldChildren.map((c) => (false));
162
211
  let matchedOldIndex = newChildren.map((c) => (-1));
@@ -207,6 +256,11 @@ export function PatchChildren(parent, oldChildren, newChildren) {
207
256
  if (oldConsumed[j]) {
208
257
  continue;
209
258
  }
259
+ let maybeOldMounted = oldChildren[j].Mounted;
260
+ if ((maybeOldMounted !== null)) {
261
+ let m = maybeOldMounted;
262
+ m.Teardown();
263
+ }
210
264
  let maybeOldNode = oldChildren[j].RealNode;
211
265
  if ((maybeOldNode !== null)) {
212
266
  let oldNode = maybeOldNode;
@@ -214,5 +268,15 @@ export function PatchChildren(parent, oldChildren, newChildren) {
214
268
  }
215
269
  }
216
270
  }
271
+ export function UnmountTree(tree) {
272
+ let maybeMounted = tree.Mounted;
273
+ if ((maybeMounted !== null)) {
274
+ let m = maybeMounted;
275
+ m.Teardown();
276
+ }
277
+ for (const child of tree.Children) {
278
+ UnmountTree(child);
279
+ }
280
+ }
217
281
 
218
282
  //# sourceMappingURL=vdom.js.map
package/src/vdom.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"vdom.js","sources":["vdom.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\n\n// The diff/patch engine behind real vdom diffing: Component.Update() (see\n// component.ks) calls Patch() with the PREVIOUS render's VElement tree\n// (which carries each node's real, live DOM counterpart via its own\n// RealNode field) and the NEW tree Render() just produced, and gets back\n// real DOM mutated/reused in place wherever possible instead of a full\n// subtree rebuild.\n//\n// Deliberately never reads the live DOM back to rediscover structure (no\n// \"get current children\"/\"get current tag\" API exists, or is needed) —\n// the retained *previous* VElement tree already records everything Patch()\n// needs to know about what's currently there. This is what makes the whole\n// approach work without a generic children/attributes read-back API that\n// KopScript's narrow, curated DOM binding doesn't have.\n\n// A component-like thing Batching (below) can flush once every DOM-event-\n// triggered Update() call inside one batch has registered itself. Declared\n// HERE, not in component.ks, specifically so THIS file's own Materialize/\n// Patch — which need to start/stop a batch around every real event\n// dispatch — never have to `using \"./component\"`: component.ks already\n// `using`s this file for Materialize/Patch themselves, and KopScript\n// rejects a circular `using` outright. Component (component.ks) is the\n// one real implementer, via `class Component : Flushable`.\ninterface Flushable {\n void FlushUpdate();\n}\n\n// Coalesces every Update() call made while a real DOM event handler\n// Kopular itself attached (see Materialize/Patch below, both of which wrap\n// each listener in Batching.Run before attaching it) into one flush per\n// affected component, applied once that handler returns — not one flush\n// per state<T> write inside it. See Component.Update()'s own comment\n// (component.ks) for the full rationale; this is just the mechanism.\n//\n// Deliberately class-level, shared across every batch/component rather\n// than per-instance — a single click can trigger Update() on more than one\n// component (e.g. a shared service's state<T> notifying two sibling\n// pages), and all of them need to flush together, once, when that one\n// handler finishes.\nclass Batching {\n private static number Depth = 0;\n private static Flushable[] Pending = [];\n\n public static bool IsActive() {\n return Batching.Depth > 0;\n }\n\n public static void Defer(Flushable f) {\n if (!Batching.Pending.Includes(f)) {\n Batching.Pending = Batching.Pending.Push(f);\n }\n }\n\n // `try`/`finally`, not a bare sequence: a handler that throws must still\n // decrement Depth and flush whatever already-triggered renders are\n // pending, or one uncaught exception would wedge every future click/\n // input on the page into \"always batching, never rendering.\" The\n // exception itself still propagates unchanged — this never catches it,\n // only guarantees the cleanup runs.\n public static void Run(() => void action) {\n Batching.Depth = Batching.Depth + 1;\n try {\n action();\n } finally {\n Batching.Depth = Batching.Depth - 1;\n if (Batching.Depth == 0) {\n Flushable[] toFlush = Batching.Pending;\n Batching.Pending = [];\n foreach (Flushable f in toFlush) {\n f.FlushUpdate();\n }\n }\n }\n }\n}\n\n// Builds a brand-new, fully real DOM subtree from a VElement tree with no\n// diffing at all — first mount, or whenever Patch() decides a subtree must\n// be replaced outright (no previous node to reuse, or the tag changed).\n// Mutates `tree.RealNode` (and recursively every descendant's) as a side\n// effect, so the tree this was called on becomes the new \"previous tree\"\n// the next Patch() call diffs against.\nElement Materialize(VElement tree) {\n Element el = document.createElement(tree.Tag);\n\n if (tree.RawHtml.Length > 0) {\n el.innerHTML = tree.RawHtml;\n } else if (tree.Children.Length > 0) {\n foreach (VElement child in tree.Children) {\n el.appendChild(Materialize(child));\n }\n } else {\n el.textContent = tree.TextContent;\n }\n\n el.className = tree.ClassName;\n el.id = tree.Id;\n el.value = tree.Value;\n\n for (number i = 0; i < tree.ExtraNames.Length; i = i + 1) {\n el.setAttribute(tree.ExtraNames[i], tree.ExtraValues[i]);\n }\n\n // Skip attaching VElement's own shared no-op (see velement.ks) — it does\n // nothing when invoked, so registering it costs real work (a listener\n // list entry, held onto for nothing) for zero benefit. A real handler\n // (never equal to the shared no-op) always gets attached as before —\n // wrapped in Batching.Run so every Update() it triggers coalesces with\n // any others from the same dispatch (see Component.Update()'s own\n // comment). The wrapper itself, not tree.OnClick, is what actually gets\n // registered — recorded on tree.AttachedOnClick (velement.ks) so a later\n // Patch() can remove this exact reference, not the handler value itself.\n if (tree.OnClick != NoOpEventHandler) {\n (Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnClick(e));\n tree.AttachedOnClick = wrapped;\n el.addEventListener(\"click\", wrapped);\n }\n if (tree.OnInput != NoOpEventHandler) {\n (Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnInput(e));\n tree.AttachedOnInput = wrapped;\n el.addEventListener(\"input\", wrapped);\n }\n if (tree.OnBlur != NoOpEventHandler) {\n (Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnBlur(e));\n tree.AttachedOnBlur = wrapped;\n el.addEventListener(\"blur\", wrapped);\n }\n if (tree.OnChange != NoOpEventHandler) {\n (Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnChange(e));\n tree.AttachedOnChange = wrapped;\n el.addEventListener(\"change\", wrapped);\n }\n\n tree.RealNode = el;\n return el;\n}\n\n// Diffs `updated` against `old` (the previous render's tree for this exact\n// position, or null if there is none — first mount) and returns the real\n// DOM node now representing `updated`, reusing `old`'s real node in place\n// whenever the tag matches. `parent` is only used to attach/replace at the\n// top of whatever subtree Patch() is called on — child-level attach/replace\n// happens inside PatchChildren.\n//\n// Deliberately all positive-branch `if (x != null) { ... } else { ... }`,\n// never an early-return guard clause — KopScript's nullable narrowing is\n// scope-based, not reachability-based, so `if (x == null) { return; }\n// use(x);` would NOT narrow `x` afterward (see KopScript's own LLM.md \"Common\n// mistakes\"). Every nullable member-access path (`oldTree.RealNode`,\n// never narrows directly either) is read into a local first for the same\n// reason.\nElement Patch(Element parent, VElement? old, VElement updated) {\n if (old != null) {\n VElement oldTree = old;\n Element? maybeOldNode = oldTree.RealNode;\n if (maybeOldNode != null) {\n Element realNode = maybeOldNode;\n if (oldTree.Tag != updated.Tag) {\n Element created = Materialize(updated);\n parent.replaceChild(created, realNode);\n return created;\n } else {\n updated.RealNode = realNode;\n\n if (updated.RawHtml.Length > 0 || oldTree.RawHtml.Length > 0) {\n if (updated.RawHtml != oldTree.RawHtml) {\n realNode.innerHTML = updated.RawHtml;\n }\n } else {\n if (updated.TextContent != oldTree.TextContent) {\n realNode.textContent = updated.TextContent;\n }\n PatchChildren(realNode, oldTree.Children, updated.Children);\n }\n\n if (updated.ClassName != oldTree.ClassName) {\n realNode.className = updated.ClassName;\n }\n if (updated.Id != oldTree.Id) {\n realNode.id = updated.Id;\n }\n // Always assigned, never conditionally on updated.Value != oldTree.Value\n // — unlike TextContent/ClassName/Id, an <input>/<select>'s live value\n // can diverge from the last-recorded VElement.Value purely through\n // user interaction (typing, picking an option) with no Update() ever\n // running in between (a real, deliberate pattern — see KopularDemo's\n // dogs_page.ks, which never Update()s on input/change). The recorded\n // oldTree.Value only reflects the tree as of the last actual render,\n // so comparing against it can't tell \"genuinely unchanged\" apart from\n // \"changed live in the DOM since then, framework never told\" — the\n // same reason a real \"controlled input\" (React's own term for this)\n // always writes value on every render rather than diffing it.\n realNode.value = updated.Value;\n\n // Same-length is the overwhelmingly common case (the same Render()\n // code path calls SetAttr the same number of times, in the same\n // order, on every call) — compare aligned by index and only touch\n // the real DOM for an entry that actually changed, rather than\n // reapplying every extra attribute on every patch regardless. A\n // length mismatch (the rarer case: a SetAttr call was added,\n // removed, or made conditional between renders) falls back to\n // reapplying everything, since index-aligned comparison isn't\n // meaningful once the two arrays don't correspond entry-for-entry.\n if (updated.ExtraNames.Length == oldTree.ExtraNames.Length) {\n for (number i = 0; i < updated.ExtraNames.Length; i = i + 1) {\n if (updated.ExtraNames[i] != oldTree.ExtraNames[i] || updated.ExtraValues[i] != oldTree.ExtraValues[i]) {\n realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);\n }\n }\n } else {\n for (number i = 0; i < updated.ExtraNames.Length; i = i + 1) {\n realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);\n }\n }\n\n // Swap a listener only when the handler reference actually changed.\n // A node that never sets a real OnClick/OnInput/OnBlur/OnChange\n // keeps VElement's own shared NoOpEventHandler reference on both\n // sides (see velement.ks) — comparing by `!=` costs nothing and\n // skips two real DOM API calls per event per node for the (common)\n // case of \"this node has no handler of this kind either time,\"\n // which matters a lot on a list with hundreds/thousands of rows\n // most of which set at most one or two of the four. A node WITH a\n // real handler still gets a fresh closure every render (it\n // captures per-render values, like a loop's own item), so it still\n // swaps every time — correctly, since the old closure really is\n // stale.\n //\n // The actually-attached listener is always a Batching.Run wrapper\n // (see Materialize above), never `oldTree.OnClick`/`updated.OnClick`\n // themselves — removeEventListener only works when passed the exact\n // reference addEventListener received, so removal always goes\n // through `oldTree.AttachedOnClick` (the wrapper Materialize/a\n // previous Patch actually registered), and a real swap builds a\n // fresh wrapper the same way Materialize does. When nothing swaps,\n // `updated.AttachedOnClick` still needs to carry `oldTree`'s\n // forward — `updated` is a brand-new VElement whose own\n // AttachedOnClick starts back at the shared no-op (velement.ks's\n // constructor), and it becomes the retained \"old tree\" the very\n // next Patch() call diffs against.\n if (updated.OnClick != oldTree.OnClick) {\n realNode.removeEventListener(\"click\", oldTree.AttachedOnClick);\n (Event) => void wrappedClick = (Event e) => Batching.Run(() => updated.OnClick(e));\n updated.AttachedOnClick = wrappedClick;\n realNode.addEventListener(\"click\", wrappedClick);\n } else {\n updated.AttachedOnClick = oldTree.AttachedOnClick;\n }\n if (updated.OnInput != oldTree.OnInput) {\n realNode.removeEventListener(\"input\", oldTree.AttachedOnInput);\n (Event) => void wrappedInput = (Event e) => Batching.Run(() => updated.OnInput(e));\n updated.AttachedOnInput = wrappedInput;\n realNode.addEventListener(\"input\", wrappedInput);\n } else {\n updated.AttachedOnInput = oldTree.AttachedOnInput;\n }\n if (updated.OnBlur != oldTree.OnBlur) {\n realNode.removeEventListener(\"blur\", oldTree.AttachedOnBlur);\n (Event) => void wrappedBlur = (Event e) => Batching.Run(() => updated.OnBlur(e));\n updated.AttachedOnBlur = wrappedBlur;\n realNode.addEventListener(\"blur\", wrappedBlur);\n } else {\n updated.AttachedOnBlur = oldTree.AttachedOnBlur;\n }\n if (updated.OnChange != oldTree.OnChange) {\n realNode.removeEventListener(\"change\", oldTree.AttachedOnChange);\n (Event) => void wrappedChange = (Event e) => Batching.Run(() => updated.OnChange(e));\n updated.AttachedOnChange = wrappedChange;\n realNode.addEventListener(\"change\", wrappedChange);\n } else {\n updated.AttachedOnChange = oldTree.AttachedOnChange;\n }\n\n return realNode;\n }\n } else {\n // Shouldn't happen in practice (every previously-rendered tree has a\n // real node by the time a second render diffs against it) — treated\n // as \"nothing to reuse\" rather than a crash, same defensive spirit\n // as Component's own IsMounted guard.\n Element created = Materialize(updated);\n parent.appendChild(created);\n return created;\n }\n } else {\n Element created = Materialize(updated);\n parent.appendChild(created);\n return created;\n }\n}\n\n// Keyed reconciliation: each VElement's own Id is its key when non-empty —\n// a real, existing DOM convention, needing no new API or syntax. A new\n// child whose Id matches an old child's Id is patched against that old\n// child (reusing its real node) regardless of position; a new child with\n// no Id, or an Id not present among the old children, falls back to\n// pairing positionally against whatever old children are still unconsumed,\n// in order. DOCUMENTED, REAL LIMITATION: without stable Ids, a reordered\n// list still produces the correct final output, but a given item's real\n// DOM node (and anything stateful attached to it, like focus) isn't\n// guaranteed to follow its data across the reorder — give list items a\n// stable Id for that guarantee.\nvoid PatchChildren(Element parent, VElement[] oldChildren, VElement[] newChildren) {\n // Map, not a Push loop — Push is deliberately non-mutating (a real\n // spread-copy every call, see KopScript's own README), so building an\n // n-length array by Push-ing once per element in a loop is an\n // accidental O(n^2) on every single PatchChildren call, however small\n // the actual diff. Map is a real, single O(n) pass straight to\n // Array.prototype.map.\n bool[] oldConsumed = oldChildren.Map((VElement c) => false);\n number[] matchedOldIndex = newChildren.Map((VElement c) => -1);\n\n // Pass 1: keyed matches, by Id.\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n if (newChildren[i].Id.Length == 0) { continue; }\n for (number j = 0; j < oldChildren.Length; j = j + 1) {\n if (!oldConsumed[j] && oldChildren[j].Id == newChildren[i].Id) {\n matchedOldIndex[i] = j;\n oldConsumed[j] = true;\n break;\n }\n }\n }\n\n // Pass 2: positional fallback for everything Pass 1 didn't match —\n // pair each remaining new child against the next still-unconsumed old\n // child, in order.\n number nextOld = 0;\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n if (matchedOldIndex[i] >= 0) { continue; }\n while (nextOld < oldChildren.Length && oldConsumed[nextOld]) {\n nextOld = nextOld + 1;\n }\n if (nextOld < oldChildren.Length) {\n matchedOldIndex[i] = nextOld;\n oldConsumed[nextOld] = true;\n nextOld = nextOld + 1;\n }\n }\n\n // Whether anything is actually moving at all — same length, and every\n // new position matched the *same* old position. The extremely common\n // case for a list that's only had some of its rows' own content change\n // (e.g. \"update every 10th row\"), where reconciliation still has real\n // work to do (see Pass 1/2 above and Patch() itself) but nothing needs\n // to physically move in the DOM at all.\n bool needsReorder = oldChildren.Length != newChildren.Length;\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n if (matchedOldIndex[i] != i) {\n needsReorder = true;\n break;\n }\n }\n\n // Pass 3: patch/create each new child in order, then — only if the list\n // actually needs reordering — move it into its correct final position.\n // appendChild on a node already attached elsewhere in the DOM MOVES it\n // (real DOM semantics), so processing new children in their final\n // desired order and always appending naturally builds up the correct\n // sequence, no separate insertBefore/reference-node bookkeeping needed.\n // Safe because VElement.Children is always the COMPLETE list of a\n // node's children — nothing else ever shares `parent`. Skipping the\n // move entirely when `needsReorder` is false avoids a real DOM API call\n // per child for the common no-reorder case — Patch() itself already\n // updates or replaces a reused/changed node exactly in place either way.\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n VElement? matchedOld = null;\n if (matchedOldIndex[i] >= 0) {\n matchedOld = oldChildren[matchedOldIndex[i]];\n }\n Element childNode = Patch(parent, matchedOld, newChildren[i]);\n if (needsReorder) {\n parent.appendChild(childNode);\n }\n }\n\n // Pass 4: remove whatever old children never got reused.\n for (number j = 0; j < oldChildren.Length; j = j + 1) {\n if (oldConsumed[j]) { continue; }\n Element? maybeOldNode = oldChildren[j].RealNode;\n if (maybeOldNode != null) {\n Element oldNode = maybeOldNode;\n parent.removeChild(oldNode);\n }\n }\n}\n"],"names":[],"mappings":";;;AAyCA;EACiB;;EACA;;EAED;IACZ;;;EAGY;IACZ;MACmB;;;;EAUP;IACG;IACf;MACQ;;MAES;MACf;QACE;QACiB;QACjB;UACe;;;;;;AAavB;EACE;EAEA;IACe;;IAEb;MACgB;;;IAGD;;EAGJ;EACP;EACG;EAET;IACiB;;EAYjB;IACE;IACqB;IACF;;EAErB;IACE;IACqB;IACF;;EAErB;IACE;IACoB;IACD;;EAErB;IACE;IACsB;IACH;;EAGP;EACd;;AAiBF;EACE;IACE;IACA;IACA;MACE;MACA;QACE;QACmB;QACnB;;QAEiB;QAEjB;UACE;YACqB;;;UAGrB;YACuB;;UAEV;;QAGf;UACqB;;QAErB;UACc;;QAaC;QAWf;UACE;YACE;cACuB;;;;UAIzB;YACuB;;;QA6BzB;UAC8B;UAC5B;UACwB;UACC;;UAED;;QAE1B;UAC8B;UAC5B;UACwB;UACC;;UAED;;QAE1B;UAC8B;UAC5B;UACuB;UACE;;UAEF;;QAEzB;UAC8B;UAC5B;UACyB;UACA;;UAEA;;QAG3B;;;MAOF;MACkB;MAClB;;;IAGF;IACkB;IAClB;;;AAeJ;EAOE;EACA;EAGA;IACE;MAAqC;;IACrC;MACE;QACqB;QACJ;QACf;;;;EAQN;EACA;IACE;MAA+B;;IAC/B;MACU;;IAEV;MACqB;MACE;MACb;;;EAUZ;EACA;IACE;MACe;MACb;;;EAeJ;IACE;IACA;MACa;;IAEb;IACA;MACoB;;;EAKtB;IACE;MAAsB;;IACtB;IACA;MACE;MACkB"}
1
+ {"version":3,"file":"vdom.js","sources":["vdom.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\n\n// The diff/patch engine behind real vdom diffing: Component.Update() (see\n// component.ks) calls Patch() with the PREVIOUS render's VElement tree\n// (which carries each node's real, live DOM counterpart via its own\n// RealNode field) and the NEW tree Render() just produced, and gets back\n// real DOM mutated/reused in place wherever possible instead of a full\n// subtree rebuild.\n//\n// Deliberately never reads the live DOM back to rediscover structure (no\n// \"get current children\"/\"get current tag\" API exists, or is needed) —\n// the retained *previous* VElement tree already records everything Patch()\n// needs to know about what's currently there. This is what makes the whole\n// approach work without a generic children/attributes read-back API that\n// KopScript's narrow, curated DOM binding doesn't have.\n\n// A component-like thing Batching (below) can flush once every DOM-event-\n// triggered Update() call inside one batch has registered itself. Declared\n// HERE, not in component.ks, specifically so THIS file's own Materialize/\n// Patch — which need to start/stop a batch around every real event\n// dispatch — never have to `using \"./component\"`: component.ks already\n// `using`s this file for Materialize/Patch themselves, and KopScript\n// rejects a circular `using` outright. Component (component.ks) is the\n// one real implementer, via `class Component : Flushable`.\ninterface Flushable {\n void FlushUpdate();\n}\n\n// Coalesces every Update() call made while a real DOM event handler\n// Kopular itself attached (see Materialize/Patch below, both of which wrap\n// each listener in Batching.Run before attaching it) into one flush per\n// affected component, applied once that handler returns — not one flush\n// per state<T> write inside it. See Component.Update()'s own comment\n// (component.ks) for the full rationale; this is just the mechanism.\n//\n// Deliberately class-level, shared across every batch/component rather\n// than per-instance — a single click can trigger Update() on more than one\n// component (e.g. a shared service's state<T> notifying two sibling\n// pages), and all of them need to flush together, once, when that one\n// handler finishes.\nclass Batching {\n private static number Depth = 0;\n private static Flushable[] Pending = [];\n\n public static bool IsActive() {\n return Batching.Depth > 0;\n }\n\n public static void Defer(Flushable f) {\n if (!Batching.Pending.Includes(f)) {\n Batching.Pending = Batching.Pending.Push(f);\n }\n }\n\n // `try`/`finally`, not a bare sequence: a handler that throws must still\n // decrement Depth and flush whatever already-triggered renders are\n // pending, or one uncaught exception would wedge every future click/\n // input on the page into \"always batching, never rendering.\" The\n // exception itself still propagates unchanged — this never catches it,\n // only guarantees the cleanup runs.\n public static void Run(() => void action) {\n Batching.Depth = Batching.Depth + 1;\n try {\n action();\n } finally {\n Batching.Depth = Batching.Depth - 1;\n if (Batching.Depth == 0) {\n Flushable[] toFlush = Batching.Pending;\n Batching.Pending = [];\n foreach (Flushable f in toFlush) {\n f.FlushUpdate();\n }\n }\n }\n }\n}\n\n// Builds a brand-new, fully real DOM subtree from a VElement tree with no\n// diffing at all — first mount, or whenever Patch() decides a subtree must\n// be replaced outright (no previous node to reuse, or the tag changed).\n// Mutates `tree.RealNode` (and recursively every descendant's) as a side\n// effect, so the tree this was called on becomes the new \"previous tree\"\n// the next Patch() call diffs against. Takes `parent` — every call site\n// already knows it (either Patch's own `parent` parameter, or the real\n// element a recursive child call is about to be appendChild'd into) —\n// solely to hand to a Mounted slot's own MountAsChild, which needs to\n// remember its real parent for THAT component's own future self-triggered\n// re-renders; an ordinary (non-Mounted) VElement never uses it.\nElement Materialize(VElement tree, Element parent) {\n Mountable? maybeMounted = tree.Mounted;\n if (maybeMounted != null) {\n Mountable m = maybeMounted;\n Element mountedRoot = m.MountAsChild(parent);\n tree.RealNode = mountedRoot;\n return mountedRoot;\n }\n\n Element el = document.createElement(tree.Tag);\n\n if (tree.RawHtml.Length > 0) {\n el.innerHTML = tree.RawHtml;\n } else if (tree.Children.Length > 0) {\n foreach (VElement child in tree.Children) {\n el.appendChild(Materialize(child, el));\n }\n } else {\n el.textContent = tree.TextContent;\n }\n\n el.className = tree.ClassName;\n el.id = tree.Id;\n el.value = tree.Value;\n\n for (number i = 0; i < tree.ExtraNames.Length; i = i + 1) {\n el.setAttribute(tree.ExtraNames[i], tree.ExtraValues[i]);\n }\n\n // Skip attaching VElement's own shared no-op (see velement.ks) — it does\n // nothing when invoked, so registering it costs real work (a listener\n // list entry, held onto for nothing) for zero benefit. A real handler\n // (never equal to the shared no-op) always gets attached as before —\n // wrapped in Batching.Run so every Update() it triggers coalesces with\n // any others from the same dispatch (see Component.Update()'s own\n // comment). The wrapper itself, not tree.OnClick, is what actually gets\n // registered — recorded on tree.AttachedOnClick (velement.ks) so a later\n // Patch() can remove this exact reference, not the handler value itself.\n if (tree.OnClick != NoOpEventHandler) {\n (Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnClick(e));\n tree.AttachedOnClick = wrapped;\n el.addEventListener(\"click\", wrapped);\n }\n if (tree.OnInput != NoOpEventHandler) {\n (Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnInput(e));\n tree.AttachedOnInput = wrapped;\n el.addEventListener(\"input\", wrapped);\n }\n if (tree.OnBlur != NoOpEventHandler) {\n (Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnBlur(e));\n tree.AttachedOnBlur = wrapped;\n el.addEventListener(\"blur\", wrapped);\n }\n if (tree.OnChange != NoOpEventHandler) {\n (Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnChange(e));\n tree.AttachedOnChange = wrapped;\n el.addEventListener(\"change\", wrapped);\n }\n\n tree.RealNode = el;\n return el;\n}\n\n// Diffs `updated` against `old` (the previous render's tree for this exact\n// position, or null if there is none — first mount) and returns the real\n// DOM node now representing `updated`, reusing `old`'s real node in place\n// whenever the tag matches. `parent` is only used to attach/replace at the\n// top of whatever subtree Patch() is called on — child-level attach/replace\n// happens inside PatchChildren.\n//\n// Deliberately all positive-branch `if (x != null) { ... } else { ... }`,\n// never an early-return guard clause — KopScript's nullable narrowing is\n// scope-based, not reachability-based, so `if (x == null) { return; }\n// use(x);` would NOT narrow `x` afterward (see KopScript's own LLM.md \"Common\n// mistakes\"). Every nullable member-access path (`oldTree.RealNode`,\n// never narrows directly either) is read into a local first for the same\n// reason.\nElement Patch(Element parent, VElement? old, VElement updated) {\n Mountable? newMounted = updated.Mounted;\n Mountable? oldMounted = null;\n if (old != null) {\n VElement oldTreeForMount = old;\n oldMounted = oldTreeForMount.Mounted;\n }\n\n // A live child slot (VElement.Mounted — see velement.ks) is handled\n // entirely separately from the ordinary Tag-based logic below: it's not\n // Kopular's own DOM element to create/reuse at all, and its \"same node\n // or replace\" decision is instance identity (== on the Mountable itself,\n // real reference equality — interfaces erase to the underlying object),\n // not Tag equality.\n if (newMounted != null) {\n Mountable nm = newMounted;\n if (oldMounted != null) {\n Mountable om = oldMounted;\n if (om == nm) {\n // Same instance still in this slot: patch it in place, don't touch\n // the DOM position at all (its own real node, reused or not, is\n // already exactly where it needs to be).\n Element reused = nm.PatchAsChild();\n updated.RealNode = reused;\n return reused;\n }\n }\n // First time this slot has anything mounted, or a DIFFERENT instance\n // took over the slot: release whatever was here, then mount fresh.\n UnmountPrevious(parent, old, oldMounted);\n Element created = nm.MountAsChild(parent);\n parent.appendChild(created);\n updated.RealNode = created;\n return created;\n }\n\n if (oldMounted != null) {\n // Slot reverted from a live component back to plain content: release\n // the old one, then fall through to an ordinary fresh materialize.\n UnmountPrevious(parent, old, oldMounted);\n Element created = Materialize(updated, parent);\n parent.appendChild(created);\n return created;\n }\n\n if (old != null) {\n VElement oldTree = old;\n Element? maybeOldNode = oldTree.RealNode;\n if (maybeOldNode != null) {\n Element realNode = maybeOldNode;\n if (oldTree.Tag != updated.Tag) {\n Element created = Materialize(updated, parent);\n parent.replaceChild(created, realNode);\n return created;\n } else {\n updated.RealNode = realNode;\n\n if (updated.RawHtml.Length > 0 || oldTree.RawHtml.Length > 0) {\n if (updated.RawHtml != oldTree.RawHtml) {\n realNode.innerHTML = updated.RawHtml;\n }\n } else {\n if (updated.TextContent != oldTree.TextContent) {\n realNode.textContent = updated.TextContent;\n }\n PatchChildren(realNode, oldTree.Children, updated.Children);\n }\n\n if (updated.ClassName != oldTree.ClassName) {\n realNode.className = updated.ClassName;\n }\n if (updated.Id != oldTree.Id) {\n realNode.id = updated.Id;\n }\n // Always assigned, never conditionally on updated.Value != oldTree.Value\n // — unlike TextContent/ClassName/Id, an <input>/<select>'s live value\n // can diverge from the last-recorded VElement.Value purely through\n // user interaction (typing, picking an option) with no Update() ever\n // running in between (a real, deliberate pattern — see KopularDemo's\n // dogs_page.ks, which never Update()s on input/change). The recorded\n // oldTree.Value only reflects the tree as of the last actual render,\n // so comparing against it can't tell \"genuinely unchanged\" apart from\n // \"changed live in the DOM since then, framework never told\" — the\n // same reason a real \"controlled input\" (React's own term for this)\n // always writes value on every render rather than diffing it.\n realNode.value = updated.Value;\n\n // Same-length is the overwhelmingly common case (the same Render()\n // code path calls SetAttr the same number of times, in the same\n // order, on every call) — compare aligned by index and only touch\n // the real DOM for an entry that actually changed, rather than\n // reapplying every extra attribute on every patch regardless. A\n // length mismatch (the rarer case: a SetAttr call was added,\n // removed, or made conditional between renders) falls back to\n // reapplying everything, since index-aligned comparison isn't\n // meaningful once the two arrays don't correspond entry-for-entry.\n if (updated.ExtraNames.Length == oldTree.ExtraNames.Length) {\n for (number i = 0; i < updated.ExtraNames.Length; i = i + 1) {\n if (updated.ExtraNames[i] != oldTree.ExtraNames[i] || updated.ExtraValues[i] != oldTree.ExtraValues[i]) {\n realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);\n }\n }\n } else {\n for (number i = 0; i < updated.ExtraNames.Length; i = i + 1) {\n realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);\n }\n }\n\n // Swap a listener only when the handler reference actually changed.\n // A node that never sets a real OnClick/OnInput/OnBlur/OnChange\n // keeps VElement's own shared NoOpEventHandler reference on both\n // sides (see velement.ks) — comparing by `!=` costs nothing and\n // skips two real DOM API calls per event per node for the (common)\n // case of \"this node has no handler of this kind either time,\"\n // which matters a lot on a list with hundreds/thousands of rows\n // most of which set at most one or two of the four. A node WITH a\n // real handler still gets a fresh closure every render (it\n // captures per-render values, like a loop's own item), so it still\n // swaps every time — correctly, since the old closure really is\n // stale.\n //\n // The actually-attached listener is always a Batching.Run wrapper\n // (see Materialize above), never `oldTree.OnClick`/`updated.OnClick`\n // themselves — removeEventListener only works when passed the exact\n // reference addEventListener received, so removal always goes\n // through `oldTree.AttachedOnClick` (the wrapper Materialize/a\n // previous Patch actually registered), and a real swap builds a\n // fresh wrapper the same way Materialize does. When nothing swaps,\n // `updated.AttachedOnClick` still needs to carry `oldTree`'s\n // forward — `updated` is a brand-new VElement whose own\n // AttachedOnClick starts back at the shared no-op (velement.ks's\n // constructor), and it becomes the retained \"old tree\" the very\n // next Patch() call diffs against.\n if (updated.OnClick != oldTree.OnClick) {\n realNode.removeEventListener(\"click\", oldTree.AttachedOnClick);\n (Event) => void wrappedClick = (Event e) => Batching.Run(() => updated.OnClick(e));\n updated.AttachedOnClick = wrappedClick;\n realNode.addEventListener(\"click\", wrappedClick);\n } else {\n updated.AttachedOnClick = oldTree.AttachedOnClick;\n }\n if (updated.OnInput != oldTree.OnInput) {\n realNode.removeEventListener(\"input\", oldTree.AttachedOnInput);\n (Event) => void wrappedInput = (Event e) => Batching.Run(() => updated.OnInput(e));\n updated.AttachedOnInput = wrappedInput;\n realNode.addEventListener(\"input\", wrappedInput);\n } else {\n updated.AttachedOnInput = oldTree.AttachedOnInput;\n }\n if (updated.OnBlur != oldTree.OnBlur) {\n realNode.removeEventListener(\"blur\", oldTree.AttachedOnBlur);\n (Event) => void wrappedBlur = (Event e) => Batching.Run(() => updated.OnBlur(e));\n updated.AttachedOnBlur = wrappedBlur;\n realNode.addEventListener(\"blur\", wrappedBlur);\n } else {\n updated.AttachedOnBlur = oldTree.AttachedOnBlur;\n }\n if (updated.OnChange != oldTree.OnChange) {\n realNode.removeEventListener(\"change\", oldTree.AttachedOnChange);\n (Event) => void wrappedChange = (Event e) => Batching.Run(() => updated.OnChange(e));\n updated.AttachedOnChange = wrappedChange;\n realNode.addEventListener(\"change\", wrappedChange);\n } else {\n updated.AttachedOnChange = oldTree.AttachedOnChange;\n }\n\n return realNode;\n }\n } else {\n // Shouldn't happen in practice (every previously-rendered tree has a\n // real node by the time a second render diffs against it) — treated\n // as \"nothing to reuse\" rather than a crash, same defensive spirit\n // as Component's own IsMounted guard.\n Element created = Materialize(updated, parent);\n parent.appendChild(created);\n return created;\n }\n } else {\n Element created = Materialize(updated, parent);\n parent.appendChild(created);\n return created;\n }\n}\n\n// Releases whatever was previously mounted in a slot (calling its\n// Teardown, which — for a Component — cascades into anything IT mounted\n// too, however many levels deep, before its own app-facing OnUnmount runs)\n// and removes its real node from the DOM, before a different Mountable (or\n// plain content) takes over that slot. A no-op when there was nothing\n// mounted here before — the common \"first render of this slot\" case.\nvoid UnmountPrevious(Element parent, VElement? old, Mountable? oldMounted) {\n if (oldMounted != null) {\n Mountable m = oldMounted;\n m.Teardown();\n if (old != null) {\n VElement oldTree = old;\n Element? maybeOldNode = oldTree.RealNode;\n if (maybeOldNode != null) {\n Element oldNode = maybeOldNode;\n parent.removeChild(oldNode);\n }\n }\n }\n}\n\n// Keyed reconciliation: each VElement's own Id is its key when non-empty —\n// a real, existing DOM convention, needing no new API or syntax. A new\n// child whose Id matches an old child's Id is patched against that old\n// child (reusing its real node) regardless of position; a new child with\n// no Id, or an Id not present among the old children, falls back to\n// pairing positionally against whatever old children are still unconsumed,\n// in order. DOCUMENTED, REAL LIMITATION: without stable Ids, a reordered\n// list still produces the correct final output, but a given item's real\n// DOM node (and anything stateful attached to it, like focus) isn't\n// guaranteed to follow its data across the reorder — give list items a\n// stable Id for that guarantee.\nvoid PatchChildren(Element parent, VElement[] oldChildren, VElement[] newChildren) {\n // Map, not a Push loop — Push is deliberately non-mutating (a real\n // spread-copy every call, see KopScript's own README), so building an\n // n-length array by Push-ing once per element in a loop is an\n // accidental O(n^2) on every single PatchChildren call, however small\n // the actual diff. Map is a real, single O(n) pass straight to\n // Array.prototype.map.\n bool[] oldConsumed = oldChildren.Map((VElement c) => false);\n number[] matchedOldIndex = newChildren.Map((VElement c) => -1);\n\n // Pass 1: keyed matches, by Id.\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n if (newChildren[i].Id.Length == 0) { continue; }\n for (number j = 0; j < oldChildren.Length; j = j + 1) {\n if (!oldConsumed[j] && oldChildren[j].Id == newChildren[i].Id) {\n matchedOldIndex[i] = j;\n oldConsumed[j] = true;\n break;\n }\n }\n }\n\n // Pass 2: positional fallback for everything Pass 1 didn't match —\n // pair each remaining new child against the next still-unconsumed old\n // child, in order.\n number nextOld = 0;\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n if (matchedOldIndex[i] >= 0) { continue; }\n while (nextOld < oldChildren.Length && oldConsumed[nextOld]) {\n nextOld = nextOld + 1;\n }\n if (nextOld < oldChildren.Length) {\n matchedOldIndex[i] = nextOld;\n oldConsumed[nextOld] = true;\n nextOld = nextOld + 1;\n }\n }\n\n // Whether anything is actually moving at all — same length, and every\n // new position matched the *same* old position. The extremely common\n // case for a list that's only had some of its rows' own content change\n // (e.g. \"update every 10th row\"), where reconciliation still has real\n // work to do (see Pass 1/2 above and Patch() itself) but nothing needs\n // to physically move in the DOM at all.\n bool needsReorder = oldChildren.Length != newChildren.Length;\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n if (matchedOldIndex[i] != i) {\n needsReorder = true;\n break;\n }\n }\n\n // Pass 3: patch/create each new child in order, then — only if the list\n // actually needs reordering — move it into its correct final position.\n // appendChild on a node already attached elsewhere in the DOM MOVES it\n // (real DOM semantics), so processing new children in their final\n // desired order and always appending naturally builds up the correct\n // sequence, no separate insertBefore/reference-node bookkeeping needed.\n // Safe because VElement.Children is always the COMPLETE list of a\n // node's children — nothing else ever shares `parent`. Skipping the\n // move entirely when `needsReorder` is false avoids a real DOM API call\n // per child for the common no-reorder case — Patch() itself already\n // updates or replaces a reused/changed node exactly in place either way.\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n VElement? matchedOld = null;\n if (matchedOldIndex[i] >= 0) {\n matchedOld = oldChildren[matchedOldIndex[i]];\n }\n Element childNode = Patch(parent, matchedOld, newChildren[i]);\n if (needsReorder) {\n parent.appendChild(childNode);\n }\n }\n\n // Pass 4: remove whatever old children never got reused — releasing\n // anything Mounted first (see velement.ks) so a child genuinely dropped\n // from a list (not replaced in the same slot by a different one, which\n // Patch() itself already handles via UnmountPrevious — removed outright)\n // still gets torn down. This is the one place THAT case is reachable\n // from, since Patch() only ever sees one slot at a time, never a slot\n // disappearing entirely.\n for (number j = 0; j < oldChildren.Length; j = j + 1) {\n if (oldConsumed[j]) { continue; }\n Mountable? maybeOldMounted = oldChildren[j].Mounted;\n if (maybeOldMounted != null) {\n Mountable m = maybeOldMounted;\n m.Teardown();\n }\n Element? maybeOldNode = oldChildren[j].RealNode;\n if (maybeOldNode != null) {\n Element oldNode = maybeOldNode;\n parent.removeChild(oldNode);\n }\n }\n}\n\n// Recursively finds and tears down every Mounted node within `tree` (tree\n// itself included) — used by Component's own Teardown (see component.ks)\n// so removing ONE component transitively releases everything IT rendered,\n// however many Mounted levels deep, not only the outermost one. A pure\n// data walk over the retained VElement tree; never touches the real DOM\n// itself (the caller already owns removing whatever real node is actually\n// attached).\nvoid UnmountTree(VElement tree) {\n Mountable? maybeMounted = tree.Mounted;\n if (maybeMounted != null) {\n Mountable m = maybeMounted;\n m.Teardown();\n }\n foreach (VElement child in tree.Children) {\n UnmountTree(child);\n }\n}\n"],"names":[],"mappings":";;;AAyCA;EACiB;;EACA;;EAED;IACZ;;;EAGY;IACZ;MACmB;;;;EAUP;IACG;IACf;MACQ;;MAES;MACf;QACE;QACiB;QACjB;UACe;;;;;;AAkBvB;EACE;EACA;IACE;IACA;IACc;IACd;;EAGF;EAEA;IACe;;IAEb;MACgB;;;IAGD;;EAGJ;EACP;EACG;EAET;IACiB;;EAYjB;IACE;IACqB;IACF;;EAErB;IACE;IACqB;IACF;;EAErB;IACE;IACoB;IACD;;EAErB;IACE;IACsB;IACH;;EAGP;EACd;;AAiBF;EACE;EACA;EACA;IACE;IACW;;EASb;IACE;IACA;MACE;MACA;QAIE;QACiB;QACjB;;;IAKW;IACf;IACkB;IACD;IACjB;;EAGF;IAGiB;IACf;IACkB;IAClB;;EAGF;IACE;IACA;IACA;MACE;MACA;QACE;QACmB;QACnB;;QAEiB;QAEjB;UACE;YACqB;;;UAGrB;YACuB;;UAEV;;QAGf;UACqB;;QAErB;UACc;;QAaC;QAWf;UACE;YACE;cACuB;;;;UAIzB;YACuB;;;QA6BzB;UAC8B;UAC5B;UACwB;UACC;;UAED;;QAE1B;UAC8B;UAC5B;UACwB;UACC;;UAED;;QAE1B;UAC8B;UAC5B;UACuB;UACE;;UAEF;;QAEzB;UAC8B;UAC5B;UACyB;UACA;;UAEA;;QAG3B;;;MAOF;MACkB;MAClB;;;IAGF;IACkB;IAClB;;;AAUJ;EACE;IACE;IACU;IACV;MACE;MACA;MACA;QACE;QACkB;;;;;AAiB1B;EAOE;EACA;EAGA;IACE;MAAqC;;IACrC;MACE;QACqB;QACJ;QACf;;;;EAQN;EACA;IACE;MAA+B;;IAC/B;MACU;;IAEV;MACqB;MACE;MACb;;;EAUZ;EACA;IACE;MACe;MACb;;;EAeJ;IACE;IACA;MACa;;IAEb;IACA;MACoB;;;EAWtB;IACE;MAAsB;;IACtB;IACA;MACE;MACU;;IAEZ;IACA;MACE;MACkB;;;;AAYxB;EACE;EACA;IACE;IACU;;EAEZ;IACa"}
package/src/vdom.ks CHANGED
@@ -81,15 +81,28 @@ class Batching {
81
81
  // be replaced outright (no previous node to reuse, or the tag changed).
82
82
  // Mutates `tree.RealNode` (and recursively every descendant's) as a side
83
83
  // effect, so the tree this was called on becomes the new "previous tree"
84
- // the next Patch() call diffs against.
85
- Element Materialize(VElement tree) {
84
+ // the next Patch() call diffs against. Takes `parent` — every call site
85
+ // already knows it (either Patch's own `parent` parameter, or the real
86
+ // element a recursive child call is about to be appendChild'd into) —
87
+ // solely to hand to a Mounted slot's own MountAsChild, which needs to
88
+ // remember its real parent for THAT component's own future self-triggered
89
+ // re-renders; an ordinary (non-Mounted) VElement never uses it.
90
+ Element Materialize(VElement tree, Element parent) {
91
+ Mountable? maybeMounted = tree.Mounted;
92
+ if (maybeMounted != null) {
93
+ Mountable m = maybeMounted;
94
+ Element mountedRoot = m.MountAsChild(parent);
95
+ tree.RealNode = mountedRoot;
96
+ return mountedRoot;
97
+ }
98
+
86
99
  Element el = document.createElement(tree.Tag);
87
100
 
88
101
  if (tree.RawHtml.Length > 0) {
89
102
  el.innerHTML = tree.RawHtml;
90
103
  } else if (tree.Children.Length > 0) {
91
104
  foreach (VElement child in tree.Children) {
92
- el.appendChild(Materialize(child));
105
+ el.appendChild(Materialize(child, el));
93
106
  }
94
107
  } else {
95
108
  el.textContent = tree.TextContent;
@@ -152,13 +165,57 @@ Element Materialize(VElement tree) {
152
165
  // never narrows directly either) is read into a local first for the same
153
166
  // reason.
154
167
  Element Patch(Element parent, VElement? old, VElement updated) {
168
+ Mountable? newMounted = updated.Mounted;
169
+ Mountable? oldMounted = null;
170
+ if (old != null) {
171
+ VElement oldTreeForMount = old;
172
+ oldMounted = oldTreeForMount.Mounted;
173
+ }
174
+
175
+ // A live child slot (VElement.Mounted — see velement.ks) is handled
176
+ // entirely separately from the ordinary Tag-based logic below: it's not
177
+ // Kopular's own DOM element to create/reuse at all, and its "same node
178
+ // or replace" decision is instance identity (== on the Mountable itself,
179
+ // real reference equality — interfaces erase to the underlying object),
180
+ // not Tag equality.
181
+ if (newMounted != null) {
182
+ Mountable nm = newMounted;
183
+ if (oldMounted != null) {
184
+ Mountable om = oldMounted;
185
+ if (om == nm) {
186
+ // Same instance still in this slot: patch it in place, don't touch
187
+ // the DOM position at all (its own real node, reused or not, is
188
+ // already exactly where it needs to be).
189
+ Element reused = nm.PatchAsChild();
190
+ updated.RealNode = reused;
191
+ return reused;
192
+ }
193
+ }
194
+ // First time this slot has anything mounted, or a DIFFERENT instance
195
+ // took over the slot: release whatever was here, then mount fresh.
196
+ UnmountPrevious(parent, old, oldMounted);
197
+ Element created = nm.MountAsChild(parent);
198
+ parent.appendChild(created);
199
+ updated.RealNode = created;
200
+ return created;
201
+ }
202
+
203
+ if (oldMounted != null) {
204
+ // Slot reverted from a live component back to plain content: release
205
+ // the old one, then fall through to an ordinary fresh materialize.
206
+ UnmountPrevious(parent, old, oldMounted);
207
+ Element created = Materialize(updated, parent);
208
+ parent.appendChild(created);
209
+ return created;
210
+ }
211
+
155
212
  if (old != null) {
156
213
  VElement oldTree = old;
157
214
  Element? maybeOldNode = oldTree.RealNode;
158
215
  if (maybeOldNode != null) {
159
216
  Element realNode = maybeOldNode;
160
217
  if (oldTree.Tag != updated.Tag) {
161
- Element created = Materialize(updated);
218
+ Element created = Materialize(updated, parent);
162
219
  parent.replaceChild(created, realNode);
163
220
  return created;
164
221
  } else {
@@ -280,17 +337,38 @@ Element Patch(Element parent, VElement? old, VElement updated) {
280
337
  // real node by the time a second render diffs against it) — treated
281
338
  // as "nothing to reuse" rather than a crash, same defensive spirit
282
339
  // as Component's own IsMounted guard.
283
- Element created = Materialize(updated);
340
+ Element created = Materialize(updated, parent);
284
341
  parent.appendChild(created);
285
342
  return created;
286
343
  }
287
344
  } else {
288
- Element created = Materialize(updated);
345
+ Element created = Materialize(updated, parent);
289
346
  parent.appendChild(created);
290
347
  return created;
291
348
  }
292
349
  }
293
350
 
351
+ // Releases whatever was previously mounted in a slot (calling its
352
+ // Teardown, which — for a Component — cascades into anything IT mounted
353
+ // too, however many levels deep, before its own app-facing OnUnmount runs)
354
+ // and removes its real node from the DOM, before a different Mountable (or
355
+ // plain content) takes over that slot. A no-op when there was nothing
356
+ // mounted here before — the common "first render of this slot" case.
357
+ void UnmountPrevious(Element parent, VElement? old, Mountable? oldMounted) {
358
+ if (oldMounted != null) {
359
+ Mountable m = oldMounted;
360
+ m.Teardown();
361
+ if (old != null) {
362
+ VElement oldTree = old;
363
+ Element? maybeOldNode = oldTree.RealNode;
364
+ if (maybeOldNode != null) {
365
+ Element oldNode = maybeOldNode;
366
+ parent.removeChild(oldNode);
367
+ }
368
+ }
369
+ }
370
+ }
371
+
294
372
  // Keyed reconciliation: each VElement's own Id is its key when non-empty —
295
373
  // a real, existing DOM convention, needing no new API or syntax. A new
296
374
  // child whose Id matches an old child's Id is patched against that old
@@ -376,9 +454,20 @@ void PatchChildren(Element parent, VElement[] oldChildren, VElement[] newChildre
376
454
  }
377
455
  }
378
456
 
379
- // Pass 4: remove whatever old children never got reused.
457
+ // Pass 4: remove whatever old children never got reused — releasing
458
+ // anything Mounted first (see velement.ks) so a child genuinely dropped
459
+ // from a list (not replaced in the same slot by a different one, which
460
+ // Patch() itself already handles via UnmountPrevious — removed outright)
461
+ // still gets torn down. This is the one place THAT case is reachable
462
+ // from, since Patch() only ever sees one slot at a time, never a slot
463
+ // disappearing entirely.
380
464
  for (number j = 0; j < oldChildren.Length; j = j + 1) {
381
465
  if (oldConsumed[j]) { continue; }
466
+ Mountable? maybeOldMounted = oldChildren[j].Mounted;
467
+ if (maybeOldMounted != null) {
468
+ Mountable m = maybeOldMounted;
469
+ m.Teardown();
470
+ }
382
471
  Element? maybeOldNode = oldChildren[j].RealNode;
383
472
  if (maybeOldNode != null) {
384
473
  Element oldNode = maybeOldNode;
@@ -386,3 +475,21 @@ void PatchChildren(Element parent, VElement[] oldChildren, VElement[] newChildre
386
475
  }
387
476
  }
388
477
  }
478
+
479
+ // Recursively finds and tears down every Mounted node within `tree` (tree
480
+ // itself included) — used by Component's own Teardown (see component.ks)
481
+ // so removing ONE component transitively releases everything IT rendered,
482
+ // however many Mounted levels deep, not only the outermost one. A pure
483
+ // data walk over the retained VElement tree; never touches the real DOM
484
+ // itself (the caller already owns removing whatever real node is actually
485
+ // attached).
486
+ void UnmountTree(VElement tree) {
487
+ Mountable? maybeMounted = tree.Mounted;
488
+ if (maybeMounted != null) {
489
+ Mountable m = maybeMounted;
490
+ m.Teardown();
491
+ }
492
+ foreach (VElement child in tree.Children) {
493
+ UnmountTree(child);
494
+ }
495
+ }
package/src/velement.js CHANGED
@@ -22,12 +22,19 @@ export class VElement {
22
22
  this.ExtraNames = [];
23
23
  this.ExtraValues = [];
24
24
  this.RealNode = null;
25
+ this.Mounted = null;
25
26
  }
26
27
 
27
28
  static Create(tag) {
28
29
  return new VElement(tag);
29
30
  }
30
31
 
32
+ static Mount(component) {
33
+ let ve = new VElement("");
34
+ ve.Mounted = component;
35
+ return ve;
36
+ }
37
+
31
38
  AppendChild(child) {
32
39
  this.Children = [...this.Children, child];
33
40
  }
@@ -1 +1 @@
1
- {"version":3,"file":"velement.js","sources":["velement.ks"],"sourcesContent":["using \"./dom\";\n\n// A lightweight, framework-owned description of one DOM element — Render()\n// builds a tree of these instead of real DOM nodes, so Update() can DIFF\n// the new tree against the previous one and patch only what changed,\n// instead of discarding and rebuilding the whole real DOM subtree every\n// time (see src/vdom.ks for the diff/patch engine, src/component.ks for\n// where Render()'s return type changed from Element to VElement).\n//\n// A single shared no-op, not a fresh closure per VElement — every instance\n// that never sets a real handler gets this exact same function reference,\n// so Patch() (vdom.ks) can tell \"no handler either time\" apart from \"a\n// handler changed\" with a plain `!=` reference check instead of always\n// removing/re-adding all four DOM listeners on every patch regardless of\n// whether anything about them actually changed. A fresh `(Event e) => {}`\n// closure per instance would defeat this — two \"empty\" handlers would\n// never compare equal, even when nothing meaningful differs.\nvoid NoOpEventHandler(Event e) {\n}\n\n// Fixed, named fields — not a generic prop bag — because KopScript has no\n// object-literal syntax to build one with. Fixed, named event slots — not\n// an array of handlers — because KopScript has no array-of-function-values\n// type either. Both are real language constraints, not an oversight; see\n// SetAttr below for the escape hatch covering everything not common enough\n// to deserve its own named field.\nclass VElement {\n public string Tag;\n // Mutually exclusive with Children and RawHtml — set at most one of the\n // three. TextContent/Children mirrors the same \"no mixed text/element\n // content\" rule KopScript's own templates already enforce.\n public string TextContent;\n public string ClassName;\n public string Id;\n // The one property patched via direct assignment, never setAttribute —\n // see Element.value's own comment in dom.ks for why (the \"default value\n // attribute\" vs \"current live value property\" DOM footgun — the exact\n // property behind the original typing bug this whole effort traces to).\n public string Value;\n public VElement[] Children;\n // An opaque, undiffed leaf — set instead of TextContent/Children for the\n // existing raw-HTML-then-wire-handlers pattern (header.ks/nav.ks). The\n // patch engine treats two VElements with different RawHtml as a single\n // innerHTML assignment, never recursing inside it — the same \"opaque\n // blob\" treatment `raw string` already gets everywhere else.\n public string RawHtml;\n\n // Each defaults to a real no-op, never null — KopScript has no nullable\n // *function* type to fall back on for \"no handler set\" (see Router.Guard\n // for the same default-real-function pattern already established).\n public (Event) => void OnClick;\n public (Event) => void OnInput;\n public (Event) => void OnBlur;\n public (Event) => void OnChange;\n\n // The REAL function reference the patch engine (vdom.ks) actually passed\n // to addEventListener for this exact real DOM node — never OnClick/\n // OnInput/OnBlur/OnChange themselves. Materialize/Patch wrap each handler\n // in Component.RunInBatch (see component.ks) before attaching it, so the\n // listener genuinely registered isn't the same function value as the one\n // an app author wrote; removeEventListener only ever works when passed\n // the exact reference addEventListener received, so the patch engine\n // needs somewhere to remember it for the swap-when-changed path. Plain\n // data, same as every other field here — only vdom.ks ever reads or\n // writes these.\n public (Event) => void AttachedOnClick;\n public (Event) => void AttachedOnInput;\n public (Event) => void AttachedOnBlur;\n public (Event) => void AttachedOnChange;\n\n // A real HTML attribute not common enough for its own named field (href,\n // src, alt, placeholder, ...) — parallel arrays, since KopScript has no\n // Dictionary type. Never Value (see its own field comment above). Public,\n // read directly by the patch engine (src/vdom.ks) rather than through\n // accessor methods — plain data, same style as this codebase's other\n // plain classes (Note, Dog, ...).\n public string[] ExtraNames;\n public string[] ExtraValues;\n\n // Set only once this VElement has been materialized into (or reused as)\n // a real DOM node — null on a freshly-built tree from a not-yet-patched\n // Render() call. The patch engine reads the *previous* render's tree's\n // RealNode to know what to reuse/patch; it never reads the live DOM back\n // to rediscover this (see vdom.ks's own header comment for why).\n public Element? RealNode;\n\n constructor(string tag) {\n this.Tag = tag;\n this.TextContent = \"\";\n this.ClassName = \"\";\n this.Id = \"\";\n this.Value = \"\";\n this.Children = [];\n this.RawHtml = \"\";\n this.OnClick = NoOpEventHandler;\n this.OnInput = NoOpEventHandler;\n this.OnBlur = NoOpEventHandler;\n this.OnChange = NoOpEventHandler;\n this.AttachedOnClick = NoOpEventHandler;\n this.AttachedOnInput = NoOpEventHandler;\n this.AttachedOnBlur = NoOpEventHandler;\n this.AttachedOnChange = NoOpEventHandler;\n this.ExtraNames = [];\n this.ExtraValues = [];\n this.RealNode = null;\n }\n\n public static VElement Create(string tag) {\n return new VElement(tag);\n }\n\n public void AppendChild(VElement child) {\n this.Children = this.Children.Push(child);\n }\n\n // Last call for a given name wins if SetAttr is called more than once\n // with the same name on one VElement — the patch engine applies\n // ExtraNames/ExtraValues in order, so a later entry's setAttribute call\n // simply overwrites an earlier one for the same name, no special\n // dedup/replace logic needed here.\n public void SetAttr(string name, string value) {\n this.ExtraNames = this.ExtraNames.Push(name);\n this.ExtraValues = this.ExtraValues.Push(value);\n }\n}\n"],"names":[],"mappings":";;AAiBA;;AASA;EA4DE;IACW;IACQ;IACF;IACP;IACG;IACG;IACD;IACA;IACA;IACD;IACE;IACO;IACA;IACD;IACE;IACN;IACC;IACH;;;EAGF;IACZ;;;EAGK;IACS;;;EAQT;IACW;IACC"}
1
+ {"version":3,"file":"velement.js","sources":["velement.ks"],"sourcesContent":["using \"./dom\";\n\n// A lightweight, framework-owned description of one DOM element — Render()\n// builds a tree of these instead of real DOM nodes, so Update() can DIFF\n// the new tree against the previous one and patch only what changed,\n// instead of discarding and rebuilding the whole real DOM subtree every\n// time (see src/vdom.ks for the diff/patch engine, src/component.ks for\n// where Render()'s return type changed from Element to VElement).\n//\n// A single shared no-op, not a fresh closure per VElement — every instance\n// that never sets a real handler gets this exact same function reference,\n// so Patch() (vdom.ks) can tell \"no handler either time\" apart from \"a\n// handler changed\" with a plain `!=` reference check instead of always\n// removing/re-adding all four DOM listeners on every patch regardless of\n// whether anything about them actually changed. A fresh `(Event e) => {}`\n// closure per instance would defeat this — two \"empty\" handlers would\n// never compare equal, even when nothing meaningful differs.\nvoid NoOpEventHandler(Event e) {\n}\n\n// A live child Component (or anything else that wants to be embedded as a\n// slot in another's VElement tree) — see VElement.Mounted below. Declared\n// HERE, not in vdom.ks, even though vdom.ks's Materialize/Patch are the\n// only real callers: VElement's own Mounted field needs to reference this\n// type, and vdom.ks already `using`s this file for VElement itself —\n// declaring it in vdom.ks would make velement.ks need to `using \"./vdom\"`\n// right back, a circular `using` KopScript rejects outright (the same\n// constraint that put the unrelated Flushable interface in vdom.ks\n// instead of component.ks — there, Component was the one doing the\n// referencing; here, VElement is). Component (component.ks) is the one\n// real implementer, via `class Component : Flushable, Mountable`.\ninterface Mountable {\n // Called by the vdom engine only (public purely for interface\n // conformance, same convention as Flushable.FlushUpdate — not the\n // intended way for app code to trigger anything). Builds this\n // component's own tree for the first time and returns its real root\n // node WITHOUT inserting it anywhere; the caller (Materialize/Patch)\n // does that itself, the same as every other VElement content mode.\n Element MountAsChild(Element parent);\n\n // Re-renders this already-mounted child in place; returns the (possibly\n // identical) real root node. Called when a slot's Mounted reference is\n // the SAME instance as last render.\n Element PatchAsChild();\n\n // Called once when this slot's Mounted reference disappears or is\n // replaced by a different instance across a re-render, before its real\n // node is removed — the one hook the vdom engine itself calls; see\n // Component's own OnUnmount for the real, overridable app-facing\n // extension point this delegates to.\n void Teardown();\n}\n\n// Fixed, named fields — not a generic prop bag — because KopScript has no\n// object-literal syntax to build one with. Fixed, named event slots — not\n// an array of handlers — because KopScript has no array-of-function-values\n// type either. Both are real language constraints, not an oversight; see\n// SetAttr below for the escape hatch covering everything not common enough\n// to deserve its own named field.\nclass VElement {\n public string Tag;\n // Mutually exclusive with Children and RawHtml — set at most one of the\n // three. TextContent/Children mirrors the same \"no mixed text/element\n // content\" rule KopScript's own templates already enforce.\n public string TextContent;\n public string ClassName;\n public string Id;\n // The one property patched via direct assignment, never setAttribute —\n // see Element.value's own comment in dom.ks for why (the \"default value\n // attribute\" vs \"current live value property\" DOM footgun — the exact\n // property behind the original typing bug this whole effort traces to).\n public string Value;\n public VElement[] Children;\n // An opaque, undiffed leaf — set instead of TextContent/Children for the\n // existing raw-HTML-then-wire-handlers pattern (header.ks/nav.ks). The\n // patch engine treats two VElements with different RawHtml as a single\n // innerHTML assignment, never recursing inside it — the same \"opaque\n // blob\" treatment `raw string` already gets everywhere else.\n public string RawHtml;\n\n // Each defaults to a real no-op, never null — KopScript has no nullable\n // *function* type to fall back on for \"no handler set\" (see Router.Guard\n // for the same default-real-function pattern already established).\n public (Event) => void OnClick;\n public (Event) => void OnInput;\n public (Event) => void OnBlur;\n public (Event) => void OnChange;\n\n // The REAL function reference the patch engine (vdom.ks) actually passed\n // to addEventListener for this exact real DOM node — never OnClick/\n // OnInput/OnBlur/OnChange themselves. Materialize/Patch wrap each handler\n // in Component.RunInBatch (see component.ks) before attaching it, so the\n // listener genuinely registered isn't the same function value as the one\n // an app author wrote; removeEventListener only ever works when passed\n // the exact reference addEventListener received, so the patch engine\n // needs somewhere to remember it for the swap-when-changed path. Plain\n // data, same as every other field here — only vdom.ks ever reads or\n // writes these.\n public (Event) => void AttachedOnClick;\n public (Event) => void AttachedOnInput;\n public (Event) => void AttachedOnBlur;\n public (Event) => void AttachedOnChange;\n\n // A real HTML attribute not common enough for its own named field (href,\n // src, alt, placeholder, ...) — parallel arrays, since KopScript has no\n // Dictionary type. Never Value (see its own field comment above). Public,\n // read directly by the patch engine (src/vdom.ks) rather than through\n // accessor methods — plain data, same style as this codebase's other\n // plain classes (Note, Dog, ...).\n public string[] ExtraNames;\n public string[] ExtraValues;\n\n // Set only once this VElement has been materialized into (or reused as)\n // a real DOM node — null on a freshly-built tree from a not-yet-patched\n // Render() call. The patch engine reads the *previous* render's tree's\n // RealNode to know what to reuse/patch; it never reads the live DOM back\n // to rediscover this (see vdom.ks's own header comment for why).\n public Element? RealNode;\n\n // Set to embed a live, mounted Component (or any other Mountable) as\n // this VElement's entire content — mutually exclusive with\n // Tag/TextContent/Children/RawHtml, and stronger than RawHtml's own\n // \"opaque leaf\" treatment: Tag is unused, since no wrapping element of\n // Kopular's own is created for this slot at all — the child's own\n // rendered root IS this slot's real node (see vdom.ks's Materialize/\n // Patch). null on every ordinary VElement, the only value every\n // VElement had before this existed, so nothing about an existing\n // content mode changes unless a tree opts into this one. Use\n // VElement.Mount(component) below rather than setting this directly.\n public Mountable? Mounted;\n\n constructor(string tag) {\n this.Tag = tag;\n this.TextContent = \"\";\n this.ClassName = \"\";\n this.Id = \"\";\n this.Value = \"\";\n this.Children = [];\n this.RawHtml = \"\";\n this.OnClick = NoOpEventHandler;\n this.OnInput = NoOpEventHandler;\n this.OnBlur = NoOpEventHandler;\n this.OnChange = NoOpEventHandler;\n this.AttachedOnClick = NoOpEventHandler;\n this.AttachedOnInput = NoOpEventHandler;\n this.AttachedOnBlur = NoOpEventHandler;\n this.AttachedOnChange = NoOpEventHandler;\n this.ExtraNames = [];\n this.ExtraValues = [];\n this.RealNode = null;\n this.Mounted = null;\n }\n\n public static VElement Create(string tag) {\n return new VElement(tag);\n }\n\n // Wraps a live child Component (or anything else implementing Mountable)\n // as a VElement slot the diff engine can create/patch/move/destroy\n // declaratively. Set .Id on the result afterward for a list of these to\n // reorder correctly, the same as any other keyed child — PatchChildren\n // (vdom.ks) needs no changes to support this; it already keys by Id\n // regardless of what a VElement's content actually is.\n public static VElement Mount(Mountable component) {\n VElement ve = new VElement(\"\");\n ve.Mounted = component;\n return ve;\n }\n\n public void AppendChild(VElement child) {\n this.Children = this.Children.Push(child);\n }\n\n // Last call for a given name wins if SetAttr is called more than once\n // with the same name on one VElement — the patch engine applies\n // ExtraNames/ExtraValues in order, so a later entry's setAttribute call\n // simply overwrites an earlier one for the same name, no special\n // dedup/replace logic needed here.\n public void SetAttr(string name, string value) {\n this.ExtraNames = this.ExtraNames.Push(name);\n this.ExtraValues = this.ExtraValues.Push(value);\n }\n}\n"],"names":[],"mappings":";;AAiBA;;AA0CA;EAwEE;IACW;IACQ;IACF;IACP;IACG;IACG;IACD;IACA;IACA;IACD;IACE;IACO;IACA;IACD;IACE;IACN;IACC;IACH;IACD;;;EAGD;IACZ;;;EASY;IACZ;IACW;IACX;;;EAGK;IACS;;;EAQT;IACW;IACC"}
package/src/velement.ks CHANGED
@@ -18,6 +18,39 @@ using "./dom";
18
18
  void NoOpEventHandler(Event e) {
19
19
  }
20
20
 
21
+ // A live child Component (or anything else that wants to be embedded as a
22
+ // slot in another's VElement tree) — see VElement.Mounted below. Declared
23
+ // HERE, not in vdom.ks, even though vdom.ks's Materialize/Patch are the
24
+ // only real callers: VElement's own Mounted field needs to reference this
25
+ // type, and vdom.ks already `using`s this file for VElement itself —
26
+ // declaring it in vdom.ks would make velement.ks need to `using "./vdom"`
27
+ // right back, a circular `using` KopScript rejects outright (the same
28
+ // constraint that put the unrelated Flushable interface in vdom.ks
29
+ // instead of component.ks — there, Component was the one doing the
30
+ // referencing; here, VElement is). Component (component.ks) is the one
31
+ // real implementer, via `class Component : Flushable, Mountable`.
32
+ interface Mountable {
33
+ // Called by the vdom engine only (public purely for interface
34
+ // conformance, same convention as Flushable.FlushUpdate — not the
35
+ // intended way for app code to trigger anything). Builds this
36
+ // component's own tree for the first time and returns its real root
37
+ // node WITHOUT inserting it anywhere; the caller (Materialize/Patch)
38
+ // does that itself, the same as every other VElement content mode.
39
+ Element MountAsChild(Element parent);
40
+
41
+ // Re-renders this already-mounted child in place; returns the (possibly
42
+ // identical) real root node. Called when a slot's Mounted reference is
43
+ // the SAME instance as last render.
44
+ Element PatchAsChild();
45
+
46
+ // Called once when this slot's Mounted reference disappears or is
47
+ // replaced by a different instance across a re-render, before its real
48
+ // node is removed — the one hook the vdom engine itself calls; see
49
+ // Component's own OnUnmount for the real, overridable app-facing
50
+ // extension point this delegates to.
51
+ void Teardown();
52
+ }
53
+
21
54
  // Fixed, named fields — not a generic prop bag — because KopScript has no
22
55
  // object-literal syntax to build one with. Fixed, named event slots — not
23
56
  // an array of handlers — because KopScript has no array-of-function-values
@@ -84,6 +117,18 @@ class VElement {
84
117
  // to rediscover this (see vdom.ks's own header comment for why).
85
118
  public Element? RealNode;
86
119
 
120
+ // Set to embed a live, mounted Component (or any other Mountable) as
121
+ // this VElement's entire content — mutually exclusive with
122
+ // Tag/TextContent/Children/RawHtml, and stronger than RawHtml's own
123
+ // "opaque leaf" treatment: Tag is unused, since no wrapping element of
124
+ // Kopular's own is created for this slot at all — the child's own
125
+ // rendered root IS this slot's real node (see vdom.ks's Materialize/
126
+ // Patch). null on every ordinary VElement, the only value every
127
+ // VElement had before this existed, so nothing about an existing
128
+ // content mode changes unless a tree opts into this one. Use
129
+ // VElement.Mount(component) below rather than setting this directly.
130
+ public Mountable? Mounted;
131
+
87
132
  constructor(string tag) {
88
133
  this.Tag = tag;
89
134
  this.TextContent = "";
@@ -103,12 +148,25 @@ class VElement {
103
148
  this.ExtraNames = [];
104
149
  this.ExtraValues = [];
105
150
  this.RealNode = null;
151
+ this.Mounted = null;
106
152
  }
107
153
 
108
154
  public static VElement Create(string tag) {
109
155
  return new VElement(tag);
110
156
  }
111
157
 
158
+ // Wraps a live child Component (or anything else implementing Mountable)
159
+ // as a VElement slot the diff engine can create/patch/move/destroy
160
+ // declaratively. Set .Id on the result afterward for a list of these to
161
+ // reorder correctly, the same as any other keyed child — PatchChildren
162
+ // (vdom.ks) needs no changes to support this; it already keys by Id
163
+ // regardless of what a VElement's content actually is.
164
+ public static VElement Mount(Mountable component) {
165
+ VElement ve = new VElement("");
166
+ ve.Mounted = component;
167
+ return ve;
168
+ }
169
+
112
170
  public void AppendChild(VElement child) {
113
171
  this.Children = this.Children.Push(child);
114
172
  }