Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/perf-hot-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"bits-ui": patch
---

perf: avoid O(n) work per rendered item on hot paths

- `Select`/`Combobox`: item props now derive from per-item booleans, so moving the highlight or changing the value only rebuilds props (and re-diffs attributes) for the items that actually changed instead of every mounted item
- `Select`/`Combobox` (multiple): selection lookups use a set instead of scanning the value array once per item
- `Calendar`/`RangeCalendar`: `data-today` resolves the local timezone once per calendar rather than once per cell
- `Menu` family: the document-level `pointermove` listener is only attached while keyboard mode is active
- `ScrollArea`, `Slider`, `NavigationMenu`: internal resize observation shares a single `ResizeObserver` across all observed elements
15 changes: 13 additions & 2 deletions packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
getLocalTimeZone,
isSameDay,
isSameMonth,
isToday,
today,
} from "@internationalized/date";
import { DEV } from "esm-env";
import { onMount, untrack } from "svelte";
Expand Down Expand Up @@ -95,6 +95,15 @@ export class CalendarRootState {

readonly opts: CalendarRootStateOpts;
readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value));
/**
* `today()` resolves the local timezone via `Intl.DateTimeFormat#formatToParts`, which is
* far too expensive to run once per rendered cell. We read `months` so this refreshes on
* the same cadence the per-cell computation used to (whenever the visible dates change).
*/
readonly todayDate = $derived.by(() => {
this.months;
return today(getLocalTimeZone());
});
readonly formatter: Formatter;
readonly accessibleHeadingId = useId();
readonly domContext: DOMContext;
Expand Down Expand Up @@ -587,7 +596,9 @@ export class CalendarCellState {
readonly isUnavailable = $derived.by(() =>
this.root.opts.isDateUnavailable.current(this.opts.date.current)
);
readonly isDateToday = $derived.by(() => isToday(this.opts.date.current, getLocalTimeZone()));
readonly isDateToday = $derived.by(() =>
isSameDay(this.opts.date.current, this.root.todayDate)
);
readonly isOutsideMonth = $derived.by(
() => !isSameMonth(this.opts.date.current, this.opts.month.current)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
getLocalTimeZone,
isSameDay,
isSameMonth,
isToday,
today,
} from "@internationalized/date";
import {
attachRef,
Expand Down Expand Up @@ -101,6 +101,15 @@ export class RangeCalendarRootState {
readonly opts: RangeCalendarRootStateOpts;
readonly attachment: RefAttachment;
readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value));
/**
* `today()` resolves the local timezone via `Intl.DateTimeFormat#formatToParts`, which is
* far too expensive to run once per rendered cell. We read `months` so this refreshes on
* the same cadence the per-cell computation used to (whenever the visible dates change).
*/
readonly todayDate = $derived.by(() => {
this.months;
return today(getLocalTimeZone());
});
months: Month<DateValue>[] = $state([]);
announcer: Announcer;
formatter: Formatter;
Expand Down Expand Up @@ -737,7 +746,9 @@ export class RangeCalendarCellState {
readonly isUnavailable = $derived.by(() =>
this.root.opts.isDateUnavailable.current(this.opts.date.current)
);
readonly isDateToday = $derived.by(() => isToday(this.opts.date.current, getLocalTimeZone()));
readonly isDateToday = $derived.by(() =>
isSameDay(this.opts.date.current, this.root.todayDate)
);

readonly isOutsideVisibleMonths = $derived.by(() =>
this.root.isOutsideVisibleMonths(this.opts.date.current)
Expand Down
28 changes: 18 additions & 10 deletions packages/bits-ui/src/lib/bits/select/select.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,11 @@ class SelectMultipleRootState extends SelectBaseRootState {
readonly opts: SelectMultipleRootStateOpts;
readonly isMulti = true as const;
readonly hasValue = $derived.by(() => this.opts.value.current.length > 0);
/**
* `includesItem` is called once per mounted item whenever the value changes, so we keep a
* set around to avoid a linear scan of `value` per item.
*/
readonly #valueSet = $derived.by(() => new Set(this.opts.value.current));

constructor(opts: SelectMultipleRootStateOpts) {
super(opts);
Expand All @@ -348,7 +353,7 @@ class SelectMultipleRootState extends SelectBaseRootState {
}

includesItem(itemValue: string) {
return this.opts.value.current.includes(itemValue);
return this.#valueSet.has(itemValue);
}

toggleItem(itemValue: string, itemLabel: string = itemValue) {
Expand Down Expand Up @@ -1129,6 +1134,15 @@ export class SelectItemState {
readonly isHighlighted = $derived.by(
() => this.root.highlightedValue === this.opts.value.current
);
/**
* Kept as a separate boolean derived so that `props` (and the `mergeProps` /
* attribute-diffing work downstream of it) is only invalidated for the two items whose
* highlighted state actually changed, rather than for every mounted item each time the
* highlighted value moves.
*/
readonly #isHighlightedAndEnabled = $derived.by(
() => this.isHighlighted && !this.opts.disabled.current
);
readonly prevHighlighted = new Previous(() => this.isHighlighted);
mounted = $state(false);

Expand Down Expand Up @@ -1241,17 +1255,11 @@ export class SelectItemState {
({
id: this.opts.id.current,
role: "option",
"aria-selected": this.root.includesItem(this.opts.value.current)
? "true"
: undefined,
"aria-selected": this.isSelected ? "true" : undefined,
"data-value": this.opts.value.current,
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
"data-highlighted":
this.root.highlightedValue === this.opts.value.current &&
!this.opts.disabled.current
? ""
: undefined,
"data-selected": this.root.includesItem(this.opts.value.current) ? "" : undefined,
"data-highlighted": boolToEmptyStrOrUndef(this.#isHighlightedAndEnabled),
"data-selected": boolToEmptyStrOrUndef(this.isSelected),
"data-label": this.opts.label.current,
[this.root.getBitsAttr("item")]: "",
onpointermove: this.onpointermove,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,24 @@ export class IsUsingKeyboard {
on(document, "pointerdown", handlePointer, {
capture: true,
}),
on(document, "pointermove", handlePointer, {
capture: true,
}),
on(document, "keydown", handleKeydown, {
capture: true,
})
);

/**
* `pointermove` fires constantly, so we only keep it attached while we're
* actually in keyboard mode and have something to switch off. Once it has
* flipped `isUsingKeyboard` back to `false`, the listener is removed again
* and mouse movement costs nothing until the next keypress.
*/
$effect(() => {
if (!isUsingKeyboard) return;
return on(document, "pointermove", handlePointer, {
capture: true,
});
});

// Don't forget to spread and call twice.
return executeCallbacks(...callbacksToDispose);
});
Expand Down
58 changes: 55 additions & 3 deletions packages/bits-ui/src/lib/internal/svelte-resize-observer.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,58 @@
import type { Getter } from "svelte-toolbelt";

type ResizeCallback = () => void;

/**
* A single `ResizeObserver` shared by every observed element in the app. Components like
* `ScrollArea` create a handful of observers each, and multiple of them frequently watch the
* same node, so allocating one observer per call site adds up quickly on pages with many
* instances.
*/
let sharedObserver: ResizeObserver | null = null;
const observedNodes = new WeakMap<Element, Set<ResizeCallback>>();

function getSharedObserver(): ResizeObserver {
sharedObserver ??= new ResizeObserver((entries) => {
for (const entry of entries) {
const callbacks = observedNodes.get(entry.target);
if (!callbacks) continue;
// copy so a callback that unsubscribes doesn't mutate the set mid-iteration
for (const callback of [...callbacks]) callback();
}
});
return sharedObserver;
}

/**
* Observes `node` for size changes, returning a function that stops observing.
*
* Like a dedicated `ResizeObserver`, the callback fires once shortly after subscribing.
*/
function observeResize(node: Element, callback: ResizeCallback): () => void {
let callbacks = observedNodes.get(node);

if (callbacks === undefined) {
callbacks = new Set();
observedNodes.set(node, callbacks);
callbacks.add(callback);
getSharedObserver().observe(node);
} else {
callbacks.add(callback);
// `observe` only delivers its initial entry for newly observed targets, so a second
// subscriber to an already-observed node has to be kicked off manually.
callback();
}

return () => {
const current = observedNodes.get(node);
if (!current) return;
current.delete(callback);
if (current.size) return;
observedNodes.delete(node);
sharedObserver?.unobserve(node);
};
}

export class SvelteResizeObserver {
#node: Getter<HTMLElement | null>;
#onResize: () => void;
Expand All @@ -14,15 +67,14 @@ export class SvelteResizeObserver {
let rAF = 0;
const _node = this.#node();
if (!_node) return;
const resizeObserver = new ResizeObserver(() => {
const unobserve = observeResize(_node, () => {
cancelAnimationFrame(rAF);
rAF = window.requestAnimationFrame(this.#onResize);
});

resizeObserver.observe(_node);
return () => {
window.cancelAnimationFrame(rAF);
resizeObserver.unobserve(_node);
unobserve();
};
}
}
Loading