@praxisjs/runtime 0.7.0 → 0.8.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/CHANGELOG.md +39 -0
- package/dist/children.d.ts.map +1 -1
- package/dist/children.js +2 -1
- package/dist/children.js.map +1 -1
- package/dist/component.d.ts +2 -3
- package/dist/component.d.ts.map +1 -1
- package/dist/component.js +133 -60
- package/dist/component.js.map +1 -1
- package/dist/context.d.ts +13 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +24 -2
- package/dist/context.js.map +1 -1
- package/dist/dom/constants.d.ts +1 -1
- package/dist/dom/constants.d.ts.map +1 -1
- package/dist/dom/constants.js +24 -83
- package/dist/dom/constants.js.map +1 -1
- package/dist/dom/create.d.ts +1 -1
- package/dist/dom/create.d.ts.map +1 -1
- package/dist/dom/create.js +3 -3
- package/dist/dom/create.js.map +1 -1
- package/dist/dom/events.d.ts +29 -1
- package/dist/dom/events.d.ts.map +1 -1
- package/dist/dom/events.js +167 -17
- package/dist/dom/events.js.map +1 -1
- package/dist/dom/props.d.ts +4 -0
- package/dist/dom/props.d.ts.map +1 -1
- package/dist/dom/props.js +57 -8
- package/dist/dom/props.js.map +1 -1
- package/dist/dom/range.d.ts +4 -0
- package/dist/dom/range.d.ts.map +1 -0
- package/dist/dom/range.js +43 -0
- package/dist/dom/range.js.map +1 -0
- package/dist/dom/remove.d.ts.map +1 -1
- package/dist/dom/remove.js +2 -8
- package/dist/dom/remove.js.map +1 -1
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +7 -5
- package/dist/element.js.map +1 -1
- package/dist/hmr.d.ts +35 -0
- package/dist/hmr.d.ts.map +1 -0
- package/dist/hmr.js +85 -0
- package/dist/hmr.js.map +1 -0
- package/dist/hydration-context.d.ts +9 -1
- package/dist/hydration-context.d.ts.map +1 -1
- package/dist/hydration-context.js +34 -13
- package/dist/hydration-context.js.map +1 -1
- package/dist/hydration.d.ts.map +1 -1
- package/dist/hydration.js +4 -11
- package/dist/hydration.js.map +1 -1
- package/dist/index.d.ts +16 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -7
- package/dist/index.js.map +1 -1
- package/dist/list.d.ts +72 -0
- package/dist/list.d.ts.map +1 -0
- package/dist/list.js +786 -0
- package/dist/list.js.map +1 -0
- package/dist/portal.d.ts.map +1 -1
- package/dist/portal.js +3 -0
- package/dist/portal.js.map +1 -1
- package/dist/reactive.d.ts +2 -2
- package/dist/reactive.d.ts.map +1 -1
- package/dist/reactive.js +109 -34
- package/dist/reactive.js.map +1 -1
- package/dist/scope.d.ts +14 -4
- package/dist/scope.d.ts.map +1 -1
- package/dist/scope.js +55 -15
- package/dist/scope.js.map +1 -1
- package/dist/template.d.ts +48 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +270 -0
- package/dist/template.js.map +1 -0
- package/package.json +15 -6
- package/src/children.ts +2 -1
- package/src/component.ts +135 -63
- package/src/context.ts +35 -4
- package/src/dom/constants.ts +25 -82
- package/src/dom/create.ts +3 -3
- package/src/dom/events.ts +188 -17
- package/src/dom/props.ts +66 -9
- package/src/dom/range.ts +45 -0
- package/src/dom/remove.ts +3 -7
- package/src/element.ts +8 -6
- package/src/hmr.ts +110 -0
- package/src/hydration-context.ts +34 -13
- package/src/hydration.ts +4 -9
- package/src/index.ts +54 -7
- package/src/list.ts +886 -0
- package/src/portal.ts +3 -0
- package/src/reactive.ts +147 -40
- package/src/scope.ts +63 -16
- package/src/template.ts +271 -0
- package/dist/__tests__/children.test.d.ts +0 -2
- package/dist/__tests__/children.test.d.ts.map +0 -1
- package/dist/__tests__/children.test.js +0 -241
- package/dist/__tests__/children.test.js.map +0 -1
- package/dist/__tests__/component.test.d.ts +0 -2
- package/dist/__tests__/component.test.d.ts.map +0 -1
- package/dist/__tests__/component.test.js +0 -331
- package/dist/__tests__/component.test.js.map +0 -1
- package/dist/__tests__/context.test.d.ts +0 -2
- package/dist/__tests__/context.test.d.ts.map +0 -1
- package/dist/__tests__/context.test.js +0 -111
- package/dist/__tests__/context.test.js.map +0 -1
- package/dist/__tests__/dom.test.d.ts +0 -2
- package/dist/__tests__/dom.test.d.ts.map +0 -1
- package/dist/__tests__/dom.test.js +0 -385
- package/dist/__tests__/dom.test.js.map +0 -1
- package/dist/__tests__/hydration.test.d.ts +0 -2
- package/dist/__tests__/hydration.test.d.ts.map +0 -1
- package/dist/__tests__/hydration.test.js +0 -299
- package/dist/__tests__/hydration.test.js.map +0 -1
- package/dist/__tests__/portal.test.d.ts +0 -2
- package/dist/__tests__/portal.test.d.ts.map +0 -1
- package/dist/__tests__/portal.test.js +0 -157
- package/dist/__tests__/portal.test.js.map +0 -1
- package/dist/__tests__/remove.test.d.ts +0 -2
- package/dist/__tests__/remove.test.d.ts.map +0 -1
- package/dist/__tests__/remove.test.js +0 -45
- package/dist/__tests__/remove.test.js.map +0 -1
- package/dist/__tests__/render.test.d.ts +0 -2
- package/dist/__tests__/render.test.d.ts.map +0 -1
- package/dist/__tests__/render.test.js +0 -97
- package/dist/__tests__/render.test.js.map +0 -1
- package/dist/__tests__/scope.test.d.ts +0 -2
- package/dist/__tests__/scope.test.d.ts.map +0 -1
- package/dist/__tests__/scope.test.js +0 -110
- package/dist/__tests__/scope.test.js.map +0 -1
- package/src/__tests__/children.test.ts +0 -276
- package/src/__tests__/component.test.ts +0 -363
- package/src/__tests__/context.test.ts +0 -128
- package/src/__tests__/dom.test.ts +0 -434
- package/src/__tests__/hydration.test.ts +0 -360
- package/src/__tests__/portal.test.ts +0 -177
- package/src/__tests__/remove.test.ts +0 -51
- package/src/__tests__/render.test.ts +0 -119
- package/src/__tests__/scope.test.ts +0 -120
- package/tsconfig.json +0 -8
package/src/dom/constants.ts
CHANGED
|
@@ -29,99 +29,42 @@ export const SVG_TAGS = new Set([
|
|
|
29
29
|
"foreignObject",
|
|
30
30
|
]);
|
|
31
31
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
onKeyUp: "keyup",
|
|
47
|
-
onKeyPress: "keypress",
|
|
39
|
+
"KeyDown KeyUp KeyPress " +
|
|
48
40
|
// Focus
|
|
49
|
-
|
|
50
|
-
onBlur: "blur",
|
|
41
|
+
"Focus Blur " +
|
|
51
42
|
// Pointer
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
64
|
-
onTouchEnd: "touchend",
|
|
65
|
-
onTouchMove: "touchmove",
|
|
46
|
+
"TouchStart TouchEnd TouchMove " +
|
|
66
47
|
// Drag
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
onScrollEnd: "scrollend",
|
|
77
|
-
onWheel: "wheel",
|
|
50
|
+
"Scroll ScrollEnd Wheel " +
|
|
78
51
|
// Form
|
|
79
|
-
|
|
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
|
-
|
|
88
|
-
onCut: "cut",
|
|
89
|
-
onPaste: "paste",
|
|
54
|
+
"Copy Cut Paste " +
|
|
90
55
|
// Composition (IME)
|
|
91
|
-
|
|
92
|
-
onCompositionUpdate: "compositionupdate",
|
|
93
|
-
onCompositionEnd: "compositionend",
|
|
56
|
+
"CompositionStart CompositionUpdate CompositionEnd " +
|
|
94
57
|
// Resource
|
|
95
|
-
|
|
96
|
-
onError: "error",
|
|
58
|
+
"Load Error " +
|
|
97
59
|
// Animation / Transition
|
|
98
|
-
|
|
99
|
-
onTransitionEnd: "transitionend",
|
|
60
|
+
"AnimationEnd TransitionEnd " +
|
|
100
61
|
// Element-specific (details, dialog, popover)
|
|
101
|
-
|
|
102
|
-
onBeforeToggle: "beforetoggle",
|
|
103
|
-
onClose: "close",
|
|
104
|
-
onCancel: "cancel",
|
|
62
|
+
"Toggle BeforeToggle Close Cancel " +
|
|
105
63
|
// Media
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
? (
|
|
6
|
-
:
|
|
5
|
+
? (doc.createElementNS(SVG_NAMESPACE, tag))
|
|
6
|
+
: doc.createElement(tag);
|
|
7
7
|
}
|
package/src/dom/events.ts
CHANGED
|
@@ -1,30 +1,201 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
22
35
|
el: Element,
|
|
23
36
|
eventName: string,
|
|
24
|
-
handler: EventListener,
|
|
37
|
+
handler: EventListener | EventListenerObject | null | undefined,
|
|
25
38
|
scope: Scope,
|
|
26
39
|
): void {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
// Components forward optional handlers (`onSubmit={this.onSubmit}`) as-is, and addEventListener
|
|
41
|
+
// has always treated a missing listener as a no-op.
|
|
42
|
+
if (handler == null) return;
|
|
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];
|
|
30
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
|
}
|
|
@@ -62,7 +70,7 @@ function lookupWritable(proto: object, key: string): boolean {
|
|
|
62
70
|
// must be excluded here or assignment below throws. The prototype walk is cached per
|
|
63
71
|
// prototype because every element of a given tag repeats the same lookups; a property defined on
|
|
64
72
|
// a prototype after its first lookup (e.g. a late polyfill) is therefore not picked up.
|
|
65
|
-
function hasWritableProperty(el: Element, key: string): boolean {
|
|
73
|
+
export function hasWritableProperty(el: Element, key: string): boolean {
|
|
66
74
|
const own = Object.getOwnPropertyDescriptor(el, key);
|
|
67
75
|
if (own) return own.set !== undefined || own.writable === true;
|
|
68
76
|
|
|
@@ -80,7 +88,7 @@ function hasWritableProperty(el: Element, key: string): boolean {
|
|
|
80
88
|
return writable;
|
|
81
89
|
}
|
|
82
90
|
|
|
83
|
-
function setProp(el: Element, key: string, value: unknown): void {
|
|
91
|
+
export function setProp(el: Element, key: string, value: unknown): void {
|
|
84
92
|
if (key === "class" || key === "className") {
|
|
85
93
|
applyClass(el, value);
|
|
86
94
|
} else if (key === "style") {
|
|
@@ -106,21 +114,70 @@ export function applyProp(
|
|
|
106
114
|
|
|
107
115
|
if (normalizedKey === "ref") {
|
|
108
116
|
// untracked: a ref callback that reads a signal must not subscribe the enclosing reactive child
|
|
109
|
-
untrack(() => { (value as (el: Element) => void)(el); });
|
|
117
|
+
if (typeof value === "function") untrack(() => { (value as (el: Element) => void)(el); });
|
|
110
118
|
return;
|
|
111
119
|
}
|
|
112
120
|
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
// Every event prop starts with "on": checking that first keeps other props off the map lookup.
|
|
122
|
+
const type = normalizedKey.startsWith("on") ? EVENT_MAP.get(normalizedKey) : undefined;
|
|
123
|
+
if (type !== undefined) {
|
|
124
|
+
addEvent(el, type, value as EventListener, scope);
|
|
115
125
|
return;
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
if (typeof value === "function") {
|
|
119
|
-
|
|
120
|
-
setProp(el, normalizedKey, (value as () => unknown)());
|
|
121
|
-
});
|
|
129
|
+
bindProp(el, normalizedKey, value as () => unknown, scope);
|
|
122
130
|
return;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
setProp(el, normalizedKey, value);
|
|
126
134
|
}
|
|
135
|
+
|
|
136
|
+
// State the user can change behind a binding's back (typing, toggling a <details>, the media
|
|
137
|
+
// controls, scrolling): always written, so a re-run puts the bound value back exactly as before.
|
|
138
|
+
const LIVE_PROPS = new Set([
|
|
139
|
+
"value", "checked", "selected", "indeterminate", "selectedIndex", "open",
|
|
140
|
+
"muted", "volume", "currentTime", "playbackRate", "scrollTop", "scrollLeft",
|
|
141
|
+
]);
|
|
142
|
+
const UNSET = Symbol("unset");
|
|
143
|
+
|
|
144
|
+
interface PropBinding {
|
|
145
|
+
el: Element;
|
|
146
|
+
key: string;
|
|
147
|
+
read: () => unknown;
|
|
148
|
+
last: unknown;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// One function for every binding, reading its state from the effect: no closure per binding.
|
|
152
|
+
function runBinding(this: EffectNode<PropBinding>): void {
|
|
153
|
+
const binding = this.ctx;
|
|
154
|
+
const value = binding.read();
|
|
155
|
+
// A binding re-runs when anything it read changes; most re-runs produce the same primitive,
|
|
156
|
+
// and skipping that write saves a DOM call and the style or attribute work behind it.
|
|
157
|
+
if (value === binding.last && (typeof value !== "object" || value === null)) return;
|
|
158
|
+
binding.last = value;
|
|
159
|
+
setProp(binding.el, binding.key, value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function runLiveBinding(this: EffectNode<PropBinding>): void {
|
|
163
|
+
const binding = this.ctx;
|
|
164
|
+
setProp(binding.el, binding.key, binding.read());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function bindProp(el: Element, key: string, read: () => unknown, scope: Scope): void {
|
|
168
|
+
scope.contextEffect(LIVE_PROPS.has(key) ? runLiveBinding : runBinding, { el, key, read, last: UNSET });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function runClassBinding(this: EffectNode<PropBinding>): void {
|
|
172
|
+
const binding = this.ctx;
|
|
173
|
+
const value = binding.read();
|
|
174
|
+
if (value === binding.last && (typeof value !== "object" || value === null)) return;
|
|
175
|
+
binding.last = value;
|
|
176
|
+
applyClass(binding.el, value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A reactive `class` prop, bound like any other reactive prop but with no key dispatch per run. */
|
|
180
|
+
export function bindClass(el: Element, read: () => unknown, scope: Scope): void {
|
|
181
|
+
// Same record shape as bindProp's, so the effects of both kinds stay one shape.
|
|
182
|
+
scope.contextEffect(runClassBinding, { el, key: "class", read, last: UNSET });
|
|
183
|
+
}
|
package/src/dom/range.ts
ADDED
|
@@ -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
|
-
|
|
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 {
|
|
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 (
|
|
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
|
}
|