kopular 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLM.md CHANGED
@@ -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 isn't in the import map itself, but component.js and router.js each
172
- // import it internally via a relative "./dom.js" — Kopular's own compiled
173
- // files reference each other as siblings, so all three have to land in the
174
- // same vendor/kopular/ directory together, not just the two the import map
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 Element Render();
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.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -60,7 +60,7 @@
60
60
  "@types/jsdom": "^30.0.0",
61
61
  "@types/node": "^20.14.0",
62
62
  "jsdom": "^25.0.1",
63
- "kopscript": "^0.16.0",
63
+ "kopscript": "^0.19.1",
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;
@@ -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;;;EAWR;IACR;MACE;;IAEF;IACA;IACU;IACM"}
1
+ {"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\nusing \"./vdom\";\n\n// A minimal component base: subclasses override Render() to build a\n// VElement tree describing the current state, and call the inherited\n// Update() whenever that state changes to re-render. Update() DIFFS the\n// new VElement tree against the previous one (see vdom.ks's Patch) and\n// patches only what changed, reusing real DOM nodes wherever their tag\n// stays the same — replacing a whole subtree is now the exception (a\n// changed tag, or no previous tree at all), not the default on every\n// re-render the way it used to be.\n//\n// 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
- el.addEventListener("click", tree.OnClick);
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
- el.addEventListener("input", tree.OnInput);
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
- el.addEventListener("blur", tree.OnBlur);
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
- el.addEventListener("change", tree.OnChange);
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.OnClick);
78
- realNode.addEventListener("click", updated.OnClick);
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.OnInput);
82
- realNode.addEventListener("input", updated.OnInput);
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.OnBlur);
86
- realNode.addEventListener("blur", updated.OnBlur);
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.OnChange);
90
- realNode.addEventListener("change", updated.OnChange);
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
- 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); }
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.OnClick);
150
- realNode.addEventListener("click", updated.OnClick);
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.OnInput);
154
- realNode.addEventListener("input", updated.OnInput);
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.OnBlur);
158
- realNode.addEventListener("blur", updated.OnBlur);
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.OnChange);
162
- realNode.addEventListener("change", updated.OnChange);
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;
@@ -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;EA6CE;IACW;IACQ;IACF;IACP;IACG;IACG;IACD;IACA;IACA;IACD;IACE;IACE;IACC;IACH;;;EAGF;IACZ;;;EAGK;IACS;;;EAQT;IACW;IACC"}
1
+ {"version":3,"file":"velement.js","sources":["velement.ks"],"sourcesContent":["using \"./dom\";\n\n// A lightweight, framework-owned description of one DOM element — Render()\n// builds a tree of these instead of real DOM nodes, so Update() can DIFF\n// the new tree against the previous one and patch only what changed,\n// instead of discarding and rebuilding the whole real DOM subtree every\n// time (see src/vdom.ks for the diff/patch engine, src/component.ks for\n// where Render()'s return type changed from Element to VElement).\n//\n// A single shared no-op, not a fresh closure per VElement — every instance\n// that never sets a real handler gets this exact same function reference,\n// so Patch() (vdom.ks) can tell \"no handler either time\" apart from \"a\n// handler changed\" with a plain `!=` reference check instead of always\n// removing/re-adding all four DOM listeners on every patch regardless of\n// whether anything about them actually changed. A fresh `(Event e) => {}`\n// closure per instance would defeat this — two \"empty\" handlers would\n// never compare equal, even when nothing meaningful differs.\nvoid NoOpEventHandler(Event e) {\n}\n\n// 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;