From 7439c0e85fbcba3a9db2679c5241885cf7c7f0f7 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 15:07:36 -0600 Subject: [PATCH 1/6] fix(desktop): restore emoji recents Remove the eager emoji-mart initialization that can poison its module-global Frequent category, repair existing empty indexes, and refresh the scoped quick reaction ranking immediately after every selection. Add fail-before regression coverage for both the picker lifecycle and mounted quick-reaction reranking. Co-authored-by: Carl Signed-off-by: Wes --- .../features/custom-emoji/ui/EmojiPicker.tsx | 30 +++++-------- .../ui/useQuickReactionEmojis.test.mjs | 35 ++++++++++++++- .../messages/ui/useQuickReactionEmojis.ts | 43 ++++++++++++++++++- desktop/tests/e2e/custom-emoji.spec.ts | 35 +++++++++++++++ 4 files changed, 120 insertions(+), 23 deletions(-) diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index b3f1d0de656..3dbc677ff90 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -1,32 +1,22 @@ 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); +// 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. } -warmEmojiIndex(); /** * Reach into the `em-emoji-picker` shadow root and disable spellcheck, diff --git a/desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs b/desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs index dfc286bad54..5ffea7cc559 100644 --- a/desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs +++ b/desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs @@ -1,7 +1,20 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { JSDOM } from "jsdom"; -import { resolveQuickReactionEmojis } from "./useQuickReactionEmojis.ts"; +import { + recordQuickReactionEmoji, + resolveQuickReactionEmojis, + useQuickReactionEmojis, +} from "./useQuickReactionEmojis.ts"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.CustomEvent = dom.window.CustomEvent; +globalThis.HTMLElement = dom.window.HTMLElement; function entry(emoji) { return { emoji }; @@ -40,3 +53,23 @@ test("quick reactions skip stale custom emoji before applying the limit", () => [":shipit:", "🔥", "👍", "❤️"], ); }); + +test("recording an emoji updates mounted quick reactions immediately", async () => { + window.localStorage.clear(); + const { act, cleanup, renderHook } = await import("@testing-library/react"); + + try { + const { result } = renderHook(() => useQuickReactionEmojis()); + assert.deepEqual(result.current, ["👍", "❤️", "😂", "🎉"]); + + act(() => recordQuickReactionEmoji("🔥")); + assert.deepEqual(result.current, ["🔥", "👍", "❤️", "😂"]); + + act(() => recordQuickReactionEmoji("🎉")); + act(() => recordQuickReactionEmoji("🎉")); + assert.deepEqual(result.current, ["🎉", "🔥", "👍", "❤️"]); + } finally { + cleanup(); + window.localStorage.clear(); + } +}); diff --git a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts index 457c659141d..14d4c4084e8 100644 --- a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts +++ b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts @@ -7,6 +7,7 @@ import { import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; const QUICK_REACTION_STORAGE_KEY = "buzz.quick-reaction-emojis.v1"; +const QUICK_REACTION_UPDATED_EVENT = "buzz:quick-reaction-emojis-updated"; const DEFAULT_QUICK_REACTIONS = ["👍", "❤️", "😂", "🎉"] as const; const MAX_STORED_REACTIONS = 24; const sessionQuickReactionEmojis = new Map(); @@ -207,6 +208,24 @@ function getSessionQuickReactionEmojis( return emojis; } +function invalidateSessionQuickReactions(communityScope: string | null) { + const prefix = `${communityScope ?? "global"}:`; + for (const key of sessionQuickReactionEmojis.keys()) { + if (key.startsWith(prefix)) { + sessionQuickReactionEmojis.delete(key); + } + } +} + +function notifyQuickReactionUpdate(communityScope: string | null) { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(QUICK_REACTION_UPDATED_EVENT, { + detail: { communityScope }, + }), + ); +} + export function recordQuickReactionEmoji(emoji: string) { const trimmed = emoji.trim(); if (!trimmed) return; @@ -226,9 +245,9 @@ export function recordQuickReactionEmoji(emoji: string) { }); } - // Keep the current hover tray stable; the stored recents apply on reload or - // when another tab updates this community's quick reactions. writeQuickReactionEntries(entries, storageKey); + invalidateSessionQuickReactions(communityScope); + notifyQuickReactionUpdate(communityScope); } export function useQuickReactionEmojis( @@ -263,13 +282,33 @@ export function useQuickReactionEmojis( } }; + const handleLocalUpdate = (event: Event) => { + if ( + event instanceof CustomEvent && + event.detail?.communityScope === communityScope + ) { + setEmojis( + getSessionQuickReactionEmojis( + limit, + communityScope, + customEmojiCacheKey, + ), + ); + } + }; + window.addEventListener("storage", handleStorage); + window.addEventListener(QUICK_REACTION_UPDATED_EVENT, handleLocalUpdate); setEmojis( getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey), ); return () => { window.removeEventListener("storage", handleStorage); + window.removeEventListener( + QUICK_REACTION_UPDATED_EVENT, + handleLocalUpdate, + ); }; }, [customEmojiCacheKey, limit, communityScope]); 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, }) => { From 9fd631bc5be1227d91b18075429efe3de0c7b882 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 16:08:43 -0600 Subject: [PATCH 2/6] test(desktop): expect refreshed custom quick reaction The recents fix intentionally updates the mounted quick-reaction tray after a picker selection. Update the existing custom-emoji smoke assertion to expect the newly selected custom emoji in that tray. Co-authored-by: Carl Signed-off-by: Wes --- desktop/tests/e2e/custom-emoji.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 16ca3900ece..5315ba2288a 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -406,7 +406,7 @@ test("reacting with a custom emoji renders via the loopback media proxy", async .toBe(true); await expect( messageActionBar(row).locator("button[title=':react:']"), - ).toHaveCount(0); + ).toHaveCount(1); await expect(messageReactionTrigger(row)).not.toHaveClass( SELECTED_ACTION_CLASS, ); From 6a94680966526555b84c57f5d0a21d4a7bf43407 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 16:17:23 -0600 Subject: [PATCH 3/6] fix(desktop): preserve quick reaction tray stability Narrow the emoji recents repair to the full picker. Restore the established mounted quick-reaction tray behavior and its existing smoke assertion. Co-authored-by: Carl Signed-off-by: Wes --- .../ui/useQuickReactionEmojis.test.mjs | 35 +-------------- .../messages/ui/useQuickReactionEmojis.ts | 43 +------------------ desktop/tests/e2e/custom-emoji.spec.ts | 2 +- 3 files changed, 4 insertions(+), 76 deletions(-) diff --git a/desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs b/desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs index 5ffea7cc559..dfc286bad54 100644 --- a/desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs +++ b/desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs @@ -1,20 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { JSDOM } from "jsdom"; -import { - recordQuickReactionEmoji, - resolveQuickReactionEmojis, - useQuickReactionEmojis, -} from "./useQuickReactionEmojis.ts"; - -const dom = new JSDOM("", { - url: "http://localhost", -}); -globalThis.window = dom.window; -globalThis.document = dom.window.document; -globalThis.CustomEvent = dom.window.CustomEvent; -globalThis.HTMLElement = dom.window.HTMLElement; +import { resolveQuickReactionEmojis } from "./useQuickReactionEmojis.ts"; function entry(emoji) { return { emoji }; @@ -53,23 +40,3 @@ test("quick reactions skip stale custom emoji before applying the limit", () => [":shipit:", "🔥", "👍", "❤️"], ); }); - -test("recording an emoji updates mounted quick reactions immediately", async () => { - window.localStorage.clear(); - const { act, cleanup, renderHook } = await import("@testing-library/react"); - - try { - const { result } = renderHook(() => useQuickReactionEmojis()); - assert.deepEqual(result.current, ["👍", "❤️", "😂", "🎉"]); - - act(() => recordQuickReactionEmoji("🔥")); - assert.deepEqual(result.current, ["🔥", "👍", "❤️", "😂"]); - - act(() => recordQuickReactionEmoji("🎉")); - act(() => recordQuickReactionEmoji("🎉")); - assert.deepEqual(result.current, ["🎉", "🔥", "👍", "❤️"]); - } finally { - cleanup(); - window.localStorage.clear(); - } -}); diff --git a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts index 14d4c4084e8..457c659141d 100644 --- a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts +++ b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts @@ -7,7 +7,6 @@ import { import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; const QUICK_REACTION_STORAGE_KEY = "buzz.quick-reaction-emojis.v1"; -const QUICK_REACTION_UPDATED_EVENT = "buzz:quick-reaction-emojis-updated"; const DEFAULT_QUICK_REACTIONS = ["👍", "❤️", "😂", "🎉"] as const; const MAX_STORED_REACTIONS = 24; const sessionQuickReactionEmojis = new Map(); @@ -208,24 +207,6 @@ function getSessionQuickReactionEmojis( return emojis; } -function invalidateSessionQuickReactions(communityScope: string | null) { - const prefix = `${communityScope ?? "global"}:`; - for (const key of sessionQuickReactionEmojis.keys()) { - if (key.startsWith(prefix)) { - sessionQuickReactionEmojis.delete(key); - } - } -} - -function notifyQuickReactionUpdate(communityScope: string | null) { - if (typeof window === "undefined") return; - window.dispatchEvent( - new CustomEvent(QUICK_REACTION_UPDATED_EVENT, { - detail: { communityScope }, - }), - ); -} - export function recordQuickReactionEmoji(emoji: string) { const trimmed = emoji.trim(); if (!trimmed) return; @@ -245,9 +226,9 @@ export function recordQuickReactionEmoji(emoji: string) { }); } + // Keep the current hover tray stable; the stored recents apply on reload or + // when another tab updates this community's quick reactions. writeQuickReactionEntries(entries, storageKey); - invalidateSessionQuickReactions(communityScope); - notifyQuickReactionUpdate(communityScope); } export function useQuickReactionEmojis( @@ -282,33 +263,13 @@ export function useQuickReactionEmojis( } }; - const handleLocalUpdate = (event: Event) => { - if ( - event instanceof CustomEvent && - event.detail?.communityScope === communityScope - ) { - setEmojis( - getSessionQuickReactionEmojis( - limit, - communityScope, - customEmojiCacheKey, - ), - ); - } - }; - window.addEventListener("storage", handleStorage); - window.addEventListener(QUICK_REACTION_UPDATED_EVENT, handleLocalUpdate); setEmojis( getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey), ); return () => { window.removeEventListener("storage", handleStorage); - window.removeEventListener( - QUICK_REACTION_UPDATED_EVENT, - handleLocalUpdate, - ); }; }, [customEmojiCacheKey, limit, communityScope]); diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 5315ba2288a..16ca3900ece 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -406,7 +406,7 @@ test("reacting with a custom emoji renders via the loopback media proxy", async .toBe(true); await expect( messageActionBar(row).locator("button[title=':react:']"), - ).toHaveCount(1); + ).toHaveCount(0); await expect(messageReactionTrigger(row)).not.toHaveClass( SELECTED_ACTION_CLASS, ); From 5f83caf71aa6658e4a73d7b4fa0ac21c0179684b Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 16:24:48 -0600 Subject: [PATCH 4/6] fix(desktop): retain emoji picker prewarm Normalize the poisoned empty recents state before the existing idle emoji-mart initialization, preserving first-open responsiveness while keeping Frequently used available. Co-authored-by: Carl Signed-off-by: Wes --- .../features/custom-emoji/ui/EmojiPicker.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index 3dbc677ff90..fc744bb4478 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -1,5 +1,6 @@ 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"; @@ -18,6 +19,25 @@ try { // emoji-mart also tolerates unavailable storage; picker selection still works. } +// emoji-mart synchronously builds its search index inside `init`. Preserve the +// idle prewarm so the first picker open does not pay that cost. Normalizing the +// poisoned persisted state above ensures this initialization cannot remove the +// module-global Frequent category. +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(); + /** * Reach into the `em-emoji-picker` shadow root and disable spellcheck, * autocorrect, and autocapitalize on its search input. When `autoFocus` is From a80f690413a79a751af6606e8c1c6fbceae6a3cf Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 16:52:27 -0600 Subject: [PATCH 5/6] test(desktop): guard emoji index idle prewarm Tie the data consumed by EmojiPicker to a startup module that normalizes poisoned recents before scheduling emoji-mart initialization at idle. Add a deterministic test for that ordering and callback contract so removing the prewarm fails without relying on wall-clock latency. Co-authored-by: Carl Signed-off-by: Wes --- .../features/custom-emoji/ui/EmojiPicker.tsx | 37 +------------------ .../custom-emoji/ui/emojiMartPrewarm.test.mjs | 35 ++++++++++++++++++ .../custom-emoji/ui/emojiMartPrewarm.ts | 28 ++++++++++++++ desktop/test-loader-hooks.mjs | 2 +- 4 files changed, 66 insertions(+), 36 deletions(-) create mode 100644 desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs create mode 100644 desktop/src/features/custom-emoji/ui/emojiMartPrewarm.ts diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index fc744bb4478..92d640afa9f 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -1,42 +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 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`. Preserve the -// idle prewarm so the first picker open does not pay that cost. Normalizing the -// poisoned persisted state above ensures this initialization cannot remove the -// module-global Frequent category. -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, @@ -138,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..472f164e001 --- /dev/null +++ b/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +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("normalizes poisoned recents before scheduling the emoji index prewarm", () => { + 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", ], From 98188797e219db20670b46379a240493ff8f4cbe Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 19 Aug 2026 11:26:54 -0600 Subject: [PATCH 6/6] test(desktop): guard emoji picker prewarm boundary Assert that the production picker imports its data through the prewarm module and passes that binding to emoji-mart, preventing a direct data import from silently bypassing startup index warming. Co-authored-by: Carl Signed-off-by: Wes --- .../custom-emoji/ui/emojiMartPrewarm.test.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs b/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs index 472f164e001..4d7534f4364 100644 --- a/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs +++ b/desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs @@ -1,6 +1,12 @@ 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"], @@ -22,6 +28,15 @@ 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);