@praxisjs/runtime 0.7.1 → 0.8.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/children.d.ts.map +1 -1
  3. package/dist/children.js +2 -1
  4. package/dist/children.js.map +1 -1
  5. package/dist/component.d.ts +2 -3
  6. package/dist/component.d.ts.map +1 -1
  7. package/dist/component.js +133 -60
  8. package/dist/component.js.map +1 -1
  9. package/dist/context.d.ts +13 -0
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js +24 -2
  12. package/dist/context.js.map +1 -1
  13. package/dist/dom/constants.d.ts +1 -1
  14. package/dist/dom/constants.d.ts.map +1 -1
  15. package/dist/dom/constants.js +24 -83
  16. package/dist/dom/constants.js.map +1 -1
  17. package/dist/dom/create.d.ts +1 -1
  18. package/dist/dom/create.d.ts.map +1 -1
  19. package/dist/dom/create.js +3 -3
  20. package/dist/dom/create.js.map +1 -1
  21. package/dist/dom/events.d.ts +28 -0
  22. package/dist/dom/events.d.ts.map +1 -1
  23. package/dist/dom/events.js +163 -17
  24. package/dist/dom/events.js.map +1 -1
  25. package/dist/dom/props.d.ts +4 -0
  26. package/dist/dom/props.d.ts.map +1 -1
  27. package/dist/dom/props.js +61 -8
  28. package/dist/dom/props.js.map +1 -1
  29. package/dist/dom/range.d.ts +4 -0
  30. package/dist/dom/range.d.ts.map +1 -0
  31. package/dist/dom/range.js +43 -0
  32. package/dist/dom/range.js.map +1 -0
  33. package/dist/dom/remove.d.ts.map +1 -1
  34. package/dist/dom/remove.js +2 -8
  35. package/dist/dom/remove.js.map +1 -1
  36. package/dist/element.d.ts.map +1 -1
  37. package/dist/element.js +7 -5
  38. package/dist/element.js.map +1 -1
  39. package/dist/hmr.d.ts +35 -0
  40. package/dist/hmr.d.ts.map +1 -0
  41. package/dist/hmr.js +85 -0
  42. package/dist/hmr.js.map +1 -0
  43. package/dist/hydration-context.d.ts +9 -1
  44. package/dist/hydration-context.d.ts.map +1 -1
  45. package/dist/hydration-context.js +34 -13
  46. package/dist/hydration-context.js.map +1 -1
  47. package/dist/hydration.d.ts.map +1 -1
  48. package/dist/hydration.js +4 -11
  49. package/dist/hydration.js.map +1 -1
  50. package/dist/index.d.ts +16 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +31 -7
  53. package/dist/index.js.map +1 -1
  54. package/dist/list.d.ts +72 -0
  55. package/dist/list.d.ts.map +1 -0
  56. package/dist/list.js +786 -0
  57. package/dist/list.js.map +1 -0
  58. package/dist/portal.d.ts.map +1 -1
  59. package/dist/portal.js +3 -0
  60. package/dist/portal.js.map +1 -1
  61. package/dist/reactive.d.ts +2 -2
  62. package/dist/reactive.d.ts.map +1 -1
  63. package/dist/reactive.js +109 -34
  64. package/dist/reactive.js.map +1 -1
  65. package/dist/scope.d.ts +14 -4
  66. package/dist/scope.d.ts.map +1 -1
  67. package/dist/scope.js +55 -15
  68. package/dist/scope.js.map +1 -1
  69. package/dist/template.d.ts +48 -0
  70. package/dist/template.d.ts.map +1 -0
  71. package/dist/template.js +270 -0
  72. package/dist/template.js.map +1 -0
  73. package/package.json +15 -6
  74. package/src/children.ts +2 -1
  75. package/src/component.ts +135 -63
  76. package/src/context.ts +35 -4
  77. package/src/dom/constants.ts +25 -82
  78. package/src/dom/create.ts +3 -3
  79. package/src/dom/events.ts +184 -16
  80. package/src/dom/props.ts +70 -9
  81. package/src/dom/range.ts +45 -0
  82. package/src/dom/remove.ts +3 -7
  83. package/src/element.ts +8 -6
  84. package/src/hmr.ts +110 -0
  85. package/src/hydration-context.ts +34 -13
  86. package/src/hydration.ts +4 -9
  87. package/src/index.ts +54 -7
  88. package/src/list.ts +886 -0
  89. package/src/portal.ts +3 -0
  90. package/src/reactive.ts +147 -40
  91. package/src/scope.ts +63 -16
  92. package/src/template.ts +271 -0
  93. package/dist/__tests__/children.test.d.ts +0 -2
  94. package/dist/__tests__/children.test.d.ts.map +0 -1
  95. package/dist/__tests__/children.test.js +0 -241
  96. package/dist/__tests__/children.test.js.map +0 -1
  97. package/dist/__tests__/component.test.d.ts +0 -2
  98. package/dist/__tests__/component.test.d.ts.map +0 -1
  99. package/dist/__tests__/component.test.js +0 -347
  100. package/dist/__tests__/component.test.js.map +0 -1
  101. package/dist/__tests__/context.test.d.ts +0 -2
  102. package/dist/__tests__/context.test.d.ts.map +0 -1
  103. package/dist/__tests__/context.test.js +0 -111
  104. package/dist/__tests__/context.test.js.map +0 -1
  105. package/dist/__tests__/dom.test.d.ts +0 -2
  106. package/dist/__tests__/dom.test.d.ts.map +0 -1
  107. package/dist/__tests__/dom.test.js +0 -429
  108. package/dist/__tests__/dom.test.js.map +0 -1
  109. package/dist/__tests__/hydration.test.d.ts +0 -2
  110. package/dist/__tests__/hydration.test.d.ts.map +0 -1
  111. package/dist/__tests__/hydration.test.js +0 -299
  112. package/dist/__tests__/hydration.test.js.map +0 -1
  113. package/dist/__tests__/portal.test.d.ts +0 -2
  114. package/dist/__tests__/portal.test.d.ts.map +0 -1
  115. package/dist/__tests__/portal.test.js +0 -157
  116. package/dist/__tests__/portal.test.js.map +0 -1
  117. package/dist/__tests__/remove.test.d.ts +0 -2
  118. package/dist/__tests__/remove.test.d.ts.map +0 -1
  119. package/dist/__tests__/remove.test.js +0 -45
  120. package/dist/__tests__/remove.test.js.map +0 -1
  121. package/dist/__tests__/render.test.d.ts +0 -2
  122. package/dist/__tests__/render.test.d.ts.map +0 -1
  123. package/dist/__tests__/render.test.js +0 -97
  124. package/dist/__tests__/render.test.js.map +0 -1
  125. package/dist/__tests__/scope.test.d.ts +0 -2
  126. package/dist/__tests__/scope.test.d.ts.map +0 -1
  127. package/dist/__tests__/scope.test.js +0 -110
  128. package/dist/__tests__/scope.test.js.map +0 -1
  129. package/src/__tests__/children.test.ts +0 -276
  130. package/src/__tests__/component.test.ts +0 -381
  131. package/src/__tests__/context.test.ts +0 -128
  132. package/src/__tests__/dom.test.ts +0 -483
  133. package/src/__tests__/hydration.test.ts +0 -360
  134. package/src/__tests__/portal.test.ts +0 -177
  135. package/src/__tests__/remove.test.ts +0 -51
  136. package/src/__tests__/render.test.ts +0 -119
  137. package/src/__tests__/scope.test.ts +0 -120
  138. package/tsconfig.json +0 -8
