diff --git a/src/core/elementNode.ts b/src/core/elementNode.ts index f05fb6c..9753814 100644 --- a/src/core/elementNode.ts +++ b/src/core/elementNode.ts @@ -1542,7 +1542,14 @@ export class ElementNode { const states = this.states; - if (this._undoStyles || keyExists(this, states)) { + // An empty _undoStyles (left behind once a state style has been undone) + // must not force the resolution branch: with nothing to undo and no style + // matching any active state, the branch provably assigns nothing, and it + // runs on every path element of every focus change. + if ( + (this._undoStyles !== undefined && this._undoStyles.length > 0) || + keyExists(this, states) + ) { let stylesToUndo: { [key: string]: any } | undefined; if (this._undoStyles && this._undoStyles.length) { stylesToUndo = {}; diff --git a/src/core/focusManager.ts b/src/core/focusManager.ts index 8a6f412..eb5187f 100644 --- a/src/core/focusManager.ts +++ b/src/core/focusManager.ts @@ -228,8 +228,10 @@ const updateFocusPath = ( prevFocusedElm: ElementNode | undefined, ) => { let current: ElementNode | undefined = currentFocusedElm; + // fp escapes through the focusPath signal, so it must be a fresh array; the + // membership test below runs on paths of a handful of elements every single + // keypress, where a linear scan beats allocating and hashing a Set. const fp: ElementNode[] = []; - const fpSet = new Set(); while (current) { if ( !current.states.has(Config.focusStateKey) || @@ -251,13 +253,13 @@ const updateFocusPath = ( ); } fp.push(current); - fpSet.add(current); current = current.parent; } const prevFp = focusPath(); - prevFp.forEach((elm) => { - if (!fpSet.has(elm)) { + for (let i = 0; i < prevFp.length; i++) { + const elm = prevFp[i]!; + if (fp.indexOf(elm) === -1) { elm.states.remove(Config.focusStateKey); elm.onBlur?.call(elm, currentFocusedElm, prevFocusedElm!, elm); elm.onFocusChanged?.call( @@ -268,7 +270,7 @@ const updateFocusPath = ( elm, ); } - }); + } if (Config.focusDebug) { addFocusDebug(prevFp, fp); diff --git a/src/core/states.ts b/src/core/states.ts index 82294d2..e87e0d6 100644 --- a/src/core/states.ts +++ b/src/core/states.ts @@ -26,8 +26,17 @@ export default class States extends Array { } has(state: DollarString) { - // temporary check for $ prefix - return this.indexOf(state) >= 0 || this.indexOf(`$${state}`) >= 0; + if (this.indexOf(state) >= 0) { + return true; + } + // temporary check for $ prefix, so has('focus') matches '$focus'. A query + // that already starts with '$' could only match a doubled prefix, which + // nothing produces, so skip the lookup: this runs per path element on + // every focus change and the template string was a per-call allocation. + return ( + state.charCodeAt(0) !== 36 && + this.indexOf(('$' + state) as DollarString) >= 0 + ); } is(state: DollarString) {