kopular 0.14.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LLM.md +79 -11
- package/README.md +19 -7
- package/package.json +1 -1
- package/src/component.js +1 -1
- package/src/directives.js +1 -1
- package/src/router.js +1 -1
- package/src/vdom.js +51 -24
- package/src/vdom.js.map +1 -1
- package/src/vdom.ks +91 -35
- package/src/velement.js +6 -8
- package/src/velement.js.map +1 -1
- package/src/velement.ks +15 -4
package/LLM.md
CHANGED
|
@@ -22,7 +22,67 @@ there is no `kopular/template` entry point to import.
|
|
|
22
22
|
## Consuming Kopular from your own KopScript project
|
|
23
23
|
|
|
24
24
|
`using` only resolves same-project relative paths — reaching into an npm package (Kopular
|
|
25
|
-
included) always goes through `extern`, re-describing exactly the members you use
|
|
25
|
+
included) always goes through `extern`, re-describing exactly the members you use.
|
|
26
|
+
|
|
27
|
+
**`Element`/`Document`/`Event` are NOT part of Kopular's own exports** — they're plain
|
|
28
|
+
ambient browser globals (`extern class Element { ... };`, no `from` clause), genuinely
|
|
29
|
+
present at runtime with no import needed, but Kopular has no re-exportable copy of them
|
|
30
|
+
to reach for: `kopular/dom` is Kopular's own *internal* ambient binding, itself made of
|
|
31
|
+
erased `extern` declarations with nothing real behind them at runtime, so `extern class
|
|
32
|
+
Element { ... } from "kopular/dom";` doesn't work — there's no `Element` symbol actually
|
|
33
|
+
living in that module to bind to. Every consuming project re-declares its own
|
|
34
|
+
Element/Document/Event, same as this one does. **Copy the block below rather than
|
|
35
|
+
hand-rolling a smaller one from scratch and adding members as compile errors demand
|
|
36
|
+
them** — a real, complete first-attempt implementation of a Kopular app hit the exact
|
|
37
|
+
same missing property (`Element.value`) twice from two independently-trimmed subsets,
|
|
38
|
+
because the compile error only ever names the one member actually touched, never warns
|
|
39
|
+
that a *sibling* feature (a template's `placeholder="..."` attribute, a `[(value)]`
|
|
40
|
+
binding's generated `e.target.value` read) will need one you didn't happen to write by
|
|
41
|
+
hand:
|
|
42
|
+
|
|
43
|
+
```ks
|
|
44
|
+
extern class Event {
|
|
45
|
+
Element target { get; }
|
|
46
|
+
void preventDefault();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
extern class Element {
|
|
50
|
+
string textContent { get; set; }
|
|
51
|
+
string innerHTML { get; set; }
|
|
52
|
+
string id { get; set; }
|
|
53
|
+
string className { get; set; }
|
|
54
|
+
string href { get; set; }
|
|
55
|
+
string src { get; set; }
|
|
56
|
+
string alt { get; set; }
|
|
57
|
+
// Every `[(value)]="Field"` template binding and any handler reading
|
|
58
|
+
// `e.target.value` needs this — the single most commonly missing member
|
|
59
|
+
// when a hand-trimmed subset breaks.
|
|
60
|
+
string value { get; set; }
|
|
61
|
+
string placeholder { get; set; }
|
|
62
|
+
void appendChild(Element child);
|
|
63
|
+
void replaceChild(Element newChild, Element oldChild);
|
|
64
|
+
void insertBefore(Element newChild, Element? referenceChild);
|
|
65
|
+
void removeChild(Element child);
|
|
66
|
+
void setAttribute(string name, string value);
|
|
67
|
+
void addEventListener(string eventType, (Event) => void handler);
|
|
68
|
+
void removeEventListener(string eventType, (Event) => void handler);
|
|
69
|
+
Element querySelector(string selector);
|
|
70
|
+
Element? closest(string selector);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
extern class Document {
|
|
74
|
+
Element createElement(string tagName);
|
|
75
|
+
Element getElementById(string id);
|
|
76
|
+
Element body { get; }
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
extern Document document;
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Trim what you genuinely never use (this is still "describe exactly the members you
|
|
83
|
+
use," not "always paste everything") — but trim it *after* writing the app, not before,
|
|
84
|
+
so the trim is informed by what actually compiled, not a guess at what a template will
|
|
85
|
+
eventually need.
|
|
26
86
|
|
|
27
87
|
```ks
|
|
28
88
|
extern class VElement {
|
|
@@ -221,10 +281,12 @@ class Counter : Component {
|
|
|
221
281
|
no manual `Subscribe` in the constructor for that field. State reached indirectly
|
|
222
282
|
(through a method, or `this.SomeService.Count`) still needs a manual `Subscribe`, same
|
|
223
283
|
as a hand-written `Render()` always has.
|
|
284
|
+
- **Two-way binding**: `[(value)]="Field"` desugars to `[value]="Field"` +
|
|
285
|
+
`(input)="Field = e.target.value"` — `value` only, and `Field` must be a bare name or
|
|
286
|
+
`this.Field` (see KopScript's own LLM.md `KS5017`/`KS5018` for the two rejected cases).
|
|
224
287
|
- One top-level element per template (hard error otherwise); no mixing text and element
|
|
225
288
|
children under one element (`VElement` has no text-node sibling concept, only
|
|
226
|
-
`.TextContent`); no
|
|
227
|
-
element.
|
|
289
|
+
`.TextContent`); no pipes, at most one structural directive per element.
|
|
228
290
|
- This is entirely a KopScript compiler feature (parsed/desugared before type-checking
|
|
229
291
|
runs) — Kopular's own framework code (`component.ks`, `dom.ks`) is unmodified and
|
|
230
292
|
unaware templates exist; a template-generated `Render()` is indistinguishable from a
|
|
@@ -423,10 +485,12 @@ CombineValidators2((string v) => Validators.Required(v), (string v) => Validator
|
|
|
423
485
|
Fixed-arity (2, 3 — add more the same way if a form ever needs to chain further), not a
|
|
424
486
|
general `Validators.All(...)`, for the same array-of-function-values reason above.
|
|
425
487
|
|
|
426
|
-
**No DOM binding
|
|
427
|
-
|
|
488
|
+
**No DOM binding in a hand-written `Render()`** — wiring `.Value` to a real `<input>` is a
|
|
489
|
+
plain `VElement.OnInput` assignment reading `e.target.value` (the same as any other event
|
|
428
490
|
handler; `VElement.Value` itself is one-way, host-to-DOM only), and reading it back out
|
|
429
|
-
via `.Touch()` on `OnBlur
|
|
491
|
+
via `.Touch()` on `OnBlur`. A **template** has real `[(value)]="Field"` sugar for the
|
|
492
|
+
value-binding half (see "Templates" above); `Touch()` on blur still needs its own explicit
|
|
493
|
+
`(blur)="Field.Touch()"` either way — `[(value)]` only ever wires `value`/`input`.
|
|
430
494
|
|
|
431
495
|
## `kopular/testing` (`testing.js`, hand-written JS, not compiled from `.ks`)
|
|
432
496
|
|
|
@@ -528,10 +592,12 @@ class CounterService {
|
|
|
528
592
|
independent mechanisms, not the same thing wired two ways** — a template never needs
|
|
529
593
|
`directives.ks`'s `If()` imported or called; `*if`/`*for` compile to real `if`/`for`
|
|
530
594
|
directly. Don't mix a template with a hand-written-`Render()` helper call.
|
|
531
|
-
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
595
|
+
- **`[(value)]` two-way binding is template-only, and `value`-only.** A hand-written
|
|
596
|
+
`Render()` still always needs an explicit `OnInput` assignment reading
|
|
597
|
+
`e.target.value` — there's no equivalent shorthand there, since it already has direct
|
|
598
|
+
field access. `[(id)]`/`[(className)]` don't exist either, even in a template — the
|
|
599
|
+
sugar only wires `value`/`input`, the one pairing with a real "user just changed this"
|
|
600
|
+
event.
|
|
535
601
|
- **A `VElement` event binding only accepts `click`/`input`/`blur`/`change`** — both in a
|
|
536
602
|
template's `(event)="..."` and a hand-written `OnClick`/`OnInput`/`OnBlur`/`OnChange`
|
|
537
603
|
assignment. There's no generic `addEventListener` on `VElement` (event handlers are part
|
|
@@ -549,7 +615,9 @@ class CounterService {
|
|
|
549
615
|
DI container/injector · decorators (`@Injectable`, `@Component`, ...) · a runtime
|
|
550
616
|
template engine or interpreted expression language — templates compile to the same
|
|
551
617
|
imperative `Render()` code as the hand-written form, checked at compile time, not
|
|
552
|
-
interpreted at runtime (see "Templates" above) · two-way binding
|
|
618
|
+
interpreted at runtime (see "Templates" above) · two-way binding in a hand-written
|
|
619
|
+
`Render()`, or on anything but `value` even in a template (`[(value)]="Field"` exists —
|
|
620
|
+
see "Templates" above — but it's `value`-only, and templates-only) ·
|
|
553
621
|
reconciliation across a nested-Component boundary (see "Common mistakes" above — real
|
|
554
622
|
vdom diffing exists *within* one Component's own subtree, via `Update()`/`vdom.ks`) ·
|
|
555
623
|
a generic/arbitrary `VElement` event binding — only `click`/`input`/`blur`/`change` ·
|
package/README.md
CHANGED
|
@@ -477,13 +477,16 @@ to live as a free function instead. `Validators` ships the handful of checks alm
|
|
|
477
477
|
form needs (`Required`, `MinLength`, `MaxLength`, `Email`, `Min`, `Max`), each returning
|
|
478
478
|
its own message; write your own validator function for anything more specific.
|
|
479
479
|
|
|
480
|
-
**No two-way data binding
|
|
481
|
-
assignment shown above, the same manual pattern `Counter`
|
|
482
|
-
handler (`VElement.Value` itself is one-way, host-to-DOM
|
|
483
|
-
value back out is always the real event's
|
|
484
|
-
mirrors into `Value` for you). This is
|
|
485
|
-
|
|
486
|
-
|
|
480
|
+
**No two-way data binding in a hand-written `Render()`** — wiring `Value` to a real
|
|
481
|
+
`<input>` is the `OnInput` assignment shown above, the same manual pattern `Counter`
|
|
482
|
+
already uses for its click handler (`VElement.Value` itself is one-way, host-to-DOM
|
|
483
|
+
only — reading the *current* DOM value back out is always the real event's
|
|
484
|
+
`e.target.value`, not something Kopular mirrors into `Value` for you). This is
|
|
485
|
+
deliberate, not a missing feature: hand-written code already has direct field/handler
|
|
486
|
+
access, so there's nothing for a magic binding to save you from writing. A **template**
|
|
487
|
+
gets real sugar for exactly this — `[(value)]="Field"` desugars to `[value]="Field"` +
|
|
488
|
+
`(input)="Field = e.target.value"` (see KopScript's own "Templates" docs) — since a
|
|
489
|
+
markup file has no equivalent direct access to fall back on.
|
|
487
490
|
|
|
488
491
|
## Starting a new project: `kp new`
|
|
489
492
|
|
|
@@ -546,6 +549,15 @@ Marking `Render()` `virtual` in the `extern` declaration is what lets a real sub
|
|
|
546
549
|
for a full working example (components, a service, and routing, all consuming Kopular
|
|
547
550
|
this way).
|
|
548
551
|
|
|
552
|
+
The snippet above is illustrative, not exhaustive — real code needs a fuller
|
|
553
|
+
`VElement`/`Component`/`Router`, and `Element` itself (used above as `Mount`'s
|
|
554
|
+
parameter type but never shown declared) is a plain ambient browser global your own
|
|
555
|
+
project declares, not something Kopular exports — `LLM.md`'s "Consuming Kopular from
|
|
556
|
+
your own KopScript project" section has the complete, copy-ready block for all of
|
|
557
|
+
these, `Element`/`Document`/`Event` included. `npx kp new` (above) generates this
|
|
558
|
+
boilerplate for a fresh project either way — reach for `LLM.md`'s block when adding to
|
|
559
|
+
an existing one instead.
|
|
560
|
+
|
|
549
561
|
`extern class` can carry its own `<T>` (kopscript >= 0.5.0), so a generic export like
|
|
550
562
|
`FormField<T>` describes the same way a real generic class does — see `LLM.md`'s
|
|
551
563
|
`FormField<T>`/`Validators` section for the full example.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kopular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/component.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Event, Element, Document, Location, History, Window, document, location, history, window } from "./dom.js";
|
|
2
|
-
import { VElement } from "./velement.js";
|
|
2
|
+
import { VElement, NoOpEventHandler } from "./velement.js";
|
|
3
3
|
import { Materialize, Patch, PatchChildren } from "./vdom.js";
|
|
4
4
|
|
|
5
5
|
export class Component {
|
package/src/directives.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Event, Element, Document, Location, History, Window, document, location, history, window } from "./dom.js";
|
|
2
|
-
import { VElement } from "./velement.js";
|
|
2
|
+
import { VElement, NoOpEventHandler } from "./velement.js";
|
|
3
3
|
|
|
4
4
|
export function If(condition, whenTrue, whenFalse) {
|
|
5
5
|
if (condition) {
|
package/src/router.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 { Component } from "./component.js";
|
|
3
|
-
import { VElement } from "./velement.js";
|
|
3
|
+
import { VElement, NoOpEventHandler } from "./velement.js";
|
|
4
4
|
|
|
5
5
|
export class Router extends Component {
|
|
6
6
|
constructor(notFoundPage) {
|
package/src/vdom.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Event, Element, Document, Location, History, Window, document, location, history, window } from "./dom.js";
|
|
2
|
-
import { VElement } from "./velement.js";
|
|
2
|
+
import { VElement, NoOpEventHandler } from "./velement.js";
|
|
3
3
|
|
|
4
4
|
export function Materialize(tree) {
|
|
5
5
|
let el = document.createElement(tree.Tag);
|
|
@@ -18,10 +18,18 @@ export function Materialize(tree) {
|
|
|
18
18
|
for (let i = 0; (i < tree.ExtraNames.length); i = (i + 1)) {
|
|
19
19
|
el.setAttribute(tree.ExtraNames[i], tree.ExtraValues[i]);
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
if ((tree.OnClick !== NoOpEventHandler)) {
|
|
22
|
+
el.addEventListener("click", tree.OnClick);
|
|
23
|
+
}
|
|
24
|
+
if ((tree.OnInput !== NoOpEventHandler)) {
|
|
25
|
+
el.addEventListener("input", tree.OnInput);
|
|
26
|
+
}
|
|
27
|
+
if ((tree.OnBlur !== NoOpEventHandler)) {
|
|
28
|
+
el.addEventListener("blur", tree.OnBlur);
|
|
29
|
+
}
|
|
30
|
+
if ((tree.OnChange !== NoOpEventHandler)) {
|
|
31
|
+
el.addEventListener("change", tree.OnChange);
|
|
32
|
+
}
|
|
25
33
|
tree.RealNode = el;
|
|
26
34
|
return el;
|
|
27
35
|
}
|
|
@@ -54,17 +62,33 @@ export function Patch(parent, old, updated) {
|
|
|
54
62
|
realNode.id = updated.Id;
|
|
55
63
|
}
|
|
56
64
|
realNode.value = updated.Value;
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
if ((updated.ExtraNames.length === oldTree.ExtraNames.length)) {
|
|
66
|
+
for (let i = 0; (i < updated.ExtraNames.length); i = (i + 1)) {
|
|
67
|
+
if (((updated.ExtraNames[i] !== oldTree.ExtraNames[i]) || (updated.ExtraValues[i] !== oldTree.ExtraValues[i]))) {
|
|
68
|
+
realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
for (let i = 0; (i < updated.ExtraNames.length); i = (i + 1)) {
|
|
73
|
+
realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if ((updated.OnClick !== oldTree.OnClick)) {
|
|
77
|
+
realNode.removeEventListener("click", oldTree.OnClick);
|
|
78
|
+
realNode.addEventListener("click", updated.OnClick);
|
|
79
|
+
}
|
|
80
|
+
if ((updated.OnInput !== oldTree.OnInput)) {
|
|
81
|
+
realNode.removeEventListener("input", oldTree.OnInput);
|
|
82
|
+
realNode.addEventListener("input", updated.OnInput);
|
|
83
|
+
}
|
|
84
|
+
if ((updated.OnBlur !== oldTree.OnBlur)) {
|
|
85
|
+
realNode.removeEventListener("blur", oldTree.OnBlur);
|
|
86
|
+
realNode.addEventListener("blur", updated.OnBlur);
|
|
87
|
+
}
|
|
88
|
+
if ((updated.OnChange !== oldTree.OnChange)) {
|
|
89
|
+
realNode.removeEventListener("change", oldTree.OnChange);
|
|
90
|
+
realNode.addEventListener("change", updated.OnChange);
|
|
59
91
|
}
|
|
60
|
-
realNode.removeEventListener("click", oldTree.OnClick);
|
|
61
|
-
realNode.addEventListener("click", updated.OnClick);
|
|
62
|
-
realNode.removeEventListener("input", oldTree.OnInput);
|
|
63
|
-
realNode.addEventListener("input", updated.OnInput);
|
|
64
|
-
realNode.removeEventListener("blur", oldTree.OnBlur);
|
|
65
|
-
realNode.addEventListener("blur", updated.OnBlur);
|
|
66
|
-
realNode.removeEventListener("change", oldTree.OnChange);
|
|
67
|
-
realNode.addEventListener("change", updated.OnChange);
|
|
68
92
|
return realNode;
|
|
69
93
|
}
|
|
70
94
|
} else {
|
|
@@ -79,14 +103,8 @@ export function Patch(parent, old, updated) {
|
|
|
79
103
|
}
|
|
80
104
|
}
|
|
81
105
|
export function PatchChildren(parent, oldChildren, newChildren) {
|
|
82
|
-
let oldConsumed =
|
|
83
|
-
|
|
84
|
-
oldConsumed = [...oldConsumed, false];
|
|
85
|
-
}
|
|
86
|
-
let matchedOldIndex = [];
|
|
87
|
-
for (let i = 0; (i < newChildren.length); i = (i + 1)) {
|
|
88
|
-
matchedOldIndex = [...matchedOldIndex, -1];
|
|
89
|
-
}
|
|
106
|
+
let oldConsumed = oldChildren.map((c) => (false));
|
|
107
|
+
let matchedOldIndex = newChildren.map((c) => (-1));
|
|
90
108
|
for (let i = 0; (i < newChildren.length); i = (i + 1)) {
|
|
91
109
|
if ((newChildren[i].Id.length === 0)) {
|
|
92
110
|
continue;
|
|
@@ -113,13 +131,22 @@ export function PatchChildren(parent, oldChildren, newChildren) {
|
|
|
113
131
|
nextOld = (nextOld + 1);
|
|
114
132
|
}
|
|
115
133
|
}
|
|
134
|
+
let needsReorder = (oldChildren.length !== newChildren.length);
|
|
135
|
+
for (let i = 0; (i < newChildren.length); i = (i + 1)) {
|
|
136
|
+
if ((matchedOldIndex[i] !== i)) {
|
|
137
|
+
needsReorder = true;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
116
141
|
for (let i = 0; (i < newChildren.length); i = (i + 1)) {
|
|
117
142
|
let matchedOld = null;
|
|
118
143
|
if ((matchedOldIndex[i] >= 0)) {
|
|
119
144
|
matchedOld = oldChildren[matchedOldIndex[i]];
|
|
120
145
|
}
|
|
121
146
|
let childNode = Patch(parent, matchedOld, newChildren[i]);
|
|
122
|
-
|
|
147
|
+
if (needsReorder) {
|
|
148
|
+
parent.appendChild(childNode);
|
|
149
|
+
}
|
|
123
150
|
}
|
|
124
151
|
for (let j = 0; (j < oldChildren.length); j = (j + 1)) {
|
|
125
152
|
if (oldConsumed[j]) {
|
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// 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 el.addEventListener(\"click\", tree.OnClick);\n el.addEventListener(\"input\", tree.OnInput);\n el.addEventListener(\"blur\", tree.OnBlur);\n el.addEventListener(\"change\", tree.OnChange);\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 for (number i = 0; i < updated.ExtraNames.Length; i = i + 1) {\n realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);\n }\n\n // Always swap all four listeners — cheap, and sidesteps needing to\n // compare function identity (every Render() call creates fresh\n // closures, so \"did the handler actually change\" isn't answerable\n // any other way).\n realNode.removeEventListener(\"click\", oldTree.OnClick);\n realNode.addEventListener(\"click\", updated.OnClick);\n realNode.removeEventListener(\"input\", oldTree.OnInput);\n realNode.addEventListener(\"input\", updated.OnInput);\n realNode.removeEventListener(\"blur\", oldTree.OnBlur);\n realNode.addEventListener(\"blur\", updated.OnBlur);\n realNode.removeEventListener(\"change\", oldTree.OnChange);\n realNode.addEventListener(\"change\", updated.OnChange);\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 bool[] oldConsumed = [];\n for (number i = 0; i < oldChildren.Length; i = i + 1) {\n oldConsumed = oldConsumed.Push(false);\n }\n\n number[] matchedOldIndex = [];\n for (number i = 0; i < newChildren.Length; i = i + 1) {\n matchedOldIndex = matchedOldIndex.Push(-1);\n }\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 // Pass 3: patch/create each new child in order, then move it into its\n // correct final position — appendChild on a node already attached\n // elsewhere in the DOM MOVES it (real DOM semantics), so processing new\n // children in their final desired order and always appending naturally\n // builds up the correct sequence, no separate insertBefore/reference-\n // node bookkeeping needed. Safe because VElement.Children is always the\n // COMPLETE list of a node's children — nothing else ever shares `parent`.\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 parent.appendChild(childNode);\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":";;;AAuBA;EACE;EAEA;IACe;;IAEb;MACgB;;;IAGD;;EAGJ;EACP;EACG;EAET;IACiB;;EAGE;EACA;EACA;EACA;EAEL;EACd;;AAiBF;EACE;IACE;IACA;IACA;MACE;MACA;QACE;QACmB;QACnB;;QAEiB;QAEjB;UACE;YACqB;;;UAGrB;YACuB;;UAEV;;QAGf;UACqB;;QAErB;UACc;;QAaC;QAEf;UACuB;;QAOK;QACH;QACG;QACH;QACG;QACH;QACG;QACH;QAEzB;;;MAOF;MACkB;MAClB;;;IAGF;IACkB;IAClB;;;AAeJ;EACE;EACA;IACc;;EAGd;EACA;IACkB;;EAIlB;IACE;MAAqC;;IACrC;MACE;QACqB;QACJ;QACf;;;;EAQN;EACA;IACE;MAA+B;;IAC/B;MACU;;IAEV;MACqB;MACE;MACb;;;EAWZ;IACE;IACA;MACa;;IAEb;IACkB;;EAIpB;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// 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 if (tree.OnClick != NoOpEventHandler) { el.addEventListener(\"click\", tree.OnClick); }\n if (tree.OnInput != NoOpEventHandler) { el.addEventListener(\"input\", tree.OnInput); }\n if (tree.OnBlur != NoOpEventHandler) { el.addEventListener(\"blur\", tree.OnBlur); }\n if (tree.OnChange != NoOpEventHandler) { el.addEventListener(\"change\", tree.OnChange); }\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 if (updated.OnClick != oldTree.OnClick) {\n realNode.removeEventListener(\"click\", oldTree.OnClick);\n realNode.addEventListener(\"click\", updated.OnClick);\n }\n if (updated.OnInput != oldTree.OnInput) {\n realNode.removeEventListener(\"input\", oldTree.OnInput);\n realNode.addEventListener(\"input\", updated.OnInput);\n }\n if (updated.OnBlur != oldTree.OnBlur) {\n realNode.removeEventListener(\"blur\", oldTree.OnBlur);\n realNode.addEventListener(\"blur\", updated.OnBlur);\n }\n if (updated.OnChange != oldTree.OnChange) {\n realNode.removeEventListener(\"change\", oldTree.OnChange);\n realNode.addEventListener(\"change\", updated.OnChange);\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":";;;AAuBA;EACE;EAEA;IACe;;IAEb;MACgB;;;IAGD;;EAGJ;EACP;EACG;EAET;IACiB;;EAOjB;IAA2D;;EAC3D;IAA2D;;EAC3D;IAA0D;;EAC1D;IAA4D;;EAE9C;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;;;QAgBzB;UAC8B;UACH;;QAE3B;UAC8B;UACH;;QAE3B;UAC8B;UACH;;QAE3B;UAC8B;UACH;;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"}
|
package/src/vdom.ks
CHANGED
|
@@ -42,10 +42,14 @@ Element Materialize(VElement tree) {
|
|
|
42
42
|
el.setAttribute(tree.ExtraNames[i], tree.ExtraValues[i]);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
// Skip attaching VElement's own shared no-op (see velement.ks) — it does
|
|
46
|
+
// nothing when invoked, so registering it costs real work (a listener
|
|
47
|
+
// list entry, held onto for nothing) for zero benefit. A real handler
|
|
48
|
+
// (never equal to the shared no-op) always gets attached as before.
|
|
49
|
+
if (tree.OnClick != NoOpEventHandler) { el.addEventListener("click", tree.OnClick); }
|
|
50
|
+
if (tree.OnInput != NoOpEventHandler) { el.addEventListener("input", tree.OnInput); }
|
|
51
|
+
if (tree.OnBlur != NoOpEventHandler) { el.addEventListener("blur", tree.OnBlur); }
|
|
52
|
+
if (tree.OnChange != NoOpEventHandler) { el.addEventListener("change", tree.OnChange); }
|
|
49
53
|
|
|
50
54
|
tree.RealNode = el;
|
|
51
55
|
return el;
|
|
@@ -108,22 +112,55 @@ Element Patch(Element parent, VElement? old, VElement updated) {
|
|
|
108
112
|
// always writes value on every render rather than diffing it.
|
|
109
113
|
realNode.value = updated.Value;
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
// Same-length is the overwhelmingly common case (the same Render()
|
|
116
|
+
// code path calls SetAttr the same number of times, in the same
|
|
117
|
+
// order, on every call) — compare aligned by index and only touch
|
|
118
|
+
// the real DOM for an entry that actually changed, rather than
|
|
119
|
+
// reapplying every extra attribute on every patch regardless. A
|
|
120
|
+
// length mismatch (the rarer case: a SetAttr call was added,
|
|
121
|
+
// removed, or made conditional between renders) falls back to
|
|
122
|
+
// reapplying everything, since index-aligned comparison isn't
|
|
123
|
+
// meaningful once the two arrays don't correspond entry-for-entry.
|
|
124
|
+
if (updated.ExtraNames.Length == oldTree.ExtraNames.Length) {
|
|
125
|
+
for (number i = 0; i < updated.ExtraNames.Length; i = i + 1) {
|
|
126
|
+
if (updated.ExtraNames[i] != oldTree.ExtraNames[i] || updated.ExtraValues[i] != oldTree.ExtraValues[i]) {
|
|
127
|
+
realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
for (number i = 0; i < updated.ExtraNames.Length; i = i + 1) {
|
|
132
|
+
realNode.setAttribute(updated.ExtraNames[i], updated.ExtraValues[i]);
|
|
133
|
+
}
|
|
113
134
|
}
|
|
114
135
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
// Swap a listener only when the handler reference actually changed.
|
|
137
|
+
// A node that never sets a real OnClick/OnInput/OnBlur/OnChange
|
|
138
|
+
// keeps VElement's own shared NoOpEventHandler reference on both
|
|
139
|
+
// sides (see velement.ks) — comparing by `!=` costs nothing and
|
|
140
|
+
// skips two real DOM API calls per event per node for the (common)
|
|
141
|
+
// case of "this node has no handler of this kind either time,"
|
|
142
|
+
// which matters a lot on a list with hundreds/thousands of rows
|
|
143
|
+
// most of which set at most one or two of the four. A node WITH a
|
|
144
|
+
// real handler still gets a fresh closure every render (it
|
|
145
|
+
// captures per-render values, like a loop's own item), so it still
|
|
146
|
+
// swaps every time — correctly, since the old closure really is
|
|
147
|
+
// stale.
|
|
148
|
+
if (updated.OnClick != oldTree.OnClick) {
|
|
149
|
+
realNode.removeEventListener("click", oldTree.OnClick);
|
|
150
|
+
realNode.addEventListener("click", updated.OnClick);
|
|
151
|
+
}
|
|
152
|
+
if (updated.OnInput != oldTree.OnInput) {
|
|
153
|
+
realNode.removeEventListener("input", oldTree.OnInput);
|
|
154
|
+
realNode.addEventListener("input", updated.OnInput);
|
|
155
|
+
}
|
|
156
|
+
if (updated.OnBlur != oldTree.OnBlur) {
|
|
157
|
+
realNode.removeEventListener("blur", oldTree.OnBlur);
|
|
158
|
+
realNode.addEventListener("blur", updated.OnBlur);
|
|
159
|
+
}
|
|
160
|
+
if (updated.OnChange != oldTree.OnChange) {
|
|
161
|
+
realNode.removeEventListener("change", oldTree.OnChange);
|
|
162
|
+
realNode.addEventListener("change", updated.OnChange);
|
|
163
|
+
}
|
|
127
164
|
|
|
128
165
|
return realNode;
|
|
129
166
|
}
|
|
@@ -155,15 +192,14 @@ Element Patch(Element parent, VElement? old, VElement updated) {
|
|
|
155
192
|
// guaranteed to follow its data across the reorder — give list items a
|
|
156
193
|
// stable Id for that guarantee.
|
|
157
194
|
void PatchChildren(Element parent, VElement[] oldChildren, VElement[] newChildren) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
195
|
+
// Map, not a Push loop — Push is deliberately non-mutating (a real
|
|
196
|
+
// spread-copy every call, see KopScript's own README), so building an
|
|
197
|
+
// n-length array by Push-ing once per element in a loop is an
|
|
198
|
+
// accidental O(n^2) on every single PatchChildren call, however small
|
|
199
|
+
// the actual diff. Map is a real, single O(n) pass straight to
|
|
200
|
+
// Array.prototype.map.
|
|
201
|
+
bool[] oldConsumed = oldChildren.Map((VElement c) => false);
|
|
202
|
+
number[] matchedOldIndex = newChildren.Map((VElement c) => -1);
|
|
167
203
|
|
|
168
204
|
// Pass 1: keyed matches, by Id.
|
|
169
205
|
for (number i = 0; i < newChildren.Length; i = i + 1) {
|
|
@@ -193,20 +229,40 @@ void PatchChildren(Element parent, VElement[] oldChildren, VElement[] newChildre
|
|
|
193
229
|
}
|
|
194
230
|
}
|
|
195
231
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
232
|
+
// Whether anything is actually moving at all — same length, and every
|
|
233
|
+
// new position matched the *same* old position. The extremely common
|
|
234
|
+
// case for a list that's only had some of its rows' own content change
|
|
235
|
+
// (e.g. "update every 10th row"), where reconciliation still has real
|
|
236
|
+
// work to do (see Pass 1/2 above and Patch() itself) but nothing needs
|
|
237
|
+
// to physically move in the DOM at all.
|
|
238
|
+
bool needsReorder = oldChildren.Length != newChildren.Length;
|
|
239
|
+
for (number i = 0; i < newChildren.Length; i = i + 1) {
|
|
240
|
+
if (matchedOldIndex[i] != i) {
|
|
241
|
+
needsReorder = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Pass 3: patch/create each new child in order, then — only if the list
|
|
247
|
+
// actually needs reordering — move it into its correct final position.
|
|
248
|
+
// appendChild on a node already attached elsewhere in the DOM MOVES it
|
|
249
|
+
// (real DOM semantics), so processing new children in their final
|
|
250
|
+
// desired order and always appending naturally builds up the correct
|
|
251
|
+
// sequence, no separate insertBefore/reference-node bookkeeping needed.
|
|
252
|
+
// Safe because VElement.Children is always the COMPLETE list of a
|
|
253
|
+
// node's children — nothing else ever shares `parent`. Skipping the
|
|
254
|
+
// move entirely when `needsReorder` is false avoids a real DOM API call
|
|
255
|
+
// per child for the common no-reorder case — Patch() itself already
|
|
256
|
+
// updates or replaces a reused/changed node exactly in place either way.
|
|
203
257
|
for (number i = 0; i < newChildren.Length; i = i + 1) {
|
|
204
258
|
VElement? matchedOld = null;
|
|
205
259
|
if (matchedOldIndex[i] >= 0) {
|
|
206
260
|
matchedOld = oldChildren[matchedOldIndex[i]];
|
|
207
261
|
}
|
|
208
262
|
Element childNode = Patch(parent, matchedOld, newChildren[i]);
|
|
209
|
-
|
|
263
|
+
if (needsReorder) {
|
|
264
|
+
parent.appendChild(childNode);
|
|
265
|
+
}
|
|
210
266
|
}
|
|
211
267
|
|
|
212
268
|
// Pass 4: remove whatever old children never got reused.
|
package/src/velement.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Event, Element, Document, Location, History, Window, document, location, history, window } from "./dom.js";
|
|
2
2
|
|
|
3
|
+
export function NoOpEventHandler(e) {
|
|
4
|
+
}
|
|
3
5
|
export class VElement {
|
|
4
6
|
constructor(tag) {
|
|
5
7
|
this.Tag = tag;
|
|
@@ -9,14 +11,10 @@ export class VElement {
|
|
|
9
11
|
this.Value = "";
|
|
10
12
|
this.Children = [];
|
|
11
13
|
this.RawHtml = "";
|
|
12
|
-
this.OnClick =
|
|
13
|
-
|
|
14
|
-
this.
|
|
15
|
-
|
|
16
|
-
this.OnBlur = (e) => {
|
|
17
|
-
};
|
|
18
|
-
this.OnChange = (e) => {
|
|
19
|
-
};
|
|
14
|
+
this.OnClick = NoOpEventHandler;
|
|
15
|
+
this.OnInput = NoOpEventHandler;
|
|
16
|
+
this.OnBlur = NoOpEventHandler;
|
|
17
|
+
this.OnChange = NoOpEventHandler;
|
|
20
18
|
this.ExtraNames = [];
|
|
21
19
|
this.ExtraValues = [];
|
|
22
20
|
this.RealNode = null;
|
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// 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 // 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 =
|
|
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 // 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.ExtraNames = [];\n this.ExtraValues = [];\n this.RealNode = null;\n }\n\n public static VElement Create(string tag) {\n return new VElement(tag);\n }\n\n public void AppendChild(VElement child) {\n this.Children = this.Children.Push(child);\n }\n\n // Last call for a given name wins if SetAttr is called more than once\n // with the same name on one VElement — the patch engine applies\n // ExtraNames/ExtraValues in order, so a later entry's setAttribute call\n // simply overwrites an earlier one for the same name, no special\n // dedup/replace logic needed here.\n public void SetAttr(string name, string value) {\n this.ExtraNames = this.ExtraNames.Push(name);\n this.ExtraValues = this.ExtraValues.Push(value);\n }\n}\n"],"names":[],"mappings":";;AAiBA;;AASA;EA6CE;IACW;IACQ;IACF;IACP;IACG;IACG;IACD;IACA;IACA;IACD;IACE;IACE;IACC;IACH;;;EAGF;IACZ;;;EAGK;IACS;;;EAQT;IACW;IACC"}
|
package/src/velement.ks
CHANGED
|
@@ -7,6 +7,17 @@ using "./dom";
|
|
|
7
7
|
// time (see src/vdom.ks for the diff/patch engine, src/component.ks for
|
|
8
8
|
// where Render()'s return type changed from Element to VElement).
|
|
9
9
|
//
|
|
10
|
+
// A single shared no-op, not a fresh closure per VElement — every instance
|
|
11
|
+
// that never sets a real handler gets this exact same function reference,
|
|
12
|
+
// so Patch() (vdom.ks) can tell "no handler either time" apart from "a
|
|
13
|
+
// handler changed" with a plain `!=` reference check instead of always
|
|
14
|
+
// removing/re-adding all four DOM listeners on every patch regardless of
|
|
15
|
+
// whether anything about them actually changed. A fresh `(Event e) => {}`
|
|
16
|
+
// closure per instance would defeat this — two "empty" handlers would
|
|
17
|
+
// never compare equal, even when nothing meaningful differs.
|
|
18
|
+
void NoOpEventHandler(Event e) {
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
// Fixed, named fields — not a generic prop bag — because KopScript has no
|
|
11
22
|
// object-literal syntax to build one with. Fixed, named event slots — not
|
|
12
23
|
// an array of handlers — because KopScript has no array-of-function-values
|
|
@@ -66,10 +77,10 @@ class VElement {
|
|
|
66
77
|
this.Value = "";
|
|
67
78
|
this.Children = [];
|
|
68
79
|
this.RawHtml = "";
|
|
69
|
-
this.OnClick =
|
|
70
|
-
this.OnInput =
|
|
71
|
-
this.OnBlur =
|
|
72
|
-
this.OnChange =
|
|
80
|
+
this.OnClick = NoOpEventHandler;
|
|
81
|
+
this.OnInput = NoOpEventHandler;
|
|
82
|
+
this.OnBlur = NoOpEventHandler;
|
|
83
|
+
this.OnChange = NoOpEventHandler;
|
|
73
84
|
this.ExtraNames = [];
|
|
74
85
|
this.ExtraValues = [];
|
|
75
86
|
this.RealNode = null;
|