@@ -29,99 +29,42 @@ export const SVG_TAGS = new Set([
29
29
  "foreignObject",
30
30
  ]);
31
31
 
32
- export const EVENT_MAP: Record<string, string> = {
32
+ // Event props and the DOM event each one listens to. Every prop is "on" + the event name in camel
33
+ // case and listens to that name in lowercase, so only the camel-case names are spelled out, which
34
+ // keeps the table small in bundles. A Map: looking a prop up is a single probe.
35
+ export const EVENT_MAP: ReadonlyMap<string, string> = /* @__PURE__ */ eventMap(
33
36
  // Mouse
34
- onClick: "click",
35
- onDblClick: "dblclick",
36
- onMouseDown: "mousedown",
37
- onMouseUp: "mouseup",
38
- onMouseEnter: "mouseenter",
39
- onMouseLeave: "mouseleave",
40
- onMouseMove: "mousemove",
41
- onMouseOver: "mouseover",
42
- onMouseOut: "mouseout",
43
- onContextMenu: "contextmenu",
37
+ "Click DblClick MouseDown MouseUp MouseEnter MouseLeave MouseMove MouseOver MouseOut ContextMenu " +
44
38
  // Keyboard
45
- onKeyDown: "keydown",
46
- onKeyUp: "keyup",
47
- onKeyPress: "keypress",
39
+ "KeyDown KeyUp KeyPress " +
48
40
  // Focus
49
- onFocus: "focus",
50
- onBlur: "blur",
41
+ "Focus Blur " +
51
42
  // Pointer
52
- onPointerDown: "pointerdown",
53
- onPointerUp: "pointerup",
54
- onPointerMove: "pointermove",
55
- onPointerEnter: "pointerenter",
56
- onPointerLeave: "pointerleave",
57
- onPointerOver: "pointerover",
58
- onPointerOut: "pointerout",
59
- onPointerCancel: "pointercancel",
60
- onGotPointerCapture: "gotpointercapture",
61
- onLostPointerCapture: "lostpointercapture",
43
+ "PointerDown PointerUp PointerMove PointerEnter PointerLeave PointerOver PointerOut PointerCancel " +
44
+ "GotPointerCapture LostPointerCapture " +
62
45
  // Touch
63
- onTouchStart: "touchstart",
64
- onTouchEnd: "touchend",
65
- onTouchMove: "touchmove",
46
+ "TouchStart TouchEnd TouchMove " +
66
47
  // Drag
67
- onDrag: "drag",
68
- onDragStart: "dragstart",
69
- onDragEnd: "dragend",
70
- onDragEnter: "dragenter",
71
- onDragLeave: "dragleave",
72
- onDragOver: "dragover",
73
- onDrop: "drop",
48
+ "Drag DragStart DragEnd DragEnter DragLeave DragOver Drop " +
74
49
  // Scroll / Wheel
75
- onScroll: "scroll",
76
- onScrollEnd: "scrollend",
77
- onWheel: "wheel",
50
+ "Scroll ScrollEnd Wheel " +
78
51
  // Form
79
- onChange: "change",
80
- onInput: "input",
81
- onBeforeInput: "beforeinput",
82
- onSubmit: "submit",
83
- onReset: "reset",
84
- onSelect: "select",
85
- onInvalid: "invalid",
52
+ "Change Input BeforeInput Submit Reset Select Invalid " +
86
53
  // Clipboard
87
- onCopy: "copy",
88
- onCut: "cut",
89
- onPaste: "paste",
54
+ "Copy Cut Paste " +
90
55
  // Composition (IME)
91
- onCompositionStart: "compositionstart",
92
- onCompositionUpdate: "compositionupdate",
93
- onCompositionEnd: "compositionend",
56
+ "CompositionStart CompositionUpdate CompositionEnd " +
94
57
  // Resource
95
- onLoad: "load",
96
- onError: "error",
58
+ "Load Error " +
97
59
  // Animation / Transition
98
- onAnimationEnd: "animationend",
99
- onTransitionEnd: "transitionend",
60
+ "AnimationEnd TransitionEnd " +
100
61
  // Element-specific (details, dialog, popover)
101
- onToggle: "toggle",
102
- onBeforeToggle: "beforetoggle",
103
- onClose: "close",
104
- onCancel: "cancel",
62
+ "Toggle BeforeToggle Close Cancel " +
105
63
  // Media
106
- onAbort: "abort",
107
- onCanPlay: "canplay",
108
- onCanPlayThrough: "canplaythrough",
109
- onDurationChange: "durationchange",
110
- onEmptied: "emptied",
111
- onEnded: "ended",
112
- onLoadedData: "loadeddata",
113
- onLoadedMetadata: "loadedmetadata",
114
- onLoadStart: "loadstart",
115
- onPause: "pause",
116
- onPlay: "play",
117
- onPlaying: "playing",
118
- onProgress: "progress",
119
- onRateChange: "ratechange",
120
- onSeeked: "seeked",
121
- onSeeking: "seeking",
122
- onStalled: "stalled",
123
- onSuspend: "suspend",
124
- onTimeUpdate: "timeupdate",
125
- onVolumeChange: "volumechange",
126
- onWaiting: "waiting",
127
- };
64
+ "Abort CanPlay CanPlayThrough DurationChange Emptied Ended LoadedData LoadedMetadata LoadStart Pause Play " +
65
+ "Playing Progress RateChange Seeked Seeking Stalled Suspend TimeUpdate VolumeChange Waiting",
66
+ );
67
+
68
+ function eventMap(names: string): Map<string, string> {
69
+ return new Map(names.split(" ").map((name) => [`on${name}`, name.toLowerCase()]));
70
+ }
package/src/dom/create.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { SVG_NAMESPACE, SVG_TAGS } from "./constants";
2
2
 
