perf(primitives): place the select panel in one layout pass
Publish to NPM / Check version changes and publish (push) Successful in 9m38s

`position()` interleaved geometry reads with inline-style writes: the horizontal
branch wrote `minWidth`/`left` and the vertical branch then read `scrollHeight`,
`getComputedStyle`, `offsetHeight` and `offsetTop`, and a second write of
`bottom` was followed by a read of `clientHeight`. Each read after a write
forces a synchronous layout, so a single placement cost at least two — and
placement runs on mount, on resize and while scrolling an expanding panel.

Split it into measure, compute and commit: every read now happens before the
first write, and the result is applied as one object rather than nine separate
property assignments.

The commit also always writes both edges of each axis. A resize can flip the
vertical branch from `bottom` to `top`, and the previous code left the old edge
in place, over-constraining the box.

No behavioural change — the arithmetic is untouched, and the placement tests
pass unchanged.
This commit is contained in:
2026-08-10 04:37:43 +07:00
parent 6da5ecaa83
commit ea96d720f2
3 changed files with 87 additions and 48 deletions
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "https://jsr.io/schema/config-file.v1.json", "$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/primitives", "name": "@robonen/primitives",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "0.0.4", "version": "0.0.5",
"exports": "./src/index.ts" "exports": "./src/index.ts"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/primitives", "name": "@robonen/primitives",
"version": "0.0.4", "version": "0.0.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Collection of UI primitives", "description": "Collection of UI primitives",
"keywords": [ "keywords": [
@@ -57,6 +57,36 @@ function itemTextOf(item: HTMLElement | undefined): HTMLElement | undefined {
return id ? item?.ownerDocument.getElementById(id) ?? undefined : undefined; return id ? item?.ownerDocument.getElementById(id) ?? undefined : undefined;
} }
/**
* Inline styles the wrapper is positioned with. Written as one object and
* committed in a single pass: every geometry read below happens before the
* first write, so the browser performs one layout for the whole placement
* instead of one per interleaved read.
*
* Both edges of each axis are always present. A resize can flip the vertical
* branch, and leaving the previous edge behind would over-constrain the box.
*/
interface WrapperPlacement {
minWidth: string;
left: string;
right: string;
top: string;
bottom: string;
height: string;
minHeight: string;
maxHeight: string;
margin: string;
}
const EMPTY_PLACEMENT: WrapperPlacement = {
minWidth: '', left: '', right: '', top: '', bottom: '',
height: '', minHeight: '', maxHeight: '', margin: '',
};
function commit(wrapper: HTMLElement, placement: Partial<WrapperPlacement>) {
Object.assign(wrapper.style, EMPTY_PLACEMENT, placement);
}
function position() { function position() {
const trigger = rootCtx.triggerElement.value; const trigger = rootCtx.triggerElement.value;
const valueNode = rootCtx.valueElement.value; const valueNode = rootCtx.valueElement.value;
@@ -79,21 +109,46 @@ function position() {
if (!valueNode || !selectedItem || !selectedItemText) { if (!valueNode || !selectedItem || !selectedItemText) {
const rect = trigger.getBoundingClientRect(); const rect = trigger.getBoundingClientRect();
const rightEdge = window.innerWidth - CONTENT_MARGIN; const rightEdge = window.innerWidth - CONTENT_MARGIN;
wrapper.style.minWidth = `${rect.width}px`; commit(wrapper, {
wrapper.style.left = `${clamp(rect.left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - rect.width))}px`; minWidth: `${rect.width}px`,
wrapper.style.top = `${rect.bottom}px`; left: `${clamp(rect.left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - rect.width))}px`,
wrapper.style.maxHeight = `${Math.max(0, window.innerHeight - rect.bottom - CONTENT_MARGIN)}px`; top: `${rect.bottom}px`,
maxHeight: `${Math.max(0, window.innerHeight - rect.bottom - CONTENT_MARGIN)}px`,
});
emit('placed'); emit('placed');
return; return;
} }
// --- Measure: every layout read lives here, before the first write ---
const triggerRect = trigger.getBoundingClientRect(); const triggerRect = trigger.getBoundingClientRect();
// --- Horizontal positioning ---
const contentRect = content.getBoundingClientRect(); const contentRect = content.getBoundingClientRect();
const valueNodeRect = valueNode.getBoundingClientRect(); const valueNodeRect = valueNode.getBoundingClientRect();
const itemTextRect = selectedItemText.getBoundingClientRect(); const itemTextRect = selectedItemText.getBoundingClientRect();
const items = Array.from(
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
);
const itemsHeight = viewport.scrollHeight;
const viewportOffsetTop = viewport.offsetTop;
const viewportOffsetHeight = viewport.offsetHeight;
const contentClientHeight = content.clientHeight;
const selectedItemHeight = selectedItem.offsetHeight;
const selectedItemOffsetTop = selectedItem.offsetTop;
const contentStyles = globalThis.getComputedStyle(content);
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
const viewportStyles = globalThis.getComputedStyle(viewport);
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
// --- Compute ---
const placement: Partial<WrapperPlacement> = {};
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
if (rootCtx.dir.value !== 'rtl') { if (rootCtx.dir.value !== 'rtl') {
const itemTextOffset = itemTextRect.left - contentRect.left; const itemTextOffset = itemTextRect.left - contentRect.left;
const left = valueNodeRect.left - itemTextOffset; const left = valueNodeRect.left - itemTextOffset;
@@ -101,10 +156,9 @@ function position() {
const minContentWidth = triggerRect.width + leftDelta; const minContentWidth = triggerRect.width + leftDelta;
const contentWidth = Math.max(minContentWidth, contentRect.width); const contentWidth = Math.max(minContentWidth, contentRect.width);
const rightEdge = window.innerWidth - CONTENT_MARGIN; const rightEdge = window.innerWidth - CONTENT_MARGIN;
const clampedLeft = clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth));
wrapper.style.minWidth = `${minContentWidth}px`; placement.minWidth = `${minContentWidth}px`;
wrapper.style.left = `${clampedLeft}px`; placement.left = `${clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth))}px`;
} }
else { else {
const itemTextOffset = contentRect.right - itemTextRect.right; const itemTextOffset = contentRect.right - itemTextRect.right;
@@ -113,67 +167,52 @@ function position() {
const minContentWidth = triggerRect.width + rightDelta; const minContentWidth = triggerRect.width + rightDelta;
const contentWidth = Math.max(minContentWidth, contentRect.width); const contentWidth = Math.max(minContentWidth, contentRect.width);
const leftEdge = window.innerWidth - CONTENT_MARGIN; const leftEdge = window.innerWidth - CONTENT_MARGIN;
const clampedRight = clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth));
wrapper.style.minWidth = `${minContentWidth}px`; placement.minWidth = `${minContentWidth}px`;
wrapper.style.right = `${clampedRight}px`; placement.right = `${clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth))}px`;
} }
// --- Vertical positioning ---
const items = Array.from(
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
);
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
const itemsHeight = viewport.scrollHeight;
const contentStyles = globalThis.getComputedStyle(content);
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth; const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth;
const minContentHeight = Math.min(selectedItem.offsetHeight * 5, fullContentHeight);
const viewportStyles = globalThis.getComputedStyle(viewport);
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN; const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle; const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
const selectedItemHalfHeight = selectedItem.offsetHeight / 2; const selectedItemHalfHeight = selectedItemHeight / 2;
const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight; const itemOffsetMiddle = selectedItemOffsetTop + selectedItemHalfHeight;
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle; const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle; const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle; let scrollTop: number | undefined;
if (willAlignWithoutTopOverflow) { if (contentTopToItemMiddle <= topEdgeToTriggerMiddle) {
const isLastItem = selectedItem === items.at(-1); const isLastItem = selectedItem === items.at(-1);
wrapper.style.bottom = '0px'; const viewportOffsetBottom = contentClientHeight - viewportOffsetTop - viewportOffsetHeight;
const viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight;
const clampedTriggerMiddleToBottomEdge = Math.max( const clampedTriggerMiddleToBottomEdge = Math.max(
triggerMiddleToBottomEdge, triggerMiddleToBottomEdge,
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth, selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
); );
const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge;
wrapper.style.height = `${height}px`; placement.bottom = '0px';
placement.height = `${contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge}px`;
} }
else { else {
const isFirstItem = selectedItem === items[0]; const isFirstItem = selectedItem === items[0];
wrapper.style.top = '0px';
const clampedTopEdgeToTriggerMiddle = Math.max( const clampedTopEdgeToTriggerMiddle = Math.max(
topEdgeToTriggerMiddle, topEdgeToTriggerMiddle,
contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight, contentBorderTopWidth + viewportOffsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
); );
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
wrapper.style.height = `${height}px`; placement.top = '0px';
viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop; placement.height = `${clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom}px`;
scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportOffsetTop;
} }
wrapper.style.margin = `${CONTENT_MARGIN}px 0`; placement.margin = `${CONTENT_MARGIN}px 0`;
wrapper.style.minHeight = `${minContentHeight}px`; placement.minHeight = `${Math.min(selectedItemHeight * 5, fullContentHeight)}px`;
wrapper.style.maxHeight = `${availableHeight}px`; placement.maxHeight = `${availableHeight}px`;
// --- Commit ---
commit(wrapper, placement);
if (scrollTop !== undefined) viewport.scrollTop = scrollTop;
emit('placed'); emit('placed');
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true)); requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));