Skip to content

Commit

Permalink
Improve collection performance with multiple items (#3295)
Browse files Browse the repository at this point in the history
Closes #3294
  • Loading branch information
diegohaz committed Jan 2, 2024
1 parent 56871b8 commit aa7d558
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 120 deletions.
9 changes: 9 additions & 0 deletions .changeset/3295-collection-perf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@ariakit/core": patch
"@ariakit/react-core": patch
"@ariakit/react": patch
---

Improved performance of large collections

Components like [`MenuItem`](https://ariakit.org/reference/menu-item), [`ComboboxItem`](https://ariakit.org/reference/combobox-item), and [`SelectItem`](https://ariakit.org/reference/select-item) should now offer improved performance when rendering large collections.
51 changes: 23 additions & 28 deletions examples/dialog-framer-motion/test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,27 @@
import { version } from "react";
import { click, press, q, waitFor } from "@ariakit/test";

const is17 = version.startsWith("17");

describe.skipIf(is17)("dialog-framer-motion", () => {
test("show/hide on click", async () => {
expect(q.dialog()).not.toBeInTheDocument();
await click(q.button("Show modal"));
expect(q.dialog()).toBeVisible();
expect(q.button("OK")).toHaveFocus();
await click(q.button("OK"));
expect(q.dialog()).toBeVisible();
expect(q.button("Show modal")).toHaveFocus();
await waitFor(() => expect(q.dialog()).not.toBeInTheDocument());
expect(q.button("Show modal")).toHaveFocus();
});
test("show/hide on click", async () => {
expect(q.dialog()).not.toBeInTheDocument();
await click(q.button("Show modal"));
expect(q.dialog()).toBeVisible();
expect(q.button("OK")).toHaveFocus();
await click(q.button("OK"));
expect(q.dialog()).toBeVisible();
expect(q.button("Show modal")).toHaveFocus();
await waitFor(() => expect(q.dialog()).not.toBeInTheDocument());
expect(q.button("Show modal")).toHaveFocus();
});

test("prevent body scroll", async () => {
expect(document.body).not.toHaveStyle({ overflow: "hidden" });
await press.Tab();
await press.Enter();
expect(document.body).toHaveStyle({ overflow: "hidden" });
expect(q.dialog()).toBeVisible();
expect(document.body).toHaveStyle({ overflow: "hidden" });
await press.Enter();
expect(q.dialog()).toBeVisible();
expect(document.body).toHaveStyle({ overflow: "hidden" });
await waitFor(() => expect(q.dialog()).not.toBeInTheDocument());
expect(document.body).not.toHaveStyle({ overflow: "hidden" });
});
test("prevent body scroll", async () => {
expect(document.body).not.toHaveStyle({ overflow: "hidden" });
await press.Tab();
await press.Enter();
expect(document.body).toHaveStyle({ overflow: "hidden" });
expect(q.dialog()).toBeVisible();
expect(document.body).toHaveStyle({ overflow: "hidden" });
await press.Enter();
expect(q.dialog()).toBeVisible();
expect(document.body).toHaveStyle({ overflow: "hidden" });
await waitFor(() => expect(q.dialog()).not.toBeInTheDocument());
expect(document.body).not.toHaveStyle({ overflow: "hidden" });
});
103 changes: 49 additions & 54 deletions examples/menu-framer-motion/test.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,56 @@
import { version } from "react";
import { click, press, q, waitFor } from "@ariakit/test";

const is17 = version.startsWith("17");

describe.skipIf(is17)("menu-framer-motion", () => {
test("show/hide on click", async () => {
expect(q.menu()).not.toBeInTheDocument();
await click(q.button("Options"));
expect(q.menu()).toBeVisible();
expect(q.menu()).toHaveFocus();
await click(q.button("Options"));
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});
test("show/hide on click", async () => {
expect(q.menu()).not.toBeInTheDocument();
await click(q.button("Options"));
expect(q.menu()).toBeVisible();
expect(q.menu()).toHaveFocus();
await click(q.button("Options"));
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});

test("show/hide on enter", async () => {
expect(q.menu()).not.toBeInTheDocument();
await press.Tab();
await press.Enter();
expect(q.menu()).toBeVisible();
expect(q.menuitem("Edit")).toHaveFocus();
await press.ShiftTab();
await press.Enter();
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});
test("show/hide on enter", async () => {
expect(q.menu()).not.toBeInTheDocument();
await press.Tab();
await press.Enter();
expect(q.menu()).toBeVisible();
expect(q.menuitem("Edit")).toHaveFocus();
await press.ShiftTab();
await press.Enter();
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});

test("show/hide on space", async () => {
expect(q.menu()).not.toBeInTheDocument();
await press.Tab();
await press.Space();
expect(q.menu()).toBeVisible();
expect(q.menuitem("Edit")).toHaveFocus();
await press.ShiftTab();
await press.Space();
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});
test("show/hide on space", async () => {
expect(q.menu()).not.toBeInTheDocument();
await press.Tab();
await press.Space();
expect(q.menu()).toBeVisible();
expect(q.menuitem("Edit")).toHaveFocus();
await press.ShiftTab();
await press.Space();
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});

test("hide on esc", async () => {
expect(q.menu()).not.toBeInTheDocument();
await click(q.button("Options"));
await press.Escape();
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});
test("hide on esc", async () => {
expect(q.menu()).not.toBeInTheDocument();
await click(q.button("Options"));
await press.Escape();
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});

test("hide on click outside", async () => {
expect(q.menu()).not.toBeInTheDocument();
await click(q.button("Options"));
await click(document.body);
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});
test("hide on click outside", async () => {
expect(q.menu()).not.toBeInTheDocument();
await click(q.button("Options"));
await click(document.body);
expect(q.button("Options")).toHaveFocus();
expect(q.menu()).toBeVisible();
await waitFor(() => expect(q.menu()).not.toBeInTheDocument());
});
55 changes: 25 additions & 30 deletions examples/tooltip-framer-motion/test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { version } from "react";
import { click, hover, press, q, waitFor } from "@ariakit/test";

const hoverOutside = async () => {
Expand All @@ -7,35 +6,31 @@ const hoverOutside = async () => {
await hover(document.body, { clientX: 20, clientY: 20 });
};

const is17 = version.startsWith("17");

describe.skipIf(is17)("tooltip-framer-motion", () => {
test("show tooltip on hover", async () => {
expect(q.tooltip()).not.toBeInTheDocument();
await hover(q.link());
await waitFor(() => expect(q.tooltip()).toBeVisible());
await hoverOutside();
expect(q.tooltip()).toBeVisible();
await waitFor(() => expect(q.tooltip()).not.toBeInTheDocument());
});
test("show tooltip on hover", async () => {
expect(q.tooltip()).not.toBeInTheDocument();
await hover(q.link());
await waitFor(() => expect(q.tooltip()).toBeVisible());
await hoverOutside();
expect(q.tooltip()).toBeVisible();
await waitFor(() => expect(q.tooltip()).not.toBeInTheDocument());
});

test("show tooltip on focus", async () => {
expect(q.tooltip()).not.toBeInTheDocument();
await press.Tab();
expect(q.tooltip()).toBeVisible();
await click(document.body);
expect(q.tooltip()).toBeVisible();
await waitFor(() => expect(q.tooltip()).not.toBeInTheDocument());
});
test("show tooltip on focus", async () => {
expect(q.tooltip()).not.toBeInTheDocument();
await press.Tab();
expect(q.tooltip()).toBeVisible();
await click(document.body);
expect(q.tooltip()).toBeVisible();
await waitFor(() => expect(q.tooltip()).not.toBeInTheDocument());
});

test("click on tooltip and press esc", async () => {
expect(q.tooltip()).not.toBeInTheDocument();
await hover(q.link());
await waitFor(() => expect(q.tooltip()).toBeVisible());
await click(q.tooltip()!);
expect(q.tooltip()).toBeVisible();
await press.Escape();
expect(q.link()).toHaveFocus();
await waitFor(() => expect(q.tooltip()).not.toBeInTheDocument());
});
test("click on tooltip and press esc", async () => {
expect(q.tooltip()).not.toBeInTheDocument();
await hover(q.link());
await waitFor(() => expect(q.tooltip()).toBeVisible());
await click(q.tooltip()!);
expect(q.tooltip()).toBeVisible();
await press.Escape();
expect(q.link()).toHaveFocus();
await waitFor(() => expect(q.tooltip()).not.toBeInTheDocument());
});
17 changes: 15 additions & 2 deletions packages/ariakit-core/src/collection/collection-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function createCollectionStore<
const syncPrivateStore = getPrivateStore<T>(props.store);

const privateStore = createStore(
{ renderedItems: initialState.renderedItems },
{ items, renderedItems: initialState.renderedItems },
syncPrivateStore,
);

Expand All @@ -108,6 +108,15 @@ export function createCollectionStore<

setup(collection, () => init(privateStore));

// Use the private store to register items and then batch the changes to the
// public store so we don't trigger multiple updates on the store when adding
// multiple items.
setup(privateStore, () => {
return batch(privateStore, ["items"], (state) => {
collection.setState("items", state.items);
});
});

setup(privateStore, () => {
return batch(privateStore, ["renderedItems"], (state) => {
let firstRun = true;
Expand Down Expand Up @@ -191,7 +200,11 @@ export function createCollectionStore<
};

const registerItem: CollectionStore<T>["registerItem"] = (item) =>
mergeItem(item, (getItems) => collection.setState("items", getItems), true);
mergeItem(
item,
(getItems) => privateStore.setState("items", getItems),
true,
);

return {
...collection,
Expand Down
12 changes: 7 additions & 5 deletions packages/ariakit-react-core/src/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,14 @@ export const useSelect = createHook<SelectOptions>(
const labelId = store.useState((state) => state.labelElement?.id);
const label = props["aria-label"];
const labelledBy = props["aria-labelledby"] || labelId;
const items = store.useState("items");
const values = useMemo(
const items = store.useState((state) => {
if (!name) return;
return state.items;
});
const values = useMemo(() => {
// Filter out items without value and duplicate values.
() => [...new Set(items.map((i) => i.value!).filter((v) => v != null))],
[items],
);
return [...new Set(items?.map((i) => i.value!).filter((v) => v != null))];
}, [items]);

// Renders a native select element with the same value as the select so we
// support browser autofill. When the native select value changes, the
Expand Down
14 changes: 13 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { defineConfig } from "vitest/config";
import { version } from "react";
import { configDefaults, defineConfig } from "vitest/config";

const excludeFromReact17 = [
"examples/form-callback-queue",
"examples/*-framer-motion/**",
];

const isReact17 = version.startsWith("17");

export default defineConfig({
test: {
globals: true,
watch: false,
environment: "jsdom",
include: ["**/*test.{ts,tsx}"],
exclude: [
...configDefaults.exclude,
...(isReact17 ? excludeFromReact17 : []),
],
setupFiles: ["vitest.setup.ts"],
coverage: {
include: ["packages"],
Expand Down

0 comments on commit aa7d558

Please sign in to comment.