diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index b3f1d0de656..92d640afa9f 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -1,32 +1,9 @@ -import data from "@emoji-mart/data"; import Picker from "@emoji-mart/react"; -import { init } from "emoji-mart"; import * as React from "react"; import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; - -// emoji-mart builds its searchable index synchronously inside `init`, which -// `` calls on mount — so the first reaction popover open paid the full -// ~1.8k-emoji index build and froze the cursor. Warm `init({ data })` once at -// idle so the index is prebuilt; `init` is a no-op after the first call (its -// `Data` singleton guards the rebuild), so the Picker's mount-time `init` skips -// the heavy work. Search still reads the prebuilt index — no first-keystroke -// hitch. Module-level so it fires regardless of when a picker first mounts. -let warmStarted = false; -function warmEmojiIndex() { - if (warmStarted) { - return; - } - warmStarted = true; - const warm = () => void init({ data }); - if (typeof window !== "undefined" && "requestIdleCallback" in window) { - window.requestIdleCallback(warm, { timeout: 1_500 }); - } else { - globalThis.setTimeout(warm, 250); - } -} -warmEmojiIndex(); +import { emojiMartData } from "@/features/custom-emoji/ui/emojiMartPrewarm"; /** * Reach into the `em-emoji-picker` shadow root and disable spellcheck, @@ -128,7 +105,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({ { // Standard emoji carry a `native` glyph. Custom emoji don't — emit diff --git a/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs b/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs new file mode 100644 index 00000000000..4d7534f4364 --- /dev/null +++ b/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const pickerSource = await readFile( + new URL("./EmojiPicker.tsx", import.meta.url), + "utf8", +); + +const entries = new Map([ + ["emoji-mart.frequently", "{}"], + ["emoji-mart.last", "thumbsup"], +]); +const idleCalls = []; +const initCalls = []; + +globalThis.window = { + localStorage: { + getItem: (key) => entries.get(key) ?? null, + removeItem: (key) => entries.delete(key), + }, + requestIdleCallback: (callback, options) => { + idleCalls.push({ callback, options }); + return 1; + }, +}; +globalThis.__BUZZ_TEST_EMOJI_MART_INIT__ = (...args) => initCalls.push(args); + +const { emojiMartData } = await import("./emojiMartPrewarm.ts"); + +test("EmojiPicker consumes data through the prewarm module", () => { + assert.match( + pickerSource, + /import\s*{\s*emojiMartData\s*}\s*from\s*["']@\/features\/custom-emoji\/ui\/emojiMartPrewarm["'];/, + ); + assert.match(pickerSource, / { + assert.equal(entries.has("emoji-mart.frequently"), false); + assert.equal(entries.has("emoji-mart.last"), false); + assert.equal(initCalls.length, 0); + assert.equal(idleCalls.length, 1); + assert.deepEqual(idleCalls[0].options, { timeout: 1_500 }); + + idleCalls[0].callback(); + assert.equal(initCalls.length, 1); + assert.equal(initCalls[0][0].data, emojiMartData); +}); diff --git a/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.ts b/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.ts new file mode 100644 index 00000000000..3c1e81733e1 --- /dev/null +++ b/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.ts @@ -0,0 +1,28 @@ +import data from "@emoji-mart/data"; +import { init } from "emoji-mart"; + +// emoji-mart treats a persisted empty object differently from a missing index: +// a missing index gets its default Frequent row, while `{}` removes the entire +// category from the module-global picker data. Normalize that poisoned state +// before the first picker initializes. +try { + if (window.localStorage.getItem("emoji-mart.frequently") === "{}") { + window.localStorage.removeItem("emoji-mart.frequently"); + window.localStorage.removeItem("emoji-mart.last"); + } +} catch { + // emoji-mart also tolerates unavailable storage; picker selection still works. +} + +// emoji-mart synchronously builds its search index inside `init`. Warm it at +// idle so the first picker open does not pay that cost. This module also owns +// the data passed to every Picker, making the prewarm part of that import path +// rather than a disconnected best-effort call. +const warm = () => void init({ data }); +if (typeof window !== "undefined" && "requestIdleCallback" in window) { + window.requestIdleCallback(warm, { timeout: 1_500 }); +} else { + globalThis.setTimeout(warm, 250); +} + +export { data as emojiMartData }; diff --git a/desktop/test-loader-hooks.mjs b/desktop/test-loader-hooks.mjs index ede5cbedae6..06c44ae2130 100644 --- a/desktop/test-loader-hooks.mjs +++ b/desktop/test-loader-hooks.mjs @@ -56,7 +56,7 @@ function resolveSourcePath(basePath) { const stubModules = new Map([ [ "emoji-mart", - "export const init = () => {};\n" + + "export const init = (...args) => globalThis.__BUZZ_TEST_EMOJI_MART_INIT__?.(...args);\n" + "export const SearchIndex = { search: async () => [] };\n" + "export default {};\n", ], diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index a48799bcb2d..16ca3900ece 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -309,6 +309,41 @@ test("message quick reaction tray stays neutral after selecting a tray emoji", a ); }); +test("emoji picker keeps Frequently used live within the app session", async ({ + page, +}) => { + await page.addInitScript(() => { + window.localStorage.setItem("emoji-mart.frequently", "{}"); + window.localStorage.removeItem("emoji-mart.last"); + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + const row = reactionTargetRow(page); + await expect(row).toBeVisible(); + await row.hover(); + await row.getByLabel("Open reactions").click(); + + let picker = page.locator("em-emoji-picker"); + await expect( + picker.getByRole("button", { name: "Frequently used" }), + ).toBeVisible(); + + await picker.locator("input[type='search']").fill("unicorn"); + await picker.getByRole("button", { name: "🦄" }).first().click(); + await expect(row.getByLabel("Toggle 🦄 reaction")).toBeVisible(); + + await row.hover(); + await row.getByLabel("Open reactions").click(); + picker = page.locator("em-emoji-picker"); + await picker.getByRole("button", { name: "Frequently used" }).click(); + await expect( + picker.getByRole("button", { name: "🦄" }).first(), + ).toBeVisible(); +}); + test("reacting with a custom emoji renders via the loopback media proxy", async ({ page, }) => {