solarite 0.7.1 → 0.9.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.
@@ -2,32 +2,30 @@ import Path from "./Path.js";
2
2
  import Util from "./Util.js";
3
3
  import delve, {isDelvePath} from "./delve.js";
4
4
  import assert from "./assert.js";
5
+ import {SelectorRef} from "./Selector.js";
5
6
 
6
7
  export default class PathToAttribValue extends Path {
7
8
 
8
9
  /** @type {?string} Used only if type=AttribType.Value. */
9
- attrName;
10
+ attribName;
10
11
 
11
12
  /**
12
13
  * @type {?string[]} Used only if type=AttribType.Value. If null, use one expr to set the whole attribute value. */
13
14
  attrValue;
14
15
 
15
- /** @type {boolean} Provides value for attribute on a component. */
16
- isComponent;
16
+ // isComponentAttrib and isHtmlProperty are declared on the Path base class.
17
17
 
18
- isHtmlProperty;
19
-
20
- constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
18
+ constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
21
19
  super(null, nodeMarker);
22
- this.attrName = attrName;
20
+ this.attribName = attribName;
23
21
  this.attrValue = attrValue;
24
22
  }
25
23
 
26
24
  /**
27
25
  * Set the value of an attribute. This can be for any attribute, not just attributes named "value".
28
26
  * @param exprs {Expr[]} */