3
- export function createElement(tag: string): HTMLElement | SVGElement {
3
+ export function createElement(tag: string, doc: Document = document): HTMLElement | SVGElement {
4
4
  return SVG_TAGS.has(tag)
5
- ? (document.createElementNS(SVG_NAMESPACE, tag))
6
- : document.createElement(tag);
5
+ ? (doc.createElementNS(SVG_NAMESPACE, tag))
6
+ : doc.createElement(tag);
7
7
  }
package/src/dom/events.ts CHANGED
@@ -1,21 +1,34 @@
1
- import { batch } from "@praxisjs/core/internal";
1
+ import { endBatch, startBatch } from "@praxisjs/core/internal";
2
+
3
+ import { EVENT_MAP } from "./constants";
2
4
 
3
5
  import type { Scope } from "../scope";
4
6
 
5
- // One wrapper per handler keeps addEventListener's dedupe of an identical listener intact.
6
- const batchedHandlers = new WeakMap<EventListener, EventListener>();
7
+ type Handler = (this: Element, event: Event) => unknown;
8
+ type HandlerSlot = Handler | Handler[];
9
+
10
+ const HANDLERS = Symbol("praxis.handlers");
11
+
12
+ interface HandlerHost {
13
+ [HANDLERS]?: Record<string, HandlerSlot | undefined>;
14
+ }
7
15
 
8
- // Handlers run inside batch() so several signal writes in one event flush their effects once,
9
- // instead of re-running the same reactive subtree after every write.
10
- function batched(handler: EventListener): EventListener {
11
- let wrapper = batchedHandlers.get(handler);
12
- if (!wrapper) {
13
- wrapper = function (this: Element, event) {
14
- batch(() => { handler.call(this, event); });
15
- };
16
- batchedHandlers.set(handler, wrapper);
16
+ // One shared listener per element and event type dispatches to the handlers stored on the
17
+ // element, so binding a handler allocates no wrapper. Handlers run inside a batch so several
18
+ // signal writes in one event flush their effects once.
19
+ function dispatch(this: Element, event: Event): void {
20
+ const slot = (this as HandlerHost)[HANDLERS]?.[event.type];
21
+ if (slot === undefined) return;
22
+ startBatch();
23
+ try {
24
+ if (typeof slot === "function") {
25
+ slot.call(this, event);
26
+ } else {
27
+ for (const handler of slot.slice()) handler.call(this, event);
28
+ }
29
+ } finally {
30
+ endBatch();
17
31
  }
18
- return wrapper;
19
32
  }
20
33
 
21
34
  export function addEvent(
@@ -27,7 +40,162 @@ export function addEvent(
27
40
  // Components forward optional handlers (`onSubmit={this.onSubmit}`) as-is, and addEventListener
28
41
  // has always treated a missing listener as a no-op.
29
42
  if (handler == null) return;
30
- const listener = typeof handler === "function" ? batched(handler) : handler;
31
- el.addEventListener(eventName, listener);
32
- scope.add(() => { el.removeEventListener(eventName, listener); });
43
+ if (typeof handler !== "function") {
44
+ el.addEventListener(eventName, handler);
45
+ scope.add(() => { el.removeEventListener(eventName, handler); });
46
+ return;
47
+ }
48
+ const host = el as HandlerHost;
49
+ const handlers = (host[HANDLERS] ??= {});
50
+ const slot = handlers[eventName];
51
+ if (slot === undefined) {
52
+ // `in` tells a slot emptied by a disposed scope (listener still attached) from a new event type.
53
+ if (!(eventName in handlers)) el.addEventListener(eventName, dispatch);
54
+ handlers[eventName] = handler as Handler;
55
+ } else if (typeof slot === "function") {
56
+ // Same as addEventListener: registering an identical listener twice is a no-op.
57
+ if (slot === handler) return;
58
+ handlers[eventName] = [slot, handler as Handler];
59
+ } else {
60
+ if (slot.includes(handler as Handler)) return;
61
+ slot.push(handler as Handler);
62
+ }
63
+ scope.listen(el, eventName, handler);
64
+ }
65
+
66
+ // ── Delegated binding (compiled templates) ──────────────────────────────────
67
+
68
+ // A delegated handler is stored on its element under a per-type key, together with the scope that
69
+ // bound it and that scope's generation: disposing the scope bumps the generation, which turns the
70
+ // handler off without any per-handler cleanup. No listener is attached when it is bound. One capture
71
+ // listener per event type, on the document and on every render container, attaches the element's own
72
+ // listener the first time an event of that type is on its way to it. Listeners are read when the
73
+ // event reaches each node, so the new one runs for that very event, at its own element and phase:
74
+ // order, `this`, `currentTarget` and `stopPropagation()` behave exactly as with a listener attached
75
+ // at render time. Render containers cover trees that are not in the document.
76
+
77
+ type DelegatedHost = Record<symbol, unknown>;
78
+
79
+ const OWNER = Symbol("praxis.owner");
80
+ const GENERATION = Symbol("praxis.generation");
81
+ export interface DelegatedType {
82
+ /** The event prop (`onClick`), under which a hydration build pass records the handler. */
83
+ key: string;
84
+ type: string;
85
+ /** Holds an element's handler for this type. */
86
+ slot: symbol;
87
+ /** Set once the element's own listener for this type is attached. */
88
+ attached: symbol;
89
+ /** The capture listeners for this type are registered. */
90
+ watched: boolean;
91
+ }
92
+
93
+ // Every event prop maps to its own event type (EVENT_MAP has no two props for one type), so an
94
+ // entry is created once per prop and found by type when an event comes in.
95
+ const byType = new Map<string, DelegatedType>();
96
+ // Keyed by event prop name (`onClick`), so binding a handler costs one lookup. `null` marks a prop
97
+ // that is not an event.
98
+ const byProp = new Map<string, DelegatedType | null>();
99
+ const watched: DelegatedType[] = [];
100
+ const roots = new Set<EventTarget>();
101
+
102
+ /** The delegated event type an event prop binds, or null when `key` is not an event prop. */
103
+ export function delegatedProp(key: string): DelegatedType | null {
104
+ let entry = byProp.get(key);
105
+ if (entry === undefined) {
106
+ const type = EVENT_MAP.get(key);
107
+ entry = null;
108
+ if (type !== undefined) {
109
+ entry = { key, type, slot: Symbol(`praxis.on.${type}`), attached: Symbol(`praxis.attached.${type}`), watched: false };
110
+ byType.set(type, entry);
111
+ }
112
+ byProp.set(key, entry);
113
+ }
114
+ return entry;
115
+ }
116
+
117
+ function dispatchDelegated(this: Element, event: Event): void {
118
+ const host = this as unknown as DelegatedHost;
119
+ const handler = host[(byType.get(event.type) as DelegatedType).slot] as Handler | undefined;
120
+ if (handler === undefined || (host[OWNER] as { gen: number }).gen !== host[GENERATION]) return;
121
+ startBatch();
122
+ try {
123
+ handler.call(this, event);
124
+ } finally {
125
+ endBatch();
126
+ }
127
+ }
128
+
129
+ function attachOnDemand(this: EventTarget, event: Event): void {
130
+ const { type, slot, attached } = byType.get(event.type) as DelegatedType;
131
+ for (const node of event.composedPath()) {
132
+ if (node === this) return;
133
+ const host = node as unknown as DelegatedHost;
134
+ if (host[slot] !== undefined && host[attached] === undefined) {
135
+ host[attached] = true;
136
+ node.addEventListener(type, dispatchDelegated);
137
+ }
138
+ }
139
+ }
140
+
141
+ function watchType(entry: DelegatedType): void {
142
+ entry.watched = true;
143
+ watched.push(entry);
144
+ document.addEventListener(entry.type, attachOnDemand, true);
145
+ for (const root of roots) root.addEventListener(entry.type, attachOnDemand, true);
146
+ }
147
+
148
+ /**
149
+ * Registers a render container (or portal target) whose subtree gets delegated handlers even while
150
+ * it is not in the document. Returns the function that unregisters it.
151
+ */
152
+ export function addEventRoot(root: EventTarget): () => void {
153
+ roots.add(root);
154
+ for (const entry of watched) root.addEventListener(entry.type, attachOnDemand, true);
155
+ return () => {
156
+ roots.delete(root);
157
+ for (const entry of watched) root.removeEventListener(entry.type, attachOnDemand, true);
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Binds `handler` for the event of `entry` (see delegatedProp) on `el` without attaching a
163
+ * listener now (see above). Falls back to addEvent when no render container is registered (an
164
+ * element mounted outside render()), when the element already has a delegated handler for this
165
+ * type, or when it was bound by another scope.
166
+ */
167
+ export function addDelegatedEvent(el: Element, entry: DelegatedType, handler: unknown, scope: Scope & { gen: number }): void {
168
+ if (handler == null) return;
169
+ const host = el as unknown as DelegatedHost;
170
+ const key = entry.slot;
171
+ if (
172
+ roots.size === 0 ||
173
+ typeof handler !== "function" ||
174
+ host[key] !== undefined ||
175
+ (host[OWNER] !== undefined && host[OWNER] !== scope)
176
+ ) {
177
+ addEvent(el, entry.type, handler as EventListener, scope);
178
+ return;
179
+ }
180
+ if (!entry.watched) watchType(entry);
181
+ host[key] = handler;
182
+ host[OWNER] = scope;
183
+ host[GENERATION] = scope.gen;
184
+ }
185
+
186
+ export function removeListener(el: Element, eventName: string, handler: unknown): void {
187
+ const handlers = (el as HandlerHost)[HANDLERS];
188
+ const slot = handlers?.[eventName];
189
+ if (handlers === undefined || slot === undefined) return;
190
+ if (typeof slot === "function") {
191
+ if (slot !== handler) return;
192
+ // The shared listener stays registered: with an empty slot it does nothing, and leaving it
193
+ // saves a native call per handler when thousands of rows are disposed at once.
194
+ handlers[eventName] = undefined;
195
+ return;
196
+ }
197
+ const index = slot.indexOf(handler as Handler);
198
+ if (index === -1) return;
199
+ slot.splice(index, 1);
200
+ if (slot.length === 1) handlers[eventName] = slot[0];
33
201
  }
package/src/dom/props.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { untrack } from "@praxisjs/core/internal";
1
+ import { untrack, type EffectNode } from "@praxisjs/core/internal";
2
2
 
3
3
  import { EVENT_MAP } from "./constants";
4
4
  import { addEvent } from "./events";
@@ -8,7 +8,15 @@ import type { Scope } from "../scope";
8
8
  function applyClass(el: Element, value: unknown): void {
9
9
  if (value === null || value === undefined) {
10
10
  el.removeAttribute("class");
11
+ } else if (el instanceof HTMLElement) {
12
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
13
+ const next = String(value);
14
+ // Reading is cheap; writing invalidates the element's style even when nothing changes, which
15
+ // is what a first binding run does on every element whose class starts empty.
16
+ if (el.className !== next) el.className = next;
11
17
  } else {
18
+ // SVG elements expose className as an SVGAnimatedString, so the attribute is the way in (as
19
+ // for any element of another kind, or of another window).
12
20
  // eslint-disable-next-line @typescript-eslint/no-base-to-string
13
21
  el.setAttribute("class", String(value));
14
22
  }
@@ -34,7 +42,11 @@ function applyStyle(el: Element, value: unknown): void {
34
42
  }
35
43
 
36
44
  function applyAttr(el: Element, key: string, value: unknown): void {
37
- if (value === false || value === null || value === undefined) {
45
+ if (typeof value === "boolean" && key.startsWith("aria-")) {
46
+ // ARIA states are the strings "true" and "false", not HTML boolean attributes: an empty or
47
+ // missing aria-selected/aria-expanded reads as unset to assistive technology.
48
+ el.setAttribute(key, value ? "true" : "false");
49
+ } else if (value === false || value === null || value === undefined) {
38
50
  el.removeAttribute(key);
39
51
  } else if (value === true) {
40
52
  el.setAttribute(key, "");
@@ -62,7 +74,7 @@ function lookupWritable(proto: object, key: string): boolean {
62
74
  // must be excluded here or assignment below throws. The prototype walk is cached per
63
75
  // prototype because every element of a given tag repeats the same lookups; a property defined on
64
76
  // a prototype after its first lookup (e.g. a late polyfill) is therefore not picked up.
65
- function hasWritableProperty(el: Element, key: string): boolean {
77
+ export function hasWritableProperty(el: Element, key: string): boolean {
66
78
  const own = Object.getOwnPropertyDescriptor(el, key);
67
79
  if (own) return own.set !== undefined || own.writable === true;
68
80
 
@@ -80,7 +92,7 @@ function hasWritableProperty(el: Element, key: string): boolean {
80
92
  return writable;
81
93
  }
82
94
 
83
- function setProp(el: Element, key: string, value: unknown): void {
95
+ export function setProp(el: Element, key: string, value: unknown): void {
84
96
  if (key === "class" || key === "className") {
85
97
  applyClass(el, value);
86
98
  } else if (key === "style") {
@@ -110,17 +122,66 @@ export function applyProp(
110
122
  return;
111
123
  }
112
124
 
113
- if (normalizedKey in EVENT_MAP) {
114
- addEvent(el, EVENT_MAP[normalizedKey], value as EventListener, scope);
125
+ // Every event prop starts with "on": checking that first keeps other props off the map lookup.
126
+ const type = normalizedKey.startsWith("on") ? EVENT_MAP.get(normalizedKey) : undefined;
127
+ if (type !== undefined) {
128
+ addEvent(el, type, value as EventListener, scope);
115
129
  return;
116
130
  }
117
131
 
118
132
  if (typeof value === "function") {
119
- scope.effect(() => {
120
- setProp(el, normalizedKey, (value as () => unknown)());
121
- });
133
+ bindProp(el, normalizedKey, value as () => unknown, scope);
122
134
  return;
123
135
  }
124
136
 
125
137
  setProp(el, normalizedKey, value);
126
138
  }
139
+
140
+ // State the user can change behind a binding's back (typing, toggling a <details>, the media
141
+ // controls, scrolling): always written, so a re-run puts the bound value back exactly as before.
142
+ const LIVE_PROPS = new Set([
143
+ "value", "checked", "selected", "indeterminate", "selectedIndex", "open",
144
+ "muted", "volume", "currentTime", "playbackRate", "scrollTop", "scrollLeft",
145
+ ]);
146
+ const UNSET = Symbol("unset");
147
+
148
+ interface PropBinding {
149
+ el: Element;
150
+ key: string;
151
+ read: () => unknown;
152
+ last: unknown;
153
+ }
154
+
155
+ // One function for every binding, reading its state from the effect: no closure per binding.
156
+ function runBinding(this: EffectNode<PropBinding>): void {
157
+ const binding = this.ctx;
158
+ const value = binding.read();
159
+ // A binding re-runs when anything it read changes; most re-runs produce the same primitive,
160
+ // and skipping that write saves a DOM call and the style or attribute work behind it.
161
+ if (value === binding.last && (typeof value !== "object" || value === null)) return;
162
+ binding.last = value;
163
+ setProp(binding.el, binding.key, value);
164
+ }
165
+
166
+ function runLiveBinding(this: EffectNode<PropBinding>): void {
167
+ const binding = this.ctx;
168
+ setProp(binding.el, binding.key, binding.read());
169
+ }
170
+
171
+ function bindProp(el: Element, key: string, read: () => unknown, scope: Scope): void {
172
+ scope.contextEffect(LIVE_PROPS.has(key) ? runLiveBinding : runBinding, { el, key, read, last: UNSET });
173
+ }
174
+
175
+ function runClassBinding(this: EffectNode<PropBinding>): void {
176
+ const binding = this.ctx;
177
+ const value = binding.read();
178
+ if (value === binding.last && (typeof value !== "object" || value === null)) return;
179
+ binding.last = value;
180
+ applyClass(binding.el, value);
181
+ }
182
+
183
+ /** A reactive `class` prop, bound like any other reactive prop but with no key dispatch per run. */
184
+ export function bindClass(el: Element, read: () => unknown, scope: Scope): void {
185
+ // Same record shape as bindProp's, so the effects of both kinds stay one shape.
186
+ scope.contextEffect(runClassBinding, { el, key: "class", read, last: UNSET });
187
+ }
@@ -0,0 +1,45 @@
1
+ type MovableParent = Node & {
2
+ moveBefore?: (node: Node, child: Node | null) => void;
3
+ };
4
+
5
+ // moveBefore() keeps focus, iframes, playing media and running CSS animations/transitions of the
6
+ // moved subtree, which a remove + insert resets. It only applies inside a connected tree (both nodes
7
+ // already share the parent here), so a detached parent such as a DocumentFragment uses insertBefore.
8
+ export function moveNode(parent: Node, node: Node, ref: Node | null): void {
9
+ const target = parent as MovableParent;
10
+ if (target.moveBefore !== undefined && target.isConnected) {
11
+ target.moveBefore(node, ref);
12
+ } else {
13
+ parent.insertBefore(node, ref);
14
+ }
15
+ }
16
+
17
+ // Every node from `first` to `last` (inclusive) moves, not only the tracked ones, so anything a
18
+ // nested reactive child inserted between them after mount travels with them.
19
+ export function moveRange(parent: Node, first: Node, last: Node, ref: Node | null): void {
20
+ if (first === last) {
21
+ moveNode(parent, first, ref);
22
+ return;
23
+ }
24
+ let node: Node = first;
25
+ for (;;) {
26
+ const next = node.nextSibling as Node;
27
+ moveNode(parent, node, ref);
28
+ if (node === last) return;
29
+ node = next;
30
+ }
31
+ }
32
+
33
+ export function removeRange(parent: Node, first: Node, last: Node): void {
34
+ if (first === last) {
35
+ parent.removeChild(first);
36
+ return;
37
+ }
38
+ let node: Node = first;
39
+ for (;;) {
40
+ const next = node.nextSibling as Node;
41
+ parent.removeChild(node);
42
+ if (node === last) return;
43
+ node = next;
44
+ }
45
+ }
package/src/dom/remove.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { removeRange } from "./range";
2
+
1
3
  // Replaces Range.deleteContents(): creating a Range per removal is several times slower than
2
4
  // walking the siblings, and unreachable Ranges stay registered with the document until collected.
3
5
  // Walking first → last (rather than only the tracked nodes) also removes anything a nested reactive
@@ -12,11 +14,5 @@ export function removeNodes(nodes: Node[]): void {
12
14
  return;
13
15
  }
14
16
 
15
- let node: Node | null = first;
16
- while (node) {
17
- const next: Node | null = node.nextSibling;
18
- parent.removeChild(node);
19
- if (node === last) return;
20
- node = next;
21
- }
17
+ removeRange(parent, first, last);
22
18
  }
package/src/element.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mountChildren } from "./children";
2
2
  import { createElement } from "./dom/create";
3
3
  import { applyProp } from "./dom/props";
4
- import { isRecording, recordProps } from "./hydration-context";
4
+ import { recording, recordProps } from "./hydration-context";
5
5
 
6
6
  import type { Scope } from "./scope";
7
7
 
@@ -14,16 +14,18 @@ export function mountElement(
14
14
 
15
15
  // During a hydration build pass, retain (props, scope) so reconcile() can
16
16
  // replay them onto whichever real element it ends up keeping instead.
17
- if (isRecording()) recordProps(el, props, scope);
17
+ if (recording) recordProps(el, props, scope);
18
+
19
+ // Children before props: a prop that depends on them (a <select>'s value picking one of its
20
+ // options, a ref reading the subtree) would otherwise run against an empty element.
21
+ if (props.children !== undefined) {
22
+ mountChildren(el, props.children, scope);
23
+ }
18
24
 
19
25
  for (const key in props) {
20
26
  if (key === "children") continue;
21
27
  applyProp(el, key, props[key], scope);
22
28
  }
23
29
 
24
- if (props.children !== undefined) {
25
- mountChildren(el, props.children, scope);
26
- }
27
-
28
30
  return el;
29
31
  }