kopular 0.15.1 → 0.16.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 +22 -0
- package/README.md +7 -1
- package/bin/kp.mjs +39 -9
- package/package.json +2 -2
- package/src/component.js +9 -1
- package/src/component.js.map +1 -1
- package/src/component.ks +36 -1
- package/src/vdom.js +67 -12
- package/src/vdom.js.map +1 -1
- package/src/vdom.ks +124 -13
- package/src/velement.js +4 -0
- package/src/velement.js.map +1 -1
- package/src/velement.ks +19 -0
package/LLM.md
CHANGED
|
@@ -215,6 +215,28 @@ w.Bump(); // re-render + diff + patch
|
|
|
215
215
|
A list child without a stable `Id` still ends up correct after a reorder, but isn't
|
|
216
216
|
guaranteed to keep its own real node (see `PatchChildren`'s keyed-vs-positional matching
|
|
217
217
|
in `vdom.ks`). Then calls `AfterRender(root)`.
|
|
218
|
+
- **Batching**: `Update()` calls made while a real DOM event handler Kopular itself
|
|
219
|
+
attached is still running (any `OnClick`/`OnInput`/`OnBlur`/`OnChange` set on a
|
|
220
|
+
`VElement`) don't each render immediately — they coalesce into ONE render per affected
|
|
221
|
+
component, applied once the handler returns:
|
|
222
|
+
```ks
|
|
223
|
+
private void Save() {
|
|
224
|
+
this.Draft = ""; // a plain field Render() also reads
|
|
225
|
+
this.Items.Value = this.Items.Value.Push(newItem); // triggers the ONE render, via Subscribe
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
Before batching, whichever line triggered `Update()` first would render with whatever
|
|
229
|
+
the OTHER field held at that exact moment — a handler mutating a plain field AFTER the
|
|
230
|
+
line that happens to trigger a `state<T>`-driven `Update()` would render with that
|
|
231
|
+
field's STALE value, since nothing re-rendered again afterward to pick up the correction.
|
|
232
|
+
With batching, a handler's own statement order no longer matters for what its eventual
|
|
233
|
+
render sees — both statements above render correctly regardless of which comes first.
|
|
234
|
+
Fully synchronous, no microtask: by the time a real `dispatchEvent` call returns, every
|
|
235
|
+
affected component (including a shared service's OTHER `Subscribe`d sibling components)
|
|
236
|
+
has already re-rendered, so a test's very next assertion still sees the final result. A
|
|
237
|
+
`state<T>` write from OUTSIDE a Kopular-attached handler (a `setTimeout`/`setInterval`
|
|
238
|
+
callback, an awaited `Http`/`task` continuation, a direct top-level call) is never
|
|
239
|
+
batched — `Update()` still renders immediately there, exactly as before batching existed.
|
|
218
240
|
- `AfterRender(root)`: `virtual`, a no-op by default, called at the end of both `Mount()`
|
|
219
241
|
and `Update()` with the real, now-materialized/patched root `Element`. For a component
|
|
220
242
|
that needs to do further imperative work against its own real DOM — most commonly,
|
package/README.md
CHANGED
|
@@ -22,7 +22,13 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
|
|
|
22
22
|
`VElement` tree against `kopular/velement`, the way you'd write careful vanilla-JS UI
|
|
23
23
|
code — your choice, and both compile to the exact same thing. An optional `virtual
|
|
24
24
|
RenderError(message)` renders a fallback instead of a hard crash if `Render()` throws —
|
|
25
|
-
purely additive; not overriding it keeps today's exact (uncaught) behavior.
|
|
25
|
+
purely additive; not overriding it keeps today's exact (uncaught) behavior. Every real DOM
|
|
26
|
+
event handler Kopular attaches (`OnClick`/`OnInput`/`OnBlur`/`OnChange`) batches the
|
|
27
|
+
`Update()` calls made while it runs into one render per component, applied once the
|
|
28
|
+
handler returns, fully synchronously — a click handler that mutates more than one field
|
|
29
|
+
only ever renders once, reading every field's final value, regardless of which statement
|
|
30
|
+
happens to trigger it. See `LLM.md`'s "Component" section for the full rationale and the
|
|
31
|
+
real bug this closes.
|
|
26
32
|
- **Templates, compiled and type-checked, not interpreted**: markup lives in its own
|
|
27
33
|
`.html` file — interpolation (`{{ }}`), event/property bindings (`(click)="..."`,
|
|
28
34
|
`[prop]="..."`), and `*if`/`*for` structural directives — desugared by the KopScript
|
package/bin/kp.mjs
CHANGED
|
@@ -67,6 +67,7 @@ const INDEX_HTML_TEMPLATE = `<!doctype html>
|
|
|
67
67
|
<script type="importmap">
|
|
68
68
|
{
|
|
69
69
|
"imports": {
|
|
70
|
+
"kopular/velement": "/vendor/kopular/velement.js",
|
|
70
71
|
"kopular/component": "/vendor/kopular/component.js",
|
|
71
72
|
"kopular/router": "/vendor/kopular/router.js"
|
|
72
73
|
}
|
|
@@ -168,11 +169,11 @@ const VENDOR_KOPULAR_MJS_TEMPLATE = `#!/usr/bin/env node
|
|
|
168
169
|
// locally serving) the whole node_modules tree just to get a few files out
|
|
169
170
|
// of it is unnecessary.
|
|
170
171
|
//
|
|
171
|
-
// dom.js
|
|
172
|
-
// import
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// names explicitly.
|
|
172
|
+
// dom.js and vdom.js aren't in the import map themselves, but component.js/
|
|
173
|
+
// router.js/velement.js each import one or both internally via a relative
|
|
174
|
+
// "./dom.js"/"./vdom.js" — Kopular's own compiled files reference each other
|
|
175
|
+
// as siblings, so all five have to land in the same vendor/kopular/
|
|
176
|
+
// directory together, not just the three the import map names explicitly.
|
|
176
177
|
import { copyFileSync, mkdirSync } from "node:fs";
|
|
177
178
|
import { dirname, join } from "node:path";
|
|
178
179
|
import { fileURLToPath } from "node:url";
|
|
@@ -181,11 +182,11 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
181
182
|
const outDir = join(root, "vendor", "kopular");
|
|
182
183
|
mkdirSync(outDir, { recursive: true });
|
|
183
184
|
|
|
184
|
-
for (const file of ["dom.js", "component.js", "router.js"]) {
|
|
185
|
+
for (const file of ["dom.js", "vdom.js", "velement.js", "component.js", "router.js"]) {
|
|
185
186
|
copyFileSync(join(root, "node_modules", "kopular", "src", file), join(outDir, file));
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
console.log(\`Vendored kopular/{dom,component,router}.js into \${outDir}\`);
|
|
189
|
+
console.log(\`Vendored kopular/{dom,vdom,velement,component,router}.js into \${outDir}\`);
|
|
189
190
|
`;
|
|
190
191
|
|
|
191
192
|
// Ambient DOM bindings, plus Kopular's own classes redeclared as `extern` —
|
|
@@ -195,7 +196,11 @@ console.log(\`Vendored kopular/{dom,component,router}.js into \${outDir}\`);
|
|
|
195
196
|
// own .ks sources — every consuming project redeclares this ambient surface
|
|
196
197
|
// once, here.
|
|
197
198
|
const KOPULAR_BINDINGS_KS_TEMPLATE = `// Ambient DOM bindings — describing standing browser globals, not anything
|
|
198
|
-
// Kopular itself exports, so this is just describing the platform.
|
|
199
|
+
// Kopular itself exports, so this is just describing the platform. Copy
|
|
200
|
+
// this block rather than trimming it down: a compile error only ever names
|
|
201
|
+
// the one member actually missing, never warns that a sibling feature (a
|
|
202
|
+
// [(value)]="Field" binding, a placeholder="..." attribute) will need one
|
|
203
|
+
// you didn't happen to include.
|
|
199
204
|
extern class Event {
|
|
200
205
|
Element target { get; }
|
|
201
206
|
void preventDefault();
|
|
@@ -209,11 +214,17 @@ extern class Element {
|
|
|
209
214
|
string href { get; set; }
|
|
210
215
|
string src { get; set; }
|
|
211
216
|
string alt { get; set; }
|
|
217
|
+
string value { get; set; }
|
|
218
|
+
string placeholder { get; set; }
|
|
212
219
|
void appendChild(Element child);
|
|
213
220
|
void replaceChild(Element newChild, Element oldChild);
|
|
221
|
+
void insertBefore(Element newChild, Element? referenceChild);
|
|
222
|
+
void removeChild(Element child);
|
|
223
|
+
void setAttribute(string name, string value);
|
|
214
224
|
void addEventListener(string eventType, (Event) => void handler);
|
|
215
225
|
void removeEventListener(string eventType, (Event) => void handler);
|
|
216
226
|
Element querySelector(string selector);
|
|
227
|
+
Element? closest(string selector);
|
|
217
228
|
};
|
|
218
229
|
|
|
219
230
|
extern class Document {
|
|
@@ -224,11 +235,30 @@ extern class Document {
|
|
|
224
235
|
|
|
225
236
|
extern Document document;
|
|
226
237
|
|
|
238
|
+
// A description of one DOM element, built instead of real DOM — Render()
|
|
239
|
+
// below returns this, not an Element, so Kopular's own Component base class
|
|
240
|
+
// can diff a render against the previous one and patch only what changed.
|
|
241
|
+
extern class VElement {
|
|
242
|
+
static VElement Create(string tag);
|
|
243
|
+
string TextContent { get; set; }
|
|
244
|
+
string ClassName { get; set; }
|
|
245
|
+
string Id { get; set; }
|
|
246
|
+
string Value { get; set; }
|
|
247
|
+
string RawHtml { get; set; }
|
|
248
|
+
(Event) => void OnClick { get; set; }
|
|
249
|
+
(Event) => void OnInput { get; set; }
|
|
250
|
+
(Event) => void OnBlur { get; set; }
|
|
251
|
+
(Event) => void OnChange { get; set; }
|
|
252
|
+
void AppendChild(VElement child);
|
|
253
|
+
void SetAttr(string name, string value);
|
|
254
|
+
} from "kopular/velement";
|
|
255
|
+
|
|
227
256
|
// Kopular's real classes, consumed from the real published "kopular" npm
|
|
228
257
|
// package. \`virtual\` on Render() is what lets a class here \`override\` it.
|
|
229
258
|
extern class Component {
|
|
230
259
|
constructor();
|
|
231
|
-
virtual
|
|
260
|
+
virtual VElement Render();
|
|
261
|
+
virtual void AfterRender(Element root);
|
|
232
262
|
void Mount(Element parent);
|
|
233
263
|
void Update();
|
|
234
264
|
} from "kopular/component";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kopular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -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.21.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 { Materialize, Patch, PatchChildren } from "./vdom.js";
|
|
3
|
+
import { Batching, Materialize, Patch, PatchChildren } from "./vdom.js";
|
|
4
4
|
|
|
5
5
|
export class Component {
|
|
6
6
|
constructor() {
|
|
@@ -39,6 +39,14 @@ export class Component {
|
|
|
39
39
|
if (!this.IsMounted) {
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
|
+
if (Batching.IsActive()) {
|
|
43
|
+
Batching.Defer(this);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.FlushUpdate();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
FlushUpdate() {
|
|
42
50
|
let newTree = this.SafeRender();
|
|
43
51
|
let root = Patch(this.ParentElement, this.Tree, newTree);
|
|
44
52
|
this.Tree = newTree;
|
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// Known limitation: if a *parent* component's own Render() re-runs (i.e.\n// something calls Update() on the parent) while it has mounted children,\n// those children are not automatically re-mounted into the parent's new\n// tree — this base class only handles a single component's own re-render\n// cycle, not parent/child reconciliation across one. Composing independent\n// components (each mounted into its own stable slot, as in app.kop) avoids\n// the issue entirely.\nclass Component {\n protected VElement Tree;\n private Element ParentElement;\n // Set true only once Mount() actually runs. A page Component is commonly\n // constructed eagerly (e.g. Router.AddRoute takes an already-built\n // instance — see Router's own header comment) long before it's ever\n // Mount()ed, and two sibling pages sharing one injected service's\n // state<T> (Pure DI — both Subscribe() the same field) both get notified\n // on any change regardless of which one is actually the currently-routed,\n // mounted page. Update() guards on this so that notification is a safe\n // no-op for the unmounted one, instead of a crash (see Update() below).\n private bool IsMounted;\n\n constructor() {\n this.IsMounted = false;\n }\n\n public virtual VElement Render() {\n return VElement.Create(\"div\");\n }\n\n // Overridden to render a fallback UI when Render() throws — a page bug,\n // an unhandled rejected Http call, anything — instead of leaving\n // Mount()/Update() to propagate the exception uncaught, which would\n // otherwise crash whatever triggered the render (a click handler, a\n // Router navigation) with nothing shown to the user at all. Default just\n // re-throws, so anything that doesn't override this keeps today's exact\n // behavior — this is purely additive, opt-in error recovery, not a\n // behavior change for existing components.\n protected virtual VElement RenderError(string message) {\n throw message;\n }\n\n private VElement SafeRender() {\n try {\n return this.Render();\n } catch (string message) {\n return this.RenderError(message);\n }\n }\n\n // Called after Mount()/Update() has materialized/patched this\n // component's own VElement tree into `root` — a hook for a component\n // that needs to do additional, imperative work against its OWN\n // now-real root element once it exists. Default is a no-op, so every\n // component that doesn't need this is unaffected. Router is the one\n // real user of this today: mounting/re-mounting its matched child PAGE\n // into the outlet div its own Render() just describes, since a nested\n // Component's own mount lifecycle isn't something a VElement tree can\n // express as data (see the class comment above on parent/child\n // reconciliation being a known, deliberately out-of-scope limitation —\n // this hook is the documented way around it, not a fix to it).\n protected virtual void AfterRender(Element root) {\n }\n\n public void Mount(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree);\n parent.appendChild(root);\n this.IsMounted = true;\n this.AfterRender(root);\n }\n\n // A no-op, not an error, when called before Mount() — see the class-level\n // comment on IsMounted for exactly when this happens for real (a\n // shared-service state change reaching a sibling page that isn't the one\n // currently routed/mounted). Nothing is lost: SafeRender() would only be\n // thrown away unread since there's no live DOM parent to put it in, and\n // Mount() itself always calls SafeRender() fresh whenever this component\n // does become the routed page, picking up whatever the current state is\n // at that point.\n protected void Update() {\n if (!this.IsMounted) {\n return;\n }\n VElement newTree = this.SafeRender();\n Element root = Patch(this.ParentElement, this.Tree, newTree);\n this.Tree = newTree;\n this.AfterRender(root);\n }\n}\n"],"names":[],"mappings":";;;;AAoBA;EAaE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAec;;;EAGX;IACc;IACT;IACV;IACkB;IACH;IACC;;;
|
|
1
|
+
{"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\nusing \"./vdom\";\n\n// A minimal component base: subclasses override Render() to build a\n// VElement tree describing the current state, and call the inherited\n// Update() whenever that state changes to re-render. Update() DIFFS the\n// new VElement tree against the previous one (see vdom.ks's Patch) and\n// patches only what changed, reusing real DOM nodes wherever their tag\n// stays the same — replacing a whole subtree is now the exception (a\n// changed tag, or no previous tree at all), not the default on every\n// re-render the way it used to be.\n//\n// Known limitation: if a *parent* component's own Render() re-runs (i.e.\n// something calls Update() on the parent) while it has mounted children,\n// those children are not automatically re-mounted into the parent's new\n// tree — this base class only handles a single component's own re-render\n// cycle, not parent/child reconciliation across one. Composing independent\n// components (each mounted into its own stable slot, as in app.kop) avoids\n// the issue entirely.\nclass Component : Flushable {\n protected VElement Tree;\n private Element ParentElement;\n // Set true only once Mount() actually runs. A page Component is commonly\n // constructed eagerly (e.g. Router.AddRoute takes an already-built\n // instance — see Router's own header comment) long before it's ever\n // Mount()ed, and two sibling pages sharing one injected service's\n // state<T> (Pure DI — both Subscribe() the same field) both get notified\n // on any change regardless of which one is actually the currently-routed,\n // mounted page. Update() guards on this so that notification is a safe\n // no-op for the unmounted one, instead of a crash (see Update() below).\n private bool IsMounted;\n\n constructor() {\n this.IsMounted = false;\n }\n\n public virtual VElement Render() {\n return VElement.Create(\"div\");\n }\n\n // Overridden to render a fallback UI when Render() throws — a page bug,\n // an unhandled rejected Http call, anything — instead of leaving\n // Mount()/Update() to propagate the exception uncaught, which would\n // otherwise crash whatever triggered the render (a click handler, a\n // Router navigation) with nothing shown to the user at all. Default just\n // re-throws, so anything that doesn't override this keeps today's exact\n // behavior — this is purely additive, opt-in error recovery, not a\n // behavior change for existing components.\n protected virtual VElement RenderError(string message) {\n throw message;\n }\n\n private VElement SafeRender() {\n try {\n return this.Render();\n } catch (string message) {\n return this.RenderError(message);\n }\n }\n\n // Called after Mount()/Update() has materialized/patched this\n // component's own VElement tree into `root` — a hook for a component\n // that needs to do additional, imperative work against its OWN\n // now-real root element once it exists. Default is a no-op, so every\n // component that doesn't need this is unaffected. Router is the one\n // real user of this today: mounting/re-mounting its matched child PAGE\n // into the outlet div its own Render() just describes, since a nested\n // Component's own mount lifecycle isn't something a VElement tree can\n // express as data (see the class comment above on parent/child\n // reconciliation being a known, deliberately out-of-scope limitation —\n // this hook is the documented way around it, not a fix to it).\n protected virtual void AfterRender(Element root) {\n }\n\n public void Mount(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree);\n parent.appendChild(root);\n this.IsMounted = true;\n this.AfterRender(root);\n }\n\n // A no-op, not an error, when called before Mount() — see the class-level\n // comment on IsMounted for exactly when this happens for real (a\n // shared-service state change reaching a sibling page that isn't the one\n // currently routed/mounted). Nothing is lost: SafeRender() would only be\n // thrown away unread since there's no live DOM parent to put it in, and\n // Mount() itself always calls SafeRender() fresh whenever this component\n // does become the routed page, picking up whatever the current state is\n // at that point.\n //\n // Batching: while a real DOM event handler Kopular itself attached is\n // still running (Batching.IsActive() — see vdom.ks; every OnClick/\n // OnInput/OnBlur/OnChange listener Materialize/Patch attaches is wrapped\n // in Batching.Run), Update() doesn't render immediately — it registers\n // this component with Batching.Defer and returns. A handler that touches\n // more than one piece of state (or a state<T> write followed by a plain\n // field write Render() also reads) then only ever renders ONCE, once the\n // handler finishes, reading every field's FINAL value for that handler —\n // not once per state<T> write, reading whatever was true at that specific\n // moment. A handler's own statement order no longer matters for what a\n // re-render sees. Fully synchronous — no microtask: by the time the real\n // DOM's own dispatchEvent call returns, every affected component has\n // already re-rendered, the same guarantee React's own synthetic-event\n // batching gives a test's very next assertion. A state<T> write from\n // OUTSIDE a wrapped handler (a setTimeout/setInterval callback, an\n // awaited Http/task continuation, a direct top-level call) is never\n // inside a batch, so Update() still renders immediately there, exactly\n // as before batching existed.\n protected void Update() {\n if (!this.IsMounted) {\n return;\n }\n if (Batching.IsActive()) {\n Batching.Defer(this);\n return;\n }\n this.FlushUpdate();\n }\n\n // The real re-render Update() performs immediately outside a batch, or\n // that Batching.Run flushes once for every component deferred during one.\n // Public only because implementing the Flushable interface (see vdom.ks)\n // requires it — KopScript has no \"internal\" visibility narrower than\n // public, and structural interface conformance requires a public match.\n // Not the intended way to trigger a render from outside this class:\n // calling it directly bypasses batching entirely. Update() is still the\n // real, documented entry point for that.\n public void FlushUpdate() {\n VElement newTree = this.SafeRender();\n Element root = Patch(this.ParentElement, this.Tree, newTree);\n this.Tree = newTree;\n this.AfterRender(root);\n }\n}\n"],"names":[],"mappings":";;;;AAoBA;EAaE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAec;;;EAGX;IACc;IACT;IACV;IACkB;IACH;IACC;;;EA8BR;IACR;MACE;;IAEF;MACgB;MACd;;IAEc;;;EAWX;IACL;IACA;IACU;IACM"}
|
package/src/component.ks
CHANGED
|
@@ -18,7 +18,7 @@ using "./vdom";
|
|
|
18
18
|
// cycle, not parent/child reconciliation across one. Composing independent
|
|
19
19
|
// components (each mounted into its own stable slot, as in app.kop) avoids
|
|
20
20
|
// the issue entirely.
|
|
21
|
-
class Component {
|
|
21
|
+
class Component : Flushable {
|
|
22
22
|
protected VElement Tree;
|
|
23
23
|
private Element ParentElement;
|
|
24
24
|
// Set true only once Mount() actually runs. A page Component is commonly
|
|
@@ -90,10 +90,45 @@ class Component {
|
|
|
90
90
|
// Mount() itself always calls SafeRender() fresh whenever this component
|
|
91
91
|
// does become the routed page, picking up whatever the current state is
|
|
92
92
|
// at that point.
|
|
93
|
+
//
|
|
94
|
+
// Batching: while a real DOM event handler Kopular itself attached is
|
|
95
|
+
// still running (Batching.IsActive() — see vdom.ks; every OnClick/
|
|
96
|
+
// OnInput/OnBlur/OnChange listener Materialize/Patch attaches is wrapped
|
|
97
|
+
// in Batching.Run), Update() doesn't render immediately — it registers
|
|
98
|
+
// this component with Batching.Defer and returns. A handler that touches
|
|
99
|
+
// more than one piece of state (or a state<T> write followed by a plain
|
|
100
|
+
// field write Render() also reads) then only ever renders ONCE, once the
|
|
101
|
+
// handler finishes, reading every field's FINAL value for that handler —
|
|
102
|
+
// not once per state<T> write, reading whatever was true at that specific
|
|
103
|
+
// moment. A handler's own statement order no longer matters for what a
|
|
104
|
+
// re-render sees. Fully synchronous — no microtask: by the time the real
|
|
105
|
+
// DOM's own dispatchEvent call returns, every affected component has
|
|
106
|
+
// already re-rendered, the same guarantee React's own synthetic-event
|
|
107
|
+
// batching gives a test's very next assertion. A state<T> write from
|
|
108
|
+
// OUTSIDE a wrapped handler (a setTimeout/setInterval callback, an
|
|
109
|
+
// awaited Http/task continuation, a direct top-level call) is never
|
|
110
|
+
// inside a batch, so Update() still renders immediately there, exactly
|
|
111
|
+
// as before batching existed.
|
|
93
112
|
protected void Update() {
|
|
94
113
|
if (!this.IsMounted) {
|
|
95
114
|
return;
|
|
96
115
|
}
|
|
116
|
+
if (Batching.IsActive()) {
|
|
117
|
+
Batching.Defer(this);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.FlushUpdate();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The real re-render Update() performs immediately outside a batch, or
|
|
124
|
+
// that Batching.Run flushes once for every component deferred during one.
|
|
125
|
+
// Public only because implementing the Flushable interface (see vdom.ks)
|
|
126
|
+
// requires it — KopScript has no "internal" visibility narrower than
|
|
127
|
+
// public, and structural interface conformance requires a public match.
|
|
128
|
+
// Not the intended way to trigger a render from outside this class:
|
|
129
|
+
// calling it directly bypasses batching entirely. Update() is still the
|
|
130
|
+
// real, documented entry point for that.
|
|
131
|
+
public void FlushUpdate() {
|
|
97
132
|
VElement newTree = this.SafeRender();
|
|
98
133
|
Element root = Patch(this.ParentElement, this.Tree, newTree);
|
|
99
134
|
this.Tree = newTree;
|
package/src/vdom.js
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
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
3
|
|
|
4
|
+
export class Batching {
|
|
5
|
+
static Depth = 0;
|
|
6
|
+
|
|
7
|
+
static Pending = [];
|
|
8
|
+
|
|
9
|
+
static IsActive() {
|
|
10
|
+
return (Batching.Depth > 0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static Defer(f) {
|
|
14
|
+
if (!Batching.Pending.includes(f)) {
|
|
15
|
+
Batching.Pending = [...Batching.Pending, f];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static Run(action) {
|
|
20
|
+
Batching.Depth = (Batching.Depth + 1);
|
|
21
|
+
try {
|
|
22
|
+
action();
|
|
23
|
+
} finally {
|
|
24
|
+
Batching.Depth = (Batching.Depth - 1);
|
|
25
|
+
if ((Batching.Depth === 0)) {
|
|
26
|
+
let toFlush = Batching.Pending;
|
|
27
|
+
Batching.Pending = [];
|
|
28
|
+
for (const f of toFlush) {
|
|
29
|
+
f.FlushUpdate();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
4
35
|
export function Materialize(tree) {
|
|
5
36
|
let el = document.createElement(tree.Tag);
|
|
6
37
|
if ((tree.RawHtml.length > 0)) {
|
|
@@ -19,16 +50,24 @@ export function Materialize(tree) {
|
|
|
19
50
|
el.setAttribute(tree.ExtraNames[i], tree.ExtraValues[i]);
|
|
20
51
|
}
|
|
21
52
|
if ((tree.OnClick !== NoOpEventHandler)) {
|
|
22
|
-
|
|
53
|
+
let wrapped = (e) => (Batching.Run(() => (tree.OnClick(e))));
|
|
54
|
+
tree.AttachedOnClick = wrapped;
|
|
55
|
+
el.addEventListener("click", wrapped);
|
|
23
56
|
}
|
|
24
57
|
if ((tree.OnInput !== NoOpEventHandler)) {
|
|
25
|
-
|
|
58
|
+
let wrapped = (e) => (Batching.Run(() => (tree.OnInput(e))));
|
|
59
|
+
tree.AttachedOnInput = wrapped;
|
|
60
|
+
el.addEventListener("input", wrapped);
|
|
26
61
|
}
|
|
27
62
|
if ((tree.OnBlur !== NoOpEventHandler)) {
|
|
28
|
-
|
|
63
|
+
let wrapped = (e) => (Batching.Run(() => (tree.OnBlur(e))));
|
|
64
|
+
tree.AttachedOnBlur = wrapped;
|
|
65
|
+
el.addEventListener("blur", wrapped);
|
|
29
66
|
}
|
|
30
67
|
if ((tree.OnChange !== NoOpEventHandler)) {
|
|
31
|
-
|
|
68
|
+
let wrapped = (e) => (Batching.Run(() => (tree.OnChange(e))));
|
|
69
|
+
tree.AttachedOnChange = wrapped;
|
|
70
|
+
el.addEventListener("change", wrapped);
|
|
32
71
|
}
|
|
33
72
|
tree.RealNode = el;
|
|
34
73
|
return el;
|
|
@@ -74,20 +113,36 @@ export function Patch(parent, old, updated) {
|
|
|
74
113
|
}
|
|
75
114
|
}
|
|
76
115
|
if ((updated.OnClick !== oldTree.OnClick)) {
|
|
77
|
-
realNode.removeEventListener("click", oldTree.
|
|
78
|
-
|
|
116
|
+
realNode.removeEventListener("click", oldTree.AttachedOnClick);
|
|
117
|
+
let wrappedClick = (e) => (Batching.Run(() => (updated.OnClick(e))));
|
|
118
|
+
updated.AttachedOnClick = wrappedClick;
|
|
119
|
+
realNode.addEventListener("click", wrappedClick);
|
|
120
|
+
} else {
|
|
121
|
+
updated.AttachedOnClick = oldTree.AttachedOnClick;
|
|
79
122
|
}
|
|
80
123
|
if ((updated.OnInput !== oldTree.OnInput)) {
|
|
81
|
-
realNode.removeEventListener("input", oldTree.
|
|
82
|
-
|
|
124
|
+
realNode.removeEventListener("input", oldTree.AttachedOnInput);
|
|
125
|
+
let wrappedInput = (e) => (Batching.Run(() => (updated.OnInput(e))));
|
|
126
|
+
updated.AttachedOnInput = wrappedInput;
|
|
127
|
+
realNode.addEventListener("input", wrappedInput);
|
|
128
|
+
} else {
|
|
129
|
+
updated.AttachedOnInput = oldTree.AttachedOnInput;
|
|
83
130
|
}
|
|
84
131
|
if ((updated.OnBlur !== oldTree.OnBlur)) {
|
|
85
|
-
realNode.removeEventListener("blur", oldTree.
|
|
86
|
-
|
|
132
|
+
realNode.removeEventListener("blur", oldTree.AttachedOnBlur);
|
|
133
|
+
let wrappedBlur = (e) => (Batching.Run(() => (updated.OnBlur(e))));
|
|
134
|
+
updated.AttachedOnBlur = wrappedBlur;
|
|
135
|
+
realNode.addEventListener("blur", wrappedBlur);
|
|
136
|
+
} else {
|
|
137
|
+
updated.AttachedOnBlur = oldTree.AttachedOnBlur;
|
|
87
138
|
}
|
|
88
139
|
if ((updated.OnChange !== oldTree.OnChange)) {
|
|
89
|
-
realNode.removeEventListener("change", oldTree.
|
|
90
|
-
|
|
140
|
+
realNode.removeEventListener("change", oldTree.AttachedOnChange);
|
|
141
|
+
let wrappedChange = (e) => (Batching.Run(() => (updated.OnChange(e))));
|
|
142
|
+
updated.AttachedOnChange = wrappedChange;
|
|
143
|
+
realNode.addEventListener("change", wrappedChange);
|
|
144
|
+
} else {
|
|
145
|
+
updated.AttachedOnChange = oldTree.AttachedOnChange;
|
|
91
146
|
}
|
|
92
147
|
return realNode;
|
|
93
148
|
}
|
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 // 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"}
|
|
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"}
|
package/src/vdom.ks
CHANGED
|
@@ -15,6 +15,67 @@ using "./velement";
|
|
|
15
15
|
// approach work without a generic children/attributes read-back API that
|
|
16
16
|
// KopScript's narrow, curated DOM binding doesn't have.
|
|
17
17
|
|
|
18
|
+
// A component-like thing Batching (below) can flush once every DOM-event-
|
|
19
|
+
// triggered Update() call inside one batch has registered itself. Declared
|
|
20
|
+
// HERE, not in component.ks, specifically so THIS file's own Materialize/
|
|
21
|
+
// Patch — which need to start/stop a batch around every real event
|
|
22
|
+
// dispatch — never have to `using "./component"`: component.ks already
|
|
23
|
+
// `using`s this file for Materialize/Patch themselves, and KopScript
|
|
24
|
+
// rejects a circular `using` outright. Component (component.ks) is the
|
|
25
|
+
// one real implementer, via `class Component : Flushable`.
|
|
26
|
+
interface Flushable {
|
|
27
|
+
void FlushUpdate();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Coalesces every Update() call made while a real DOM event handler
|
|
31
|
+
// Kopular itself attached (see Materialize/Patch below, both of which wrap
|
|
32
|
+
// each listener in Batching.Run before attaching it) into one flush per
|
|
33
|
+
// affected component, applied once that handler returns — not one flush
|
|
34
|
+
// per state<T> write inside it. See Component.Update()'s own comment
|
|
35
|
+
// (component.ks) for the full rationale; this is just the mechanism.
|
|
36
|
+
//
|
|
37
|
+
// Deliberately class-level, shared across every batch/component rather
|
|
38
|
+
// than per-instance — a single click can trigger Update() on more than one
|
|
39
|
+
// component (e.g. a shared service's state<T> notifying two sibling
|
|
40
|
+
// pages), and all of them need to flush together, once, when that one
|
|
41
|
+
// handler finishes.
|
|
42
|
+
class Batching {
|
|
43
|
+
private static number Depth = 0;
|
|
44
|
+
private static Flushable[] Pending = [];
|
|
45
|
+
|
|
46
|
+
public static bool IsActive() {
|
|
47
|
+
return Batching.Depth > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public static void Defer(Flushable f) {
|
|
51
|
+
if (!Batching.Pending.Includes(f)) {
|
|
52
|
+
Batching.Pending = Batching.Pending.Push(f);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `try`/`finally`, not a bare sequence: a handler that throws must still
|
|
57
|
+
// decrement Depth and flush whatever already-triggered renders are
|
|
58
|
+
// pending, or one uncaught exception would wedge every future click/
|
|
59
|
+
// input on the page into "always batching, never rendering." The
|
|
60
|
+
// exception itself still propagates unchanged — this never catches it,
|
|
61
|
+
// only guarantees the cleanup runs.
|
|
62
|
+
public static void Run(() => void action) {
|
|
63
|
+
Batching.Depth = Batching.Depth + 1;
|
|
64
|
+
try {
|
|
65
|
+
action();
|
|
66
|
+
} finally {
|
|
67
|
+
Batching.Depth = Batching.Depth - 1;
|
|
68
|
+
if (Batching.Depth == 0) {
|
|
69
|
+
Flushable[] toFlush = Batching.Pending;
|
|
70
|
+
Batching.Pending = [];
|
|
71
|
+
foreach (Flushable f in toFlush) {
|
|
72
|
+
f.FlushUpdate();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
18
79
|
// Builds a brand-new, fully real DOM subtree from a VElement tree with no
|
|
19
80
|
// diffing at all — first mount, or whenever Patch() decides a subtree must
|
|
20
81
|
// be replaced outright (no previous node to reuse, or the tag changed).
|
|
@@ -45,11 +106,32 @@ Element Materialize(VElement tree) {
|
|
|
45
106
|
// Skip attaching VElement's own shared no-op (see velement.ks) — it does
|
|
46
107
|
// nothing when invoked, so registering it costs real work (a listener
|
|
47
108
|
// 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
109
|
+
// (never equal to the shared no-op) always gets attached as before —
|
|
110
|
+
// wrapped in Batching.Run so every Update() it triggers coalesces with
|
|
111
|
+
// any others from the same dispatch (see Component.Update()'s own
|
|
112
|
+
// comment). The wrapper itself, not tree.OnClick, is what actually gets
|
|
113
|
+
// registered — recorded on tree.AttachedOnClick (velement.ks) so a later
|
|
114
|
+
// Patch() can remove this exact reference, not the handler value itself.
|
|
115
|
+
if (tree.OnClick != NoOpEventHandler) {
|
|
116
|
+
(Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnClick(e));
|
|
117
|
+
tree.AttachedOnClick = wrapped;
|
|
118
|
+
el.addEventListener("click", wrapped);
|
|
119
|
+
}
|
|
120
|
+
if (tree.OnInput != NoOpEventHandler) {
|
|
121
|
+
(Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnInput(e));
|
|
122
|
+
tree.AttachedOnInput = wrapped;
|
|
123
|
+
el.addEventListener("input", wrapped);
|
|
124
|
+
}
|
|
125
|
+
if (tree.OnBlur != NoOpEventHandler) {
|
|
126
|
+
(Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnBlur(e));
|
|
127
|
+
tree.AttachedOnBlur = wrapped;
|
|
128
|
+
el.addEventListener("blur", wrapped);
|
|
129
|
+
}
|
|
130
|
+
if (tree.OnChange != NoOpEventHandler) {
|
|
131
|
+
(Event) => void wrapped = (Event e) => Batching.Run(() => tree.OnChange(e));
|
|
132
|
+
tree.AttachedOnChange = wrapped;
|
|
133
|
+
el.addEventListener("change", wrapped);
|
|
134
|
+
}
|
|
53
135
|
|
|
54
136
|
tree.RealNode = el;
|
|
55
137
|
return el;
|
|
@@ -145,21 +227,50 @@ Element Patch(Element parent, VElement? old, VElement updated) {
|
|
|
145
227
|
// captures per-render values, like a loop's own item), so it still
|
|
146
228
|
// swaps every time — correctly, since the old closure really is
|
|
147
229
|
// stale.
|
|
230
|
+
//
|
|
231
|
+
// The actually-attached listener is always a Batching.Run wrapper
|
|
232
|
+
// (see Materialize above), never `oldTree.OnClick`/`updated.OnClick`
|
|
233
|
+
// themselves — removeEventListener only works when passed the exact
|
|
234
|
+
// reference addEventListener received, so removal always goes
|
|
235
|
+
// through `oldTree.AttachedOnClick` (the wrapper Materialize/a
|
|
236
|
+
// previous Patch actually registered), and a real swap builds a
|
|
237
|
+
// fresh wrapper the same way Materialize does. When nothing swaps,
|
|
238
|
+
// `updated.AttachedOnClick` still needs to carry `oldTree`'s
|
|
239
|
+
// forward — `updated` is a brand-new VElement whose own
|
|
240
|
+
// AttachedOnClick starts back at the shared no-op (velement.ks's
|
|
241
|
+
// constructor), and it becomes the retained "old tree" the very
|
|
242
|
+
// next Patch() call diffs against.
|
|
148
243
|
if (updated.OnClick != oldTree.OnClick) {
|
|
149
|
-
realNode.removeEventListener("click", oldTree.
|
|
150
|
-
|
|
244
|
+
realNode.removeEventListener("click", oldTree.AttachedOnClick);
|
|
245
|
+
(Event) => void wrappedClick = (Event e) => Batching.Run(() => updated.OnClick(e));
|
|
246
|
+
updated.AttachedOnClick = wrappedClick;
|
|
247
|
+
realNode.addEventListener("click", wrappedClick);
|
|
248
|
+
} else {
|
|
249
|
+
updated.AttachedOnClick = oldTree.AttachedOnClick;
|
|
151
250
|
}
|
|
152
251
|
if (updated.OnInput != oldTree.OnInput) {
|
|
153
|
-
realNode.removeEventListener("input", oldTree.
|
|
154
|
-
|
|
252
|
+
realNode.removeEventListener("input", oldTree.AttachedOnInput);
|
|
253
|
+
(Event) => void wrappedInput = (Event e) => Batching.Run(() => updated.OnInput(e));
|
|
254
|
+
updated.AttachedOnInput = wrappedInput;
|
|
255
|
+
realNode.addEventListener("input", wrappedInput);
|
|
256
|
+
} else {
|
|
257
|
+
updated.AttachedOnInput = oldTree.AttachedOnInput;
|
|
155
258
|
}
|
|
156
259
|
if (updated.OnBlur != oldTree.OnBlur) {
|
|
157
|
-
realNode.removeEventListener("blur", oldTree.
|
|
158
|
-
|
|
260
|
+
realNode.removeEventListener("blur", oldTree.AttachedOnBlur);
|
|
261
|
+
(Event) => void wrappedBlur = (Event e) => Batching.Run(() => updated.OnBlur(e));
|
|
262
|
+
updated.AttachedOnBlur = wrappedBlur;
|
|
263
|
+
realNode.addEventListener("blur", wrappedBlur);
|
|
264
|
+
} else {
|
|
265
|
+
updated.AttachedOnBlur = oldTree.AttachedOnBlur;
|
|
159
266
|
}
|
|
160
267
|
if (updated.OnChange != oldTree.OnChange) {
|
|
161
|
-
realNode.removeEventListener("change", oldTree.
|
|
162
|
-
|
|
268
|
+
realNode.removeEventListener("change", oldTree.AttachedOnChange);
|
|
269
|
+
(Event) => void wrappedChange = (Event e) => Batching.Run(() => updated.OnChange(e));
|
|
270
|
+
updated.AttachedOnChange = wrappedChange;
|
|
271
|
+
realNode.addEventListener("change", wrappedChange);
|
|
272
|
+
} else {
|
|
273
|
+
updated.AttachedOnChange = oldTree.AttachedOnChange;
|
|
163
274
|
}
|
|
164
275
|
|
|
165
276
|
return realNode;
|
package/src/velement.js
CHANGED
|
@@ -15,6 +15,10 @@ export class VElement {
|
|
|
15
15
|
this.OnInput = NoOpEventHandler;
|
|
16
16
|
this.OnBlur = NoOpEventHandler;
|
|
17
17
|
this.OnChange = NoOpEventHandler;
|
|
18
|
+
this.AttachedOnClick = NoOpEventHandler;
|
|
19
|
+
this.AttachedOnInput = NoOpEventHandler;
|
|
20
|
+
this.AttachedOnBlur = NoOpEventHandler;
|
|
21
|
+
this.AttachedOnChange = NoOpEventHandler;
|
|
18
22
|
this.ExtraNames = [];
|
|
19
23
|
this.ExtraValues = [];
|
|
20
24
|
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// 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;
|
|
1
|
+
{"version":3,"file":"velement.js","sources":["velement.ks"],"sourcesContent":["using \"./dom\";\n\n// A lightweight, framework-owned description of one DOM element — Render()\n// builds a tree of these instead of real DOM nodes, so Update() can DIFF\n// the new tree against the previous one and patch only what changed,\n// instead of discarding and rebuilding the whole real DOM subtree every\n// time (see src/vdom.ks for the diff/patch engine, src/component.ks for\n// where Render()'s return type changed from Element to VElement).\n//\n// A single shared no-op, not a fresh closure per VElement — every instance\n// that never sets a real handler gets this exact same function reference,\n// so Patch() (vdom.ks) can tell \"no handler either time\" apart from \"a\n// handler changed\" with a plain `!=` reference check instead of always\n// removing/re-adding all four DOM listeners on every patch regardless of\n// whether anything about them actually changed. A fresh `(Event e) => {}`\n// closure per instance would defeat this — two \"empty\" handlers would\n// never compare equal, even when nothing meaningful differs.\nvoid NoOpEventHandler(Event e) {\n}\n\n// Fixed, named fields — not a generic prop bag — because KopScript has no\n// object-literal syntax to build one with. Fixed, named event slots — not\n// an array of handlers — because KopScript has no array-of-function-values\n// type either. Both are real language constraints, not an oversight; see\n// SetAttr below for the escape hatch covering everything not common enough\n// to deserve its own named field.\nclass VElement {\n public string Tag;\n // Mutually exclusive with Children and RawHtml — set at most one of the\n // three. TextContent/Children mirrors the same \"no mixed text/element\n // content\" rule KopScript's own templates already enforce.\n public string TextContent;\n public string ClassName;\n public string Id;\n // The one property patched via direct assignment, never setAttribute —\n // see Element.value's own comment in dom.ks for why (the \"default value\n // attribute\" vs \"current live value property\" DOM footgun — the exact\n // property behind the original typing bug this whole effort traces to).\n public string Value;\n public VElement[] Children;\n // An opaque, undiffed leaf — set instead of TextContent/Children for the\n // existing raw-HTML-then-wire-handlers pattern (header.ks/nav.ks). The\n // patch engine treats two VElements with different RawHtml as a single\n // innerHTML assignment, never recursing inside it — the same \"opaque\n // blob\" treatment `raw string` already gets everywhere else.\n public string RawHtml;\n\n // Each defaults to a real no-op, never null — KopScript has no nullable\n // *function* type to fall back on for \"no handler set\" (see Router.Guard\n // for the same default-real-function pattern already established).\n public (Event) => void OnClick;\n public (Event) => void OnInput;\n public (Event) => void OnBlur;\n public (Event) => void OnChange;\n\n // The REAL function reference the patch engine (vdom.ks) actually passed\n // to addEventListener for this exact real DOM node — never OnClick/\n // OnInput/OnBlur/OnChange themselves. Materialize/Patch wrap each handler\n // in Component.RunInBatch (see component.ks) before attaching it, so the\n // listener genuinely registered isn't the same function value as the one\n // an app author wrote; removeEventListener only ever works when passed\n // the exact reference addEventListener received, so the patch engine\n // needs somewhere to remember it for the swap-when-changed path. Plain\n // data, same as every other field here — only vdom.ks ever reads or\n // writes these.\n public (Event) => void AttachedOnClick;\n public (Event) => void AttachedOnInput;\n public (Event) => void AttachedOnBlur;\n public (Event) => void AttachedOnChange;\n\n // A real HTML attribute not common enough for its own named field (href,\n // src, alt, placeholder, ...) — parallel arrays, since KopScript has no\n // Dictionary type. Never Value (see its own field comment above). Public,\n // read directly by the patch engine (src/vdom.ks) rather than through\n // accessor methods — plain data, same style as this codebase's other\n // plain classes (Note, Dog, ...).\n public string[] ExtraNames;\n public string[] ExtraValues;\n\n // Set only once this VElement has been materialized into (or reused as)\n // a real DOM node — null on a freshly-built tree from a not-yet-patched\n // Render() call. The patch engine reads the *previous* render's tree's\n // RealNode to know what to reuse/patch; it never reads the live DOM back\n // to rediscover this (see vdom.ks's own header comment for why).\n public Element? RealNode;\n\n constructor(string tag) {\n this.Tag = tag;\n this.TextContent = \"\";\n this.ClassName = \"\";\n this.Id = \"\";\n this.Value = \"\";\n this.Children = [];\n this.RawHtml = \"\";\n this.OnClick = NoOpEventHandler;\n this.OnInput = NoOpEventHandler;\n this.OnBlur = NoOpEventHandler;\n this.OnChange = NoOpEventHandler;\n this.AttachedOnClick = NoOpEventHandler;\n this.AttachedOnInput = NoOpEventHandler;\n this.AttachedOnBlur = NoOpEventHandler;\n this.AttachedOnChange = NoOpEventHandler;\n this.ExtraNames = [];\n this.ExtraValues = [];\n this.RealNode = null;\n }\n\n public static VElement Create(string tag) {\n return new VElement(tag);\n }\n\n public void AppendChild(VElement child) {\n this.Children = this.Children.Push(child);\n }\n\n // Last call for a given name wins if SetAttr is called more than once\n // with the same name on one VElement — the patch engine applies\n // ExtraNames/ExtraValues in order, so a later entry's setAttribute call\n // simply overwrites an earlier one for the same name, no special\n // dedup/replace logic needed here.\n public void SetAttr(string name, string value) {\n this.ExtraNames = this.ExtraNames.Push(name);\n this.ExtraValues = this.ExtraValues.Push(value);\n }\n}\n"],"names":[],"mappings":";;AAiBA;;AASA;EA4DE;IACW;IACQ;IACF;IACP;IACG;IACG;IACD;IACA;IACA;IACD;IACE;IACO;IACA;IACD;IACE;IACN;IACC;IACH;;;EAGF;IACZ;;;EAGK;IACS;;;EAQT;IACW;IACC"}
|
package/src/velement.ks
CHANGED
|
@@ -53,6 +53,21 @@ class VElement {
|
|
|
53
53
|
public (Event) => void OnBlur;
|
|
54
54
|
public (Event) => void OnChange;
|
|
55
55
|
|
|
56
|
+
// The REAL function reference the patch engine (vdom.ks) actually passed
|
|
57
|
+
// to addEventListener for this exact real DOM node — never OnClick/
|
|
58
|
+
// OnInput/OnBlur/OnChange themselves. Materialize/Patch wrap each handler
|
|
59
|
+
// in Component.RunInBatch (see component.ks) before attaching it, so the
|
|
60
|
+
// listener genuinely registered isn't the same function value as the one
|
|
61
|
+
// an app author wrote; removeEventListener only ever works when passed
|
|
62
|
+
// the exact reference addEventListener received, so the patch engine
|
|
63
|
+
// needs somewhere to remember it for the swap-when-changed path. Plain
|
|
64
|
+
// data, same as every other field here — only vdom.ks ever reads or
|
|
65
|
+
// writes these.
|
|
66
|
+
public (Event) => void AttachedOnClick;
|
|
67
|
+
public (Event) => void AttachedOnInput;
|
|
68
|
+
public (Event) => void AttachedOnBlur;
|
|
69
|
+
public (Event) => void AttachedOnChange;
|
|
70
|
+
|
|
56
71
|
// A real HTML attribute not common enough for its own named field (href,
|
|
57
72
|
// src, alt, placeholder, ...) — parallel arrays, since KopScript has no
|
|
58
73
|
// Dictionary type. Never Value (see its own field comment above). Public,
|
|
@@ -81,6 +96,10 @@ class VElement {
|
|
|
81
96
|
this.OnInput = NoOpEventHandler;
|
|
82
97
|
this.OnBlur = NoOpEventHandler;
|
|
83
98
|
this.OnChange = NoOpEventHandler;
|
|
99
|
+
this.AttachedOnClick = NoOpEventHandler;
|
|
100
|
+
this.AttachedOnInput = NoOpEventHandler;
|
|
101
|
+
this.AttachedOnBlur = NoOpEventHandler;
|
|
102
|
+
this.AttachedOnChange = NoOpEventHandler;
|
|
84
103
|
this.ExtraNames = [];
|
|
85
104
|
this.ExtraValues = [];
|
|
86
105
|
this.RealNode = null;
|