29
- apply(exprs) {
30
- //#IFDEV
27
+ applyAll(exprs) {
28
+ //#IFDEBUG
31
29
  assert(Array.isArray(exprs));
32
30
  //#ENDIF
33
31
 
@@ -40,14 +38,14 @@ export default class PathToAttribValue extends Path {
40
38
  // Only update attributes if the value has changed.
41
39
  // This is needed for setting input.value, .checked, option.selected, etc.
42
40
  let oldVal = isProp
43
- ? node[this.attrName]
44
- : node.getAttribute(this.attrName);
41
+ ? node[this.attribName]
42
+ : node.getAttribute(this.attribName);
45
43
  if (oldVal !== joinedValue) {
46
44
  if (isProp)
47
- node[this.attrName] = joinedValue;
48
- else if (this.attrName === 'value' && node.hasAttribute('contenteditable'))
45
+ node[this.attribName] = joinedValue;
46
+ else if (this.attribName === 'value' && node.hasAttribute('contenteditable'))
49
47
  node.innerHTML = joinedValue;
50
- node.setAttribute(this.attrName, joinedValue);
48
+ node.setAttribute(this.attribName, joinedValue);
51
49
  }
52
50
  }
53
51
  else
@@ -60,7 +58,7 @@ export default class PathToAttribValue extends Path {
60
58
  applySingle(expr) {
61
59
  // One expression surrounded by strings, e.g. class="a ${b} c". Join through apply().
62
60
  if (this.attrValue)
63
- return this.apply([expr]);
61
+ return this.applyAll([expr]);
64
62
 
65
63
  let node = this.nodeMarker;
66
64
 
@@ -81,12 +79,12 @@ export default class PathToAttribValue extends Path {
81
79
  let [obj, path] = [expr[0], expr.slice(1)];
82
80
 
83
81
  if (!obj)
84
- throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${[${expr.map(item => item ? `'${item}'` : item+'').join(', ')}]}>.`);
82
+ throw new Error(`Solarite cannot bind ${this.attribName} to ${obj}.`);
85
83
 
86
84
  let value = delve(obj, path);
87
85
 
88
86
  // Special case to allow setting select-multiple value from an array
89
- if (this.attrName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
87
+ if (this.attribName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
90
88
  // Set the .selected property on the options having a value within value.
91
89
  let strValues = value.map(v => v + '');
92
90
  for (let option of node.options)
@@ -102,7 +100,7 @@ export default class PathToAttribValue extends Path {
102
100
  const strValue = Util.isFalsy(value) ? '' : value;
103
101
 
104
102
  // Special case for contenteditable
105
- if (this.attrName === 'value' && node.hasAttribute('contenteditable')) {
103
+ if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
106
104
  const existingValue = node.innerHTML;
107
105
  if (strValue !== existingValue)
108
106
  node.innerHTML = strValue;
@@ -111,28 +109,39 @@ export default class PathToAttribValue extends Path {
111
109
 
112
110
  // If we don't have this condition, when we call render(), the browser will scroll to the currently
113
111
  // selected item in a <select> and mess up manually scrolling to a different value.
114
- if (strValue !== node[this.attrName])
115
- node[this.attrName] = strValue;
112
+ if (strValue !== node[this.attribName])
113
+ node[this.attribName] = strValue;
116
114
  }
117
115
  }
118
116
 
119
117
  // TODO: We need to remove any old listeners, like in bindEventAttribute.
120
118
  // Does bindEvent() now handle that?
121
119
  let func = () => {
122
- let value = (this.attrName === 'value' || node.type === 'radio')
120
+ let value = (this.attribName === 'value' || node.type === 'radio')
123
121
  ? Util.getInputValue(node)
124
- : node[this.attrName];
122
+ : node[this.attribName];
125
123
  delve(obj, path, value);
126
124
  }
127
125
 
128
126
  // We use capture so we update the values before other events added by the user.
129
127
  // TODO: Bind to scroll events also?
130
128
  // What about resize events and width/height?
131
- this.bindEvent(node, this.parentNg.getRootNode(), this.attrName, 'input', func, null, true);
129
+ this.bindEvent(node, this.parentNg.getRootEl(), this.attribName, 'input', func, null, true);
132
130
  }
133
131
 
134
132
  // Regular attribute
135
133
  else {
134
+ // A selection binding (h.selector().when()) writes its own value and tells the
135
+ // selector which list this row belongs to, so a later change of selection reaches
136
+ // the attribute directly instead of going back through render(). The typeof test
137
+ // keeps ordinary string attributes — nearly all of them — from paying for the
138
+ // prototype check.
139
+ if (typeof expr === 'object' && expr instanceof SelectorRef) {
140
+ if (!this.isComponentAttrib)
141
+ expr.bind(node, this.attribName, this.parentNg);
142
+ return;
143
+ }
144
+
136
145
  // Cache this on Path.isHtmlProperty when Shell creates the props.
137
146
  // Have Path.clone() copy .isHtmlProperty?
138
147
  let isProp = this.isHtmlProperty;
@@ -145,43 +154,53 @@ export default class PathToAttribValue extends Path {
145
154
  else
146
155
  expr = Util.makePrimitive(expr);
147
156
 
148
- // Values to toggle an attribute
149
- if (expr === undefined || expr === false || expr === null) { // Util.isFalsy() inlined.
150
- if (isProp)
151
- node[this.attrName] = false;
152
- node.removeAttribute(this.attrName);
157
+ // Values that remove an attribute. The empty string is included so that an attribute
158
+ // disappears whenever its expression is empty, instead of only when it happened to be
159
+ // absent already. makePrimitive() above turns null into '', so plain null lands here
160
+ // too; the explicit null test still matters for a function expression returning null,
161
+ // which skips makePrimitive.
162
+ // An html property is exempt: on those, '' is a real value meaning "empty", as when
163
+ // clearing an <input>, so it belongs on the assignment path below.
164
+ if (expr === undefined || expr === false || expr === null || (expr === '' && !isProp)) {
165
+ if (isProp) {
166
+ // Clear the property with a value of its own type. Assigning false to a string
167
+ // property such as input.value would put the text "false" in the field.
168
+ let old = node[this.attribName];
169
+ node[this.attribName] = typeof old === 'boolean' ? false : '';
170
+ }
171
+ node.removeAttribute(this.attribName);
153
172
  }
154
173
  else if (expr === true) {
155
174
  if (isProp)
156
- node[this.attrName] = true;
157
- node.setAttribute(this.attrName, '');
175
+ node[this.attribName] = true;
176
+ node.setAttribute(this.attribName, '');
158
177
  }
159
178
 
160
179
  // A non-toggled attribute
161
180
  else {
162
181
  // Only update attributes if the value has changed.
163
182
  // This is needed for setting input.value, .checked, option.selected, etc.
164
- // A missing attribute counts as '', so empty values don't write empty attributes.
183
+ // Non-property attributes never reach here with '', since that removes above.
165
184
  let oldVal = isProp
166
- ? node[this.attrName]
167
- : node.getAttribute(this.attrName) ?? '';
185
+ ? node[this.attribName]
186
+ : node.getAttribute(this.attribName) ?? '';
168
187
  if (oldVal !== expr) {
169
188
 
170
189
  // <textarea value=${expr}></textarea>
171
190
  // Without this branch we have no way to set the value of a textarea,
172
191
  // since we also prohibit expressions that are a child of textarea.
173
192
  if (isProp)
174
- node[this.attrName] = expr;
193
+ node[this.attribName] = expr;
175
194
 
176
195
  // Allow one-way binding to contenteditable value attribute.
177
196
  // Contenteditables normally don't have a value attribute and have their content set via innerHTML.
178
197
  // Solarite doesn't allow contenteditables to have expressions as their children.
179
- else if (this.attrName === 'value' && node.hasAttribute('contenteditable')) {
198
+ else if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
180
199
  node.innerHTML = expr;
181
200
  }
182
201
 
183
202
  // TODO: Putting an 'else' here would be more performant
184
- node.setAttribute(this.attrName, expr);
203
+ node.setAttribute(this.attribName, expr);
185
204
  }
186
205
  }
187
206
  }
@@ -195,14 +214,14 @@ export default class PathToAttribValue extends Path {
195
214
  * @return {string} The joined values of the expressions, or the first expression if there are no strings. */
196
215
  getValue(exprs) {
197
216
 
198
- //#IFDEV
217
+ //#IFDEBUG
199
218
  assert(Array.isArray(exprs));
200
219
  //#ENDIF
201
220
  //if (!Array.isArray(exprs))
202
221
  // return exprs;
203
222
 
204
223
  if (!this.attrValue) {// If it's not multiple paths inside a single attribute, return first (and only) expression.
205
- //#IFDEV
224
+ //#IFDEBUG
206
225
  assert(exprs.length === 1);
207
226
  //#ENDIF
208
227
  return exprs[0];
@@ -213,6 +232,18 @@ export default class PathToAttribValue extends Path {
213
232
  for (let i = 0; i < values.length; i++) {
214
233
  result.push(values[i]);
215
234
  if (i < values.length - 1) {
235
+ // A selection binding has to own the whole attribute, because its whole point is
236
+ // writing that attribute without re-rendering, which it can't do if the rest of
237
+ // the value comes from expressions it doesn't know about. Whether a selector sits
238
+ // inside a multi-part attribute is fixed by the shape of the template and never by
239
+ // the data, so this can only be an authoring mistake, and it always surfaces on the
240
+ // template's very first render -- exactly like the placement check in
241
+ // SelectorRef.bind(). That makes it safe to strip from the built file, where the
242
+ // throw is the only thing lost: makePrimitive() then turns the ref into '' and the
243
+ // attribute is written from its constant parts alone. Stripping it also keeps a
244
+ // per-expression instanceof out of the multi-part attribute loop.
245
+ if (typeof exprs[i] === 'object' && exprs[i] instanceof SelectorRef)
246
+ throw new Error(`Solarite: a selector must own the whole ${this.attribName} attribute.`);
216
247
  let val = Util.makePrimitive(exprs[i]);
217
248
  if (!Util.isFalsy(val))
218
249
  result.push(val);
@@ -233,17 +264,44 @@ export default class PathToAttribValue extends Path {
233
264
  /**
234
265
  * @param funcAndArgs {?Array} The [func, ...args] array from the template, or null if func stands alone. */
235
266
  bindEvent(node, root, key, eventName, func, funcAndArgs, capture=false) {
267
+ //#IFDEBUG
268
+ // Both callers already guarantee a function, so this only catches a future third caller.
269
+ // PathToEvent.applySingle() rejects every shape a template can produce and names the
270
+ // offending value, and the two-way binding path above passes a closure it just made
271
+ // here, so nothing a page author writes can reach this line. That makes it dev-only:
272
+ // stripping it from the built file costs no diagnostic that the surviving throw in
273
+ // PathToEvent doesn't already give, with a better message.
236
274
  if (typeof func !== 'function')
237
- throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${${func}}> because it's not a function.`);
275
+ throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attribName}=\${${func}}> because it's not a function.`);
276
+ //#ENDIF
238
277
 
239
- // Whether to delegate is decided in registerBinding(), which only runs for a NEW binding.
240
- // Re-renders rebind existing rows (just updating binding.args below), so they skip the
241
- // options lookup + delegatableEvents check entirely.
242
- let options = this.parentNg.rootNg.options;
278
+ // Delegated path: a bubbling event (when the root's options allow it, the default)
279
+ // stores its handler directly on the node as a per-event-type Symbol expando, with no
280
+ // EventBinding object and no addEventListener call. When an event of that type
281
+ // starts, jitDispatcher() attaches a real listener to each node on its path that
282
+ // carries the expando, so the browser runs the handler at the node's own turn.
283
+ // Re-renders just overwrite the property. this.delegatedKey is set by the PathToEvent
284
+ // constructor only for delegatable event names, so this test also excludes
285
+ // non-bubbling events and native:on* bindings.
286
+ if (capture === false && this.delegatedKey !== undefined) {
287
+ let opt = this.parentNg.rootNg.renderOptions?.eventDelegation ?? true;
288
+ // true delegates everything, an array only the events it names, and any other
289
+ // value (such as the retired 'document' string) counts as true.
290
+ if (opt !== false && (!Array.isArray(opt) || opt.includes(eventName))) {
291
+ let dk = this.delegatedKey;
292
+ if (node[dk] === undefined) // First binding of this type on this node.
293
+ ensureDelegatedDispatcher(root, eventName);
294
+ // Array-form bindings (onclick=${[fn, arg]}, the hot per-row case) store the
295
+ // template's own [func, ...args] array; a plain function is stored bare.
296
+ // Either way, nothing is allocated.
297
+ node[dk] = funcAndArgs || func;
298
+ node[delegatedRootKey] = root;
299
+ return;
300
+ }
301
+ }
243
302
 
244
- // Store the callable as a single [func, ...args] array. Array-form bindings
245
- // (onclick=${[fn, arg]}, the hot per-row case) pass it through with no allocation;
246
- // a plain function allocates a one-element array, which is rare (buttons, two-way).
303
+ // Direct path: capture bindings, non-bubbling events, and eventDelegation:false.
304
+ // Store the callable as a single [func, ...args] array.
247
305
  let args = funcAndArgs || [func];
248
306
 
249
307
  // One stable EventBinding object per node+key is registered with addEventListener
@@ -253,7 +311,7 @@ export default class PathToAttribValue extends Path {
253
311
  let nodeEvents = node[eventBindingsKey];
254
312
  if (nodeEvents === undefined) {
255
313
  let b = node[eventBindingsKey] = new EventBinding(root, node, key, args);
256
- registerBinding(b, node, eventName, capture, options, root);
314
+ node.addEventListener(eventName, b, capture);
257
315
  return;
258
316
  }
259
317
 
@@ -271,7 +329,7 @@ export default class PathToAttribValue extends Path {
271
329
  let map = node[eventBindingsKey] = {};
272
330
  map[nodeEvents.key] = nodeEvents;
273
331
  binding = map[key] = new EventBinding(root, node, key, args);
274
- registerBinding(binding, node, eventName, capture, options, root);
332
+ node.addEventListener(eventName, binding, capture);
275
333
  return;
276
334
  }
277
335
  }
@@ -279,11 +337,11 @@ export default class PathToAttribValue extends Path {
279
337
  binding = nodeEvents[key];
280
338
  if (!binding) {
281
339
  binding = nodeEvents[key] = new EventBinding(root, node, key, args);
282
- registerBinding(binding, node, eventName, capture, options, root);
340
+ node.addEventListener(eventName, binding, capture);
283
341
  return;
284
342
  }
285
343
  }
286
- binding.root = root;
344
+ binding.rootEl = root;
287
345
  binding.args = args;
288
346
  }
289
347
  }
@@ -304,82 +362,171 @@ export function getEventBinding(node, key) {
304
362
  return b instanceof EventBinding ? (b.key === key ? b : undefined) : b[key];
305
363
  }
306
364
 
365
+ // Bubbling events the just-in-time dispatcher handles. Same set Solid.js delegates.
366
+ const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
367
+ 'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
368
+ 'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
369
+
370
+ // One Symbol per delegated event type; nodes store their delegated handler under it.
371
+ // Symbols (vs string expandos like Solid's $$click) can't collide with user properties.
372
+ const delegatedKeys = {};
373
+
307
374
  /**
308
- * Attach a new EventBinding either directly or through the root component's delegated
309
- * dispatcher. The dispatcher lives on the root element (not the document) so a component
310
- * still receives delegated events while detached from the document, and events stay scoped
311
- * to the component that rendered them. */
312
- function registerBinding(binding, node, eventName, capture, options, root) {
313
- // Bubbling events are delegated by default: they skip addEventListener entirely, and one
314
- // root-level dispatcher per event type finds bindings by walking up from the event target.
315
- // eventDelegation:false opts out; an array delegates only the named events. Capture
316
- // bindings and non-bubbling events always stay direct.
317
- let delegate = false;
318
- if (capture === false) {
319
- let opt = options?.eventDelegation ?? true;
320
- if (opt !== false && delegatableEvents.has(eventName))
321
- delegate = opt === true || opt.includes(eventName);
322
- }
375
+ * Get the per-event-type Symbol key, or undefined for non-delegatable events.
376
+ * Called once per PathToEvent construction, never per bind.
377
+ * @param eventName {string}
378
+ * @return {symbol|undefined} */
379
+ export function delegatedKeyFor(eventName) {
380
+ if (!delegatableEvents.has(eventName))
381
+ return undefined;
382
+ return delegatedKeys[eventName] ??= Symbol('sol$' + eventName);
383
+ }
384
+
385
+ // The component root a node's delegated handlers run with as `this`.
386
+ // Exported so NodeGroup.applyStamp()'s compiled stamp program can write it directly.
387
+ export const delegatedRootKey = Symbol('solariteDelegatedRoot');
323
388
 
324
- if (delegate) {
325
- binding.delegated = true;
326
- let types = root[delegatedTypesKey];
327
- if (types === undefined)
328
- types = root[delegatedTypesKey] = new Set();
329
- if (!types.has(eventName)) {
330
- types.add(eventName);
331
- root.addEventListener(eventName, delegatedDispatcher);
389
+ // Set of event types that already have the dispatcher registered, kept on each root element
390
+ // and on each document.
391
+ const delegatedTypesKey = Symbol('solariteDelegatedTypes');
392
+
393
+ /**
394
+ * Register the just-in-time dispatcher for eventName on root and on root's document, once
395
+ * each. Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
396
+ *
397
+ * Both registrations are needed. The document's listener is what still reaches a bound node
398
+ * after another component re-parents it outside its root (a toolbar a dock parks in its own
399
+ * chrome). The root's listener is what reaches what the document cannot see: a component
400
+ * that isn't in the document at all, nodes inside a closed shadow root, and a synthetic
401
+ * event dispatched inside any shadow root without composed:true, which never leaves it.
402
+ * @param root {HTMLElement}
403
+ * @param eventName {string} */
404
+ export function ensureDelegatedDispatcher(root, eventName) {
405
+ let types = root[delegatedTypesKey];
406
+ if (types === undefined)
407
+ types = root[delegatedTypesKey] = new Set();
408
+ if (!types.has(eventName)) {
409
+ types.add(eventName);
410
+ root.addEventListener(eventName, jitDispatcher, true);
411
+
412
+ let doc = root.ownerDocument;
413
+ let docTypes = doc[delegatedTypesKey];
414
+ if (docTypes === undefined)
415
+ docTypes = doc[delegatedTypesKey] = new Set();
416
+ if (!docTypes.has(eventName)) {
417
+ docTypes.add(eventName);
418
+ doc.addEventListener(eventName, jitDispatcher, true);
332
419
  }
333
420
  }
334
- else
335
- node.addEventListener(eventName, binding, capture);
336
421
  }
337
422
 
338
- // Bubbling events that one root-level listener can dispatch. Same set Solid.js delegates.
339
- const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
340
- 'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
341
- 'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
423
+ // Set on an event by the first dispatcher to walk it, holding the length of the path it saw,
424
+ // so the dispatchers on nested roots further down don't repeat the walk. A root inside a
425
+ // closed shadow root sees a longer path than the document did, because composedPath() hides
426
+ // a closed tree from listeners outside it, and that mismatch is what makes it walk again.
427
+ const delegatedDoneKey = Symbol('solariteDelegated');
342
428
 
343
- // Per-root-element Set of event types that already have a delegated dispatcher registered.
344
- const delegatedTypesKey = Symbol('solariteDelegatedTypes');
429
+ /**
430
+ * One shared bubble-phase listener per event type, attached to a node only for the duration
431
+ * of one event. The browser invokes it at the node's own turn in propagation, and it reads
432
+ * the node's handler THEN rather than when it was attached, so a handler that an earlier
433
+ * listener in the same dispatch replaced or removed is honored.
434
+ * @type {Object<string, {handleEvent: function(Event)}>} */
435
+ const trampolines = {};
345
436
 
346
- // Marks an event the innermost root dispatcher has already walked, so an outer root's
347
- // listener (when components are nested) skips it instead of dispatching the bindings again.
348
- const delegatedDoneKey = Symbol('solariteDelegated');
437
+ /**
438
+ * @param type {string}
439
+ * @return {{handleEvent: function(Event)}} */
440
+ function trampolineFor(type) {
441
+ let tramp = trampolines[type];
442
+ if (tramp === undefined) {
443
+ let dk = delegatedKeys[type];
444
+ tramp = trampolines[type] = {
445
+ // Quoted so the minifier's property mangling doesn't rename it, since the browser looks it up by name.
446
+ 'handleEvent'(ev) {
447
+ let node = ev.currentTarget;
448
+ let a = node[dk];
449
+ if (a === undefined) // Unbound by an earlier handler in this same dispatch.
450
+ return;
451
+ let root = node[delegatedRootKey];
452
+ if (typeof a === 'function')
453
+ a.call(root, ev, node);
454
+ else
455
+ switch (a.length) {
456
+ case 1: a[0].call(root, ev, node); break;
457
+ case 2: a[0].call(root, a[1], ev, node); break;
458
+ case 3: a[0].call(root, a[1], a[2], ev, node); break;
459
+ default: a[0].call(root, ...a.slice(1), ev, node);
460
+ }
461
+ }
462
+ };
463
+ }
464
+ return tramp;
465
+ }
466
+
467
+ // Nodes still carrying a trampoline, per event type, and the one timer that clears them.
468
+ const pending = {};
469
+ let sweepTimer = 0;
470
+
471
+ /**
472
+ * Remove every trampoline attached since the last sweep. Runs as a task, which is always
473
+ * after every dispatch in progress has finished. A microtask would not be: for a real click
474
+ * the browser runs a microtask checkpoint between listeners, so a microtask sweep would strip
475
+ * the trampolines before the event reached the first of them. The sweep is housekeeping
476
+ * only; a trampoline left in place is harmless, because jitDispatcher() re-attaches it and
477
+ * the trampoline reads its handler fresh. */
478
+ function sweep() {
479
+ sweepTimer = 0;
480
+ for (let type in pending) {
481
+ let nodes = pending[type];
482
+ if (nodes.length !== 0) {
483
+ pending[type] = [];
484
+ let tramp = trampolines[type];
485
+ for (let i=0; i<nodes.length; i++)
486
+ nodes[i].removeEventListener(type, tramp);
487
+ }
488
+ }
489
+ }
349
490
 
350
491
  /**
351
- * The per-root listener for each delegated event type. The first (innermost) root the
352
- * bubbling event reaches walks from the event target upward, invoking delegated
353
- * EventBindings stored on the nodes along the way; outer roots then see the done-marker and
354
- * skip. Each binding carries its own root, so handlers in an outer component still run with
355
- * the correct `this`. event.currentTarget is patched to the node whose binding is running,
356
- * and restored after. stopPropagation() inside a handler ends the walk, mirroring native
357
- * bubbling. */
358
- function delegatedDispatcher(ev) {
359
- if (ev[delegatedDoneKey])
492
+ * The capture-phase listener registered per delegated event type on every root and on the
493
+ * document. It runs before the event reaches anything, walks the event's path, and attaches
494
+ * the type's trampoline to each node holding a delegated handler. The browser then finishes
495
+ * the dispatch natively, so those handlers interleave correctly with listeners anyone else
496
+ * registered, stopPropagation() works in both directions, currentTarget is right, and the
497
+ * event needn't bubble.
498
+ *
499
+ * Each attach removes the trampoline first. One left from an earlier event in this same task
500
+ * would otherwise keep its old place in the node's listener list, ahead of listeners added
501
+ * since; removing and re-adding puts it last, so the rule holds without exception: a
502
+ * delegated handler runs after every listener its element had when the event started. */
503
+ function jitDispatcher(ev) {
504
+ let path = ev.composedPath();
505
+ if (ev[delegatedDoneKey] === path.length)
360
506
  return;
361
- ev[delegatedDoneKey] = true;
507
+ ev[delegatedDoneKey] = path.length;
508
+
362
509
  let type = ev.type;
363
- let current = ev.target;
364
- Object.defineProperty(ev, 'currentTarget', {configurable: true, get() { return current }});
365
- while (current) {
366
- let b = current[eventBindingsKey];
367
- if (b !== undefined) {
368
- let binding = b instanceof EventBinding ? b : b[type];
369
- if (binding !== undefined && binding.delegated === true && binding.key === type) {
370
- binding.handleEvent(ev);
371
- if (ev.cancelBubble)
372
- break;
373
- }
510
+ let dk = delegatedKeys[type];
511
+ let tramp = trampolineFor(type);
512
+ let list = pending[type];
513
+ if (list === undefined)
514
+ list = pending[type] = [];
515
+ for (let i=0; i<path.length; i++) {
516
+ let node = path[i];
517
+ if (node[dk] !== undefined) {
518
+ node.removeEventListener(type, tramp);
519
+ node.addEventListener(type, tramp);
520
+ list.push(node);
374
521
  }
375
- current = current.parentNode;
376
522
  }
377
- delete ev.currentTarget; // Restore the native getter from the prototype.
523
+ if (list.length !== 0 && sweepTimer === 0)
524
+ sweepTimer = setTimeout(sweep);
378
525
  }
379
526
 
380
527
  class EventBinding {
381
528
  constructor(root, node, key, args) {
382
- this.root = root;
529
+ this.rootEl = root;
383
530
  this.node = node;
384
531
  this.key = key;
385
532
 
@@ -393,10 +540,10 @@ class EventBinding {
393
540
  'handleEvent'(event) {
394
541
  let a = this.args;
395
542
  switch (a.length) {
396
- case 1: return a[0].call(this.root, event, this.node);
397
- case 2: return a[0].call(this.root, a[1], event, this.node);
398
- case 3: return a[0].call(this.root, a[1], a[2], event, this.node);
543
+ case 1: return a[0].call(this.rootEl, event, this.node);
544
+ case 2: return a[0].call(this.rootEl, a[1], event, this.node);
545
+ case 3: return a[0].call(this.rootEl, a[1], a[2], event, this.node);
399
546
  }
400
- return a[0].call(this.root, ...a.slice(1), event, this.node);
547
+ return a[0].call(this.rootEl, ...a.slice(1), event, this.node);
401
548
  }
402
549
  }
@@ -1,6 +1,5 @@
1
1
  import Path from "./Path.js";
2
2
  import Util from "./Util.js";
3
- import assert from "./assert.js";
4
3
  import PathToAttribValue from "./PathToAttribValue.js";
5
4
  import PathToEvent from "./PathToEvent.js";
6
5
  import {JsxAttr, styleToCss} from "./jsx.js";
@@ -11,24 +10,21 @@ export default class PathToAttribs extends Path {
11
10
  * @type {Set<string>} Used for type=AttribType.Multiple to remember the attributes that were added. */
12
11
  attrNames;
13
12
 
14
- /** @type {boolean} Provides one or more attributes on a component. */
15
- isComponent;
13
+ /** @type {PathToEvent|PathToAttribValue|undefined} Cached sub-path for the JSX
14
+ * whole-attribute fast path; see applyJsxAttr(). Declared so the first assignment
15
+ * doesn't transition the hidden class. */
16
+ jsxSub;
17
+
18
+ /** @type {?string} The attribute name jsxSub was built for. */
19
+ jsxSubName;
16
20
 
17
21
  constructor(nodeBefore, nodeMarker) {
18
- super(null, null);
19
- this.nodeMarker = nodeMarker;
22
+ // nodeBefore is discarded: an attribute path has no nodes of its own. The marker goes
23
+ // straight through the base constructor rather than being stored a second time after it.
24
+ super(null, nodeMarker);
20
25
  this.attrNames = new Set();
21
26
  }
22
27
 
23
- /**
24
- * @param exprs {Expr[][]} Only the first is used. */
25
- apply(exprs) {
26
- //#IFDEV
27
- assert(Array.isArray(exprs));
28
- //#ENDIF
29
- this.applySingle(exprs[0]);
30
- }
31
-
32
28
  /**
33
29
  * @param expr {Expr} */
34
30
  applySingle(expr) {
@@ -107,8 +103,4 @@ export default class PathToAttribs extends Path {
107
103
  value = styleToCss(value);
108
104
  sub.applySingle(value);
109
105
  }
110
-
111
-
112
- getExpressionCount() { return 1 }
113
- getValue(exprs) { return exprs[0]; }
114
106
  }