kopular 0.16.0 → 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 +47 -22
- package/README.md +98 -14
- package/package.json +2 -2
- package/src/component.js +29 -3
- package/src/component.js.map +1 -1
- package/src/component.ks +72 -19
- package/src/forms.js +1 -0
- package/src/forms.js.map +1 -1
- package/src/router.js.map +1 -1
- package/src/router.ks +11 -10
- package/src/vdom.js +69 -5
- package/src/vdom.js.map +1 -1
- package/src/vdom.ks +114 -7
- package/src/velement.js +7 -0
- package/src/velement.js.map +1 -1
- package/src/velement.ks +58 -0
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 —
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
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
|
-
- **
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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()`
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/src/component.js.map
CHANGED
|
@@ -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//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
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
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// reconciliation
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
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":"
|
|
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
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/velement.js.map
CHANGED
|
@@ -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;;
|
|
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
|
}
|