From 4e021dafd1d5b350d0e9167462eebf9ffcfa81fc Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sat, 29 Aug 2026 15:53:49 -0400 Subject: [PATCH] perf(core): trim per-keypress waste on the focus and state path Three behavior-preserving reductions on the path that runs for every focus change: - States.has() built a dollar-prefixed template string on every call to support unprefixed lookups (has('focus') matching '$focus'). The hot caller is updateFocusPath querying Config.focusStateKey, which is already prefixed, so the second lookup could only ever match a doubled prefix that nothing produces. A charCode check now short-circuits it, and the allocation happens only when an unprefixed query misses. - updateFocusPath allocated a Set per focus change purely to diff the previous focus path against the new one. The paths are a handful of elements and the Set never escapes, so a linear indexOf scan does the same diff with no allocation or hashing. - _stateChanged took the style-resolution branch whenever _undoStyles was set, including the empty array left behind once a state style has been undone (empty arrays are truthy). With nothing to undo and no style matching any active state the branch provably assigns nothing, so it is now skipped in that case. Measured honestly: on a desktop browser these changes did not move the per-press benchmark numbers beyond run-to-run noise; an A/B/A sequence showed environmental drift larger than any effect. They are strictly less work with identical observable behavior (178 tests pass unchanged), and the allocation reduction targets GC pressure on TV-class devices, so a device benchmark run is the gate on whether they actually help there. Context: the keypress attribution work that motivated looking here found the focus and state machinery is a small share of a press next to row construction inside Solid's batched key handler. These are the pieces of that machinery that were pure waste either way. Co-Authored-By: Claude Fable 5 --- src/core/elementNode.ts | 9 ++++++++- src/core/focusManager.ts | 12 +++++++----- src/core/states.ts | 13 +++++++++++-- 3 files changed, 26 insertions(+), 8 deletions(-) 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) {