Skip to content

Commit fd61ba0

Browse files
chiefcllclaude
andauthored
perf(useMouse): single-pass hit-test and pre-scale mouse coords (#5)
Reduces mousemove hot-path work without changing behavior: - getChildrenByPosition: collapse BFS filter + max-z scan + queue reassignment per level into a single fused loop. Removes the intermediate currentLevelNodes allocation per descent step and the unreachable isTextNode branch. - Pre-divide mouse coords by deviceLogicalPixelRatio once instead of multiplying every node's absX/absY/width/height. Applied in both getChildrenByPosition and findElementByActiveElement. - Replace .filter() + last-element pick with a single reverse iteration in findElementWithCustomState and the focus effect. - Hoist Config.focusStateKey out of the per-tick filter. - Clean up dead code: findHighestZIndexNode (folded), unreachable `if (!activeElm) return` after the forwardStates walk-up, dead `active &&` check inside findElementByActiveElement's loop. Adds tests/useMouse.test.tsx covering: onMouseClick on focused element, ancestor walk-up on click, deepest-child focus on mousemove, z-index priority, skipFocus / alpha:0 exclusion, hoverState application and transfer, forwardStates propagation, deviceLogicalPixelRatio scaling, mousedown / pressedState lifecycle. All 10 tests pass against both the previous main implementation and this refactor. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ca2a561 commit fd61ba0

2 files changed

Lines changed: 510 additions & 125 deletions

File tree

src/primitives/useMouse.ts

Lines changed: 94 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
activeElement,
66
isElementNode,
77
isFunc,
8-
isTextNode,
98
rootNode,
109
} from '../index.js';
1110
import { makeEventListener } from '@solid-primitives/event-listener';
@@ -88,41 +87,39 @@ function findElementWithCustomState<TApp extends ElementNode>(
8887
y: number,
8988
customState: CustomState,
9089
): ElementNode | undefined {
91-
const result = getChildrenByPosition(myApp, x, y).filter((el) =>
92-
hasCustomState(el, customState),
93-
);
94-
95-
if (result.length === 0) {
96-
return undefined;
97-
}
98-
99-
let element: ElementNode | undefined = result[result.length - 1];
100-
101-
while (element) {
102-
const elmParent = element.parent;
103-
if (elmParent?.forwardStates && hasCustomState(elmParent, customState)) {
104-
element = elmParent;
105-
} else {
90+
const path = getChildrenByPosition(myApp, x, y);
91+
let element: ElementNode | undefined;
92+
for (let i = path.length - 1; i >= 0; i--) {
93+
if (hasCustomState(path[i]!, customState)) {
94+
element = path[i];
10695
break;
10796
}
10897
}
98+
if (!element) return undefined;
10999

100+
let p = element.parent;
101+
while (p?.forwardStates && hasCustomState(p, customState)) {
102+
element = p;
103+
p = p.parent;
104+
}
110105
return element;
111106
}
112107

113108
function findElementByActiveElement(e: MouseEvent): ElementNode | null {
114109
const active = activeElement();
115110
const precision = Config.rendererOptions?.deviceLogicalPixelRatio || 1;
111+
const px = e.clientX / precision;
112+
const py = e.clientY / precision;
116113

117114
if (
118115
active instanceof ElementNode &&
119116
testCollision(
120-
e.clientX,
121-
e.clientY,
122-
((active.lng.absX as number) || 0) * precision,
123-
((active.lng.absY as number) || 0) * precision,
124-
(active.width || 0) * precision,
125-
(active.height || 0) * precision,
117+
px,
118+
py,
119+
(active.lng.absX as number) || 0,
120+
(active.lng.absY as number) || 0,
121+
active.width || 0,
122+
active.height || 0,
126123
)
127124
) {
128125
return active;
@@ -132,14 +129,13 @@ function findElementByActiveElement(e: MouseEvent): ElementNode | null {
132129
while (parent) {
133130
if (
134131
isFunc(parent.onMouseClick) &&
135-
active &&
136132
testCollision(
137-
e.clientX,
138-
e.clientY,
139-
((parent.lng.absX as number) || 0) * precision,
140-
((parent.lng.absY as number) || 0) * precision,
141-
(parent.width || 0) * precision,
142-
(parent.height || 0) * precision,
133+
px,
134+
py,
135+
(parent.lng.absX as number) || 0,
136+
(parent.lng.absY as number) || 0,
137+
parent.width || 0,
138+
parent.height || 0,
143139
)
144140
) {
145141
return parent;
@@ -256,7 +252,6 @@ function isNodeAtPosition(
256252
node: ElementNode | ElementText | TextNode,
257253
x: number,
258254
y: number,
259-
precision: number,
260255
): node is ElementNode {
261256
if (!isElementNode(node)) {
262257
return false;
@@ -268,66 +263,40 @@ function isNodeAtPosition(
268263
testCollision(
269264
x,
270265
y,
271-
((node.lng.absX as number) || 0) * precision,
272-
((node.lng.absY as number) || 0) * precision,
273-
(node.width || 0) * precision,
274-
(node.height || 0) * precision,
266+
(node.lng.absX as number) || 0,
267+
(node.lng.absY as number) || 0,
268+
node.width || 0,
269+
node.height || 0,
275270
)
276271
);
277272
}
278273

279-
function findHighestZIndexNode(nodes: ElementNode[]): ElementNode | undefined {
280-
if (nodes.length === 0) {
281-
return undefined;
282-
}
283-
284-
if (nodes.length === 1) {
285-
return nodes[0];
286-
}
287-
288-
let maxZIndex = -1;
289-
let highestNode: ElementNode | undefined = undefined;
290-
291-
for (const node of nodes) {
292-
const zIndex = node.zIndex ?? -1;
293-
if (zIndex >= maxZIndex) {
294-
maxZIndex = zIndex;
295-
highestNode = node;
296-
}
297-
}
298-
299-
return highestNode;
300-
}
301-
302274
function getChildrenByPosition<TElement extends ElementNode = ElementNode>(
303275
node: TElement,
304276
x: number,
305277
y: number,
306278
): TElement[] {
307279
const result: TElement[] = [];
308280
const precision = Config.rendererOptions?.deviceLogicalPixelRatio || 1;
309-
// Queue for BFS
310-
311-
let queue: (ElementNode | ElementText | TextNode)[] = [node];
312-
313-
while (queue.length > 0) {
314-
// Process nodes at the current level
315-
const currentLevelNodes = queue.filter((currentNode) =>
316-
isNodeAtPosition(currentNode, x, y, precision),
317-
);
318-
319-
if (currentLevelNodes.length === 0) {
320-
break;
321-
}
322-
323-
const highestZIndexNode = findHighestZIndexNode(currentLevelNodes);
324-
325-
if (!highestZIndexNode || isTextNode(highestZIndexNode)) {
326-
break;
281+
const px = x / precision;
282+
const py = y / precision;
283+
284+
let current: ElementNode | ElementText | TextNode | undefined = node;
285+
while (current && isNodeAtPosition(current, px, py)) {
286+
result.push(current as TElement);
287+
288+
let best: ElementNode | undefined;
289+
let bestZ = -Infinity;
290+
for (const child of current.children) {
291+
if (!isNodeAtPosition(child, px, py)) continue;
292+
const z = child.zIndex ?? -1;
293+
if (z >= bestZ) {
294+
bestZ = z;
295+
best = child;
296+
}
327297
}
328-
329-
result.push(highestZIndexNode as TElement);
330-
queue = highestZIndexNode.children;
298+
if (!best) break;
299+
current = best;
331300
}
332301

333302
return result;
@@ -358,60 +327,60 @@ export function useMouse<TApp extends ElementNode = ElementNode>(
358327
runWithOwner(owner, () => handleMouseDown(e));
359328
};
360329

330+
const focusKey = Config.focusStateKey;
331+
361332
makeEventListener(window, 'wheel', handleScroll);
362333
makeEventListener(window, 'click', handleClickContext);
363334
makeEventListener(window, 'mousedown', handleMouseDownContext);
364335
createEffect(() => {
365-
if (scheduled()) {
366-
const result = getChildrenByPosition(myApp, pos.x, pos.y).filter(
367-
(el) =>
368-
!!(
369-
el.onEnter ||
370-
el.onMouseClick ||
371-
el.onFocus ||
372-
el[Config.focusStateKey] ||
373-
(hoverState ? el[hoverState] : false)
374-
),
375-
);
376-
377-
if (result.length) {
378-
let activeElm: ElementNode | undefined = result[result.length - 1];
379-
380-
while (activeElm) {
381-
const elmParent = activeElm.parent;
382-
if (elmParent?.forwardStates) {
383-
activeElm = elmParent;
384-
} else {
385-
break;
386-
}
387-
}
388-
389-
if (!activeElm) {
390-
return;
391-
}
392-
393-
// Update Row & Column Selected property
394-
const activeElmParent = activeElm.parent;
395-
if (activeElmParent?.selected !== undefined) {
396-
activeElmParent.selected =
397-
activeElmParent.children.indexOf(activeElm);
398-
}
399-
400-
if (previousElement && previousElement !== activeElm && hoverState) {
401-
removeCustomStateFromElement(previousElement, hoverState);
402-
}
403-
404-
if (hoverState) {
405-
addCustomStateToElement(activeElm, hoverState);
406-
} else {
407-
activeElm.setFocus();
408-
}
409-
410-
previousElement = activeElm;
411-
} else if (previousElement && hoverState) {
336+
if (!scheduled()) return;
337+
338+
const path = getChildrenByPosition(myApp, pos.x, pos.y);
339+
let activeElm: ElementNode | undefined;
340+
for (let i = path.length - 1; i >= 0; i--) {
341+
const el = path[i]!;
342+
if (
343+
el.onEnter ||
344+
el.onMouseClick ||
345+
el.onFocus ||
346+
el[focusKey] ||
347+
(hoverState && el[hoverState])
348+
) {
349+
activeElm = el;
350+
break;
351+
}
352+
}
353+
354+
if (!activeElm) {
355+
if (previousElement && hoverState) {
412356
removeCustomStateFromElement(previousElement, hoverState);
413357
previousElement = null;
414358
}
359+
return;
360+
}
361+
362+
let p = activeElm.parent;
363+
while (p?.forwardStates) {
364+
activeElm = p;
365+
p = p.parent;
415366
}
367+
368+
// Update Row & Column Selected property
369+
const activeElmParent = activeElm.parent;
370+
if (activeElmParent?.selected !== undefined) {
371+
activeElmParent.selected = activeElmParent.children.indexOf(activeElm);
372+
}
373+
374+
if (previousElement && previousElement !== activeElm && hoverState) {
375+
removeCustomStateFromElement(previousElement, hoverState);
376+
}
377+
378+
if (hoverState) {
379+
addCustomStateToElement(activeElm, hoverState);
380+
} else {
381+
activeElm.setFocus();
382+
}
383+
384+
previousElement = activeElm;
416385
});
417386
}

0 commit comments

Comments
 (0)