perf(bundle): code-split heavy routes and the emoji picker and add cacheable vendor chunks to shrink first-load

This commit is contained in:
heaven 2026-05-29 02:35:33 +03:00
parent 297b55f693
commit 067417050c
13 changed files with 305 additions and 158 deletions

11
package-lock.json generated
View file

@ -34,7 +34,6 @@
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"classnames": "2.3.2",
"dateformat": "5.0.3",
"dayjs": "1.11.10",
"domhandler": "5.0.3",
"emojibase": "15.3.1",
@ -116,7 +115,7 @@
"wait-on": "9.0.10"
},
"engines": {
"node": ">=22.0.0"
"node": ">=22.12.0"
}
},
"node_modules/@ampproject/remapping": {
@ -8266,14 +8265,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dateformat": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-5.0.3.tgz",
"integrity": "sha512-Kvr6HmPXUMerlLcLF+Pwq3K7apHpYmGDVqrxcDasBg86UcKeTSNWbEzU8bwdXnxnR44FtMhJAxI4Bov6Y/KUfA==",
"engines": {
"node": ">=12.20"
}
},
"node_modules/dayjs": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",

View file

@ -76,7 +76,6 @@
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"classnames": "2.3.2",
"dateformat": "5.0.3",
"dayjs": "1.11.10",
"domhandler": "5.0.3",
"emojibase": "15.3.1",

View file

@ -11,7 +11,8 @@ import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji';
import { IEmoji } from '../../../plugins/emoji';
import { emojis } from '../../../plugins/emoji-data';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';

View file

@ -15,7 +15,8 @@ import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
import { IEmoji } from '../../plugins/emoji';
import { emojiGroups, emojis } from '../../plugins/emoji-data';
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';

View file

@ -1,9 +1,17 @@
import React from 'react';
import React, { Suspense } from 'react';
import { Outlet, useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { isNativePlatform } from '../../utils/capacitor';
import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths';
import { MobileTabsPager } from './MobileTabsPager';
// MobileTabsPager is the ONLY static importer of the Channels and Bots feature
// modules. It renders only on `mobile && native` (below), so lazy-loading it
// here removes the static edge that otherwise pinned Channels + Bots into the
// boot bundle for every target. On native the chunk streams from the local APK
// filesystem (no network), so a null Suspense fallback is imperceptible.
const MobileTabsPager = React.lazy(() =>
import('./MobileTabsPager').then((m) => ({ default: m.MobileTabsPager }))
);
// Router-level wrapper around the three listing tabs (/direct/,
// /channels/, /bots/). When all of (mobile breakpoint, Capacitor
@ -44,5 +52,9 @@ export function MobileTabsLayout() {
if (!(mobile && native) || !onListingRoot) {
return <Outlet />;
}
return <MobileTabsPager />;
return (
<Suspense fallback={null}>
<MobileTabsPager />
</Suspense>
);
}

View file

@ -1,9 +1,13 @@
import React from 'react';
import React, { Suspense } from 'react';
import { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import ReCAPTCHA from 'react-google-recaptcha';
import { StageComponentProps } from './types';
// react-google-recaptcha (+ its grecaptcha loader) is only ever rendered in
// the registration UIA captcha stage — a cold, rare path. Lazy-loading it
// keeps it out of the boot bundle.
const ReCAPTCHA = React.lazy(() => import('react-google-recaptcha'));
function ReCaptchaErrorDialog({
title,
message,
@ -57,7 +61,9 @@ export function ReCaptchaStageDialog({ stageData, submitAuthDict, onCancel }: St
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text>Please check the box below to proceed.</Text>
<ReCAPTCHA sitekey={publicKey} onChange={handleChange} />
<Suspense fallback={<Text size="T200">Loading</Text>}>
<ReCAPTCHA sitekey={publicKey} onChange={handleChange} />
</Suspense>
</Box>
</Dialog>
);

View file

@ -1,4 +1,11 @@
import React, { FormEventHandler, MouseEventHandler, useCallback, useMemo, useState } from 'react';
import React, {
FormEventHandler,
MouseEventHandler,
Suspense,
useCallback,
useMemo,
useState,
} from 'react';
import {
Box,
Text,
@ -37,7 +44,6 @@ import { useRoom } from '../../../hooks/useRoom';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { PowerColorBadge, PowerIcon } from '../../../components/power';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board';
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@ -52,6 +58,17 @@ import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { creatorsSupported } from '../../../utils/matrix';
// Lazy-loaded: EmojiBoard pulls the heavy emoji picker dataset (`emoji-data`,
// ~506 KB compact.json). PowersEditor is reachable from the boot graph
// (RoomSettingsRenderer / SpaceSettingsRenderer are mounted at the app root),
// so a static import here would drag `emoji-data` into the initial bundle. The
// board only mounts inside the PopOut below when the user opens the icon
// picker, so deferring it keeps `emoji-data` off boot (the chat composer's own
// static EmojiBoard import keeps the chunk in the lazy Room bundle).
const EmojiBoard = React.lazy(() =>
import('../../../components/emoji-board').then((m) => ({ default: m.EmojiBoard }))
);
type EditPowerProps = {
maxPower: number;
power?: number;
@ -208,23 +225,25 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
position="Bottom"
anchor={cords}
content={
<EmojiBoard
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
allowTextCustomEmoji={false}
addToRecentEmoji={false}
onEmojiSelect={(key) => {
setTagIcon({ key });
setCords(undefined);
}}
onCustomEmojiSelect={(mxc) => {
setTagIcon({ key: mxc });
setCords(undefined);
}}
requestClose={() => {
setCords(undefined);
}}
/>
<Suspense fallback={null}>
<EmojiBoard
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
allowTextCustomEmoji={false}
addToRecentEmoji={false}
onEmojiSelect={(key) => {
setTagIcon({ key });
setCords(undefined);
}}
onCustomEmojiSelect={(mxc) => {
setTagIcon({ key: mxc });
setCords(undefined);
}}
requestClose={() => {
setCords(undefined);
}}
/>
</Suspense>
}
>
<Button

View file

@ -3,7 +3,6 @@ import { Provider as JotaiProvider } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ClientConfigLoader } from '../components/ClientConfigLoader';
import { ClientConfigProvider } from '../hooks/useClientConfig';
@ -16,6 +15,15 @@ import { installPushLanguageBridge } from '../utils/pushLanguageBridge';
const queryClient = new QueryClient();
// Dev-only: react-query-devtools must never ship to production. The dynamic
// import lives in a branch Vite statically eliminates (import.meta.env.DEV →
// false in prod), so Rollup drops the chunk entirely from the prod bundle.
const ReactQueryDevtools = import.meta.env.DEV
? React.lazy(() =>
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools }))
)
: null;
function App() {
const screenSize = useScreenSize();
useCompositionEndTracking();
@ -48,7 +56,11 @@ function App() {
<JotaiProvider>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
{ReactQueryDevtools && (
<React.Suspense fallback={null}>
<ReactQueryDevtools initialIsOpen={false} />
</React.Suspense>
)}
</QueryClientProvider>
</ClientConfigProvider>
)}

View file

@ -1,4 +1,5 @@
import React from 'react';
import React, { Suspense } from 'react';
import { Box, Spinner } from 'folds';
import {
Navigate,
Outlet,
@ -53,13 +54,9 @@ import { getMxIdServer, isUserId } from '../utils/matrix';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { HomeRouteRoomProvider } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { BotExperienceHost, Bots } from './client/bots';
import { Channels, ChannelsRootNav, ChannelPickPlaceholder } from './client/channels';
import { ChannelPickPlaceholder } from './client/channels/ChannelPickPlaceholder';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { Room } from '../features/room';
import { Lobby } from '../features/lobby';
import { WelcomePage } from './client/WelcomePage';
import { PageRoot } from '../components/page';
import { ScreenSize } from '../hooks/useScreenSize';
@ -73,11 +70,10 @@ import { RoomSettingsRenderer } from '../features/room-settings';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { SpaceSettingsRenderer } from '../features/space-settings';
import { CreateRoomModalRenderer } from '../features/create-room';
import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search';
import { useShareTargetReceiver } from '../hooks/useShareTargetReceiver';
import { SettingsScreen } from '../features/settings';
import { SettingsScreen } from '../features/settings/SettingsScreen';
import { getFallbackSession } from '../state/sessions';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
@ -129,6 +125,59 @@ function UserLinkRedirect() {
return <Navigate to={withSearchParam(getDirectCreatePath(), params)} replace />;
}
// Route-level code-splitting. Each heavy page is loaded on first navigation
// instead of in the boot bundle — this pulls the timeline (Room), lobby,
// explore, bots and channels subsystems off first load. Imports point at the
// concrete source file (not the barrel) so each chunk stays tight.
// `routeSuspense` wraps every usage so only the content area shows the fallback
// while the chunk streams in — surrounding nav/chrome stays mounted.
//
// NOTE: SettingsScreen is intentionally NOT lazy here. The settings UI
// (`features/settings/Settings`) is already pulled into the boot graph by
// `Direct` → `MobileSettingsHorseshoe` (the mobile settings sheet, mounted in
// the boot listing view), so a Router-level lazy split produces a Rollup
// "dynamically + statically imported" warning and zero size win. Splitting
// settings off boot requires lazy-loading `<Settings>` inside its two hosts
// (MobileSettingsHorseshoe + SettingsScreen) — deferred as a separate change.
const Room = React.lazy(() => import('../features/room/Room').then((m) => ({ default: m.Room })));
const Lobby = React.lazy(() =>
import('../features/lobby/Lobby').then((m) => ({ default: m.Lobby }))
);
const Explore = React.lazy(() =>
import('./client/explore/Explore').then((m) => ({ default: m.Explore }))
);
const FeaturedRooms = React.lazy(() =>
import('./client/explore/Featured').then((m) => ({ default: m.FeaturedRooms }))
);
const PublicRooms = React.lazy(() =>
import('./client/explore/Server').then((m) => ({ default: m.PublicRooms }))
);
const Bots = React.lazy(() => import('./client/bots/Bots').then((m) => ({ default: m.Bots })));
const BotExperienceHost = React.lazy(() =>
import('./client/bots/BotExperienceHost').then((m) => ({ default: m.BotExperienceHost }))
);
const Channels = React.lazy(() =>
import('./client/channels/Channels').then((m) => ({ default: m.Channels }))
);
const ChannelsRootNav = React.lazy(() =>
import('./client/channels/Channels').then((m) => ({ default: m.ChannelsRootNav }))
);
const Create = React.lazy(() =>
import('./client/create/Create').then((m) => ({ default: m.Create }))
);
function RouteSuspenseFallback() {
return (
<Box grow="Yes" style={{ height: '100%' }} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="600" />
</Box>
);
}
const routeSuspense = (node: React.ReactNode) => (
<Suspense fallback={<RouteSuspenseFallback />}>{node}</Suspense>
);
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
const mobile = screenSize === ScreenSize.Mobile;
@ -225,11 +274,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_SEARCH_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
<Route
path={_ROOM_PATH}
element={
<HomeRouteRoomProvider>
<Room />
</HomeRouteRoomProvider>
}
element={<HomeRouteRoomProvider>{routeSuspense(<Room />)}</HomeRouteRoomProvider>}
/>
</Route>
{/* Mobile + Capacitor horizontal swipe pager. The layout-route
@ -258,11 +303,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_CREATE_PATH} element={<DirectCreate />} />
<Route
path={_ROOM_PATH}
element={
<DirectRouteRoomProvider>
<Room />
</DirectRouteRoomProvider>
}
element={<DirectRouteRoomProvider>{routeSuspense(<Room />)}</DirectRouteRoomProvider>}
/>
</Route>
{/* Bots reuses StreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */}
@ -272,7 +313,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot
nav={
<MobileFriendlyPageNav path={BOTS_PATH}>
<Bots />
{routeSuspense(<Bots />)}
</MobileFriendlyPageNav>
}
>
@ -281,7 +322,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
{mobile ? null : <Route index element={<WelcomePage />} />}
<Route path=":botId" element={<BotExperienceHost />} />
<Route path=":botId" element={routeSuspense(<BotExperienceHost />)} />
</Route>
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the
@ -302,7 +343,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot
nav={
<MobileFriendlyPageNav path={CHANNELS_PATH}>
<ChannelsRootNav />
{routeSuspense(<ChannelsRootNav />)}
</MobileFriendlyPageNav>
}
>
@ -317,7 +358,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot
nav={
<MobileFriendlyPageNav path={CHANNELS_SPACE_PATH}>
<Channels />
{routeSuspense(<Channels />)}
</MobileFriendlyPageNav>
}
>
@ -329,11 +370,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
{mobile ? null : <Route index element={<ChannelPickPlaceholder />} />}
<Route
path={CHANNELS_ROOM_PATH.slice(CHANNELS_SPACE_PATH.length)}
element={
<SpaceRouteRoomProvider>
<Room />
</SpaceRouteRoomProvider>
}
element={<SpaceRouteRoomProvider>{routeSuspense(<Room />)}</SpaceRouteRoomProvider>}
>
{/* Thread drawer URL same Room element renders, drawer
opens by reading `:rootId` via useParams. The
@ -383,15 +420,11 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />}
/>
)}
<Route path={_LOBBY_PATH} element={<Lobby />} />
<Route path={_LOBBY_PATH} element={routeSuspense(<Lobby />)} />
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route
path={_ROOM_PATH}
element={
<SpaceRouteRoomProvider>
<Room />
</SpaceRouteRoomProvider>
}
element={<SpaceRouteRoomProvider>{routeSuspense(<Room />)}</SpaceRouteRoomProvider>}
/>
</Route>
<Route
@ -400,7 +433,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot
nav={
<MobileFriendlyPageNav path={EXPLORE_PATH}>
<Explore />
{routeSuspense(<Explore />)}
</MobileFriendlyPageNav>
}
>
@ -415,10 +448,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />}
/>
)}
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
<Route path={_SERVER_PATH} element={<PublicRooms />} />
<Route path={_FEATURED_PATH} element={routeSuspense(<FeaturedRooms />)} />
<Route path={_SERVER_PATH} element={routeSuspense(<PublicRooms />)} />
</Route>
<Route path={CREATE_PATH} element={<Create />} />
<Route path={CREATE_PATH} element={routeSuspense(<Create />)} />
{/* /settings shares the DIRECT_PATH shell left page-nav stays
the DM list, the right pane swaps the chat outlet for the
Settings UI. The horseshoe rounded TL/BL on the right pane

View file

@ -0,0 +1,86 @@
import emojisData from 'emojibase-data/en/compact.json';
import { EmojiGroupId, IEmoji, IEmojiGroup, getShortcodesFor } from './emoji';
// Heavy emoji dataset (~506 KB raw `compact.json`). Split out of `./emoji` so
// it is bundled only into the lazy chunks that render the emoji picker /
// autocomplete (EmojiBoard, EmoticonAutocomplete) and never into the initial
// app bundle. The module-level processing below runs once on first import of
// this file.
export const emojiGroups: IEmojiGroup[] = [
{
id: EmojiGroupId.People,
order: 0,
emojis: [],
},
{
id: EmojiGroupId.Nature,
order: 1,
emojis: [],
},
{
id: EmojiGroupId.Food,
order: 2,
emojis: [],
},
{
id: EmojiGroupId.Activity,
order: 3,
emojis: [],
},
{
id: EmojiGroupId.Travel,
order: 4,
emojis: [],
},
{
id: EmojiGroupId.Object,
order: 5,
emojis: [],
},
{
id: EmojiGroupId.Symbol,
order: 6,
emojis: [],
},
{
id: EmojiGroupId.Flag,
order: 7,
emojis: [],
},
];
export const emojis: IEmoji[] = [];
function addEmojiToGroup(groupIndex: number, emoji: IEmoji) {
emojiGroups[groupIndex].emojis.push(emoji);
}
function getGroupIndex(emoji: IEmoji): number | undefined {
if (emoji.group === 0 || emoji.group === 1) return 0;
if (emoji.group === 3) return 1;
if (emoji.group === 4) return 2;
if (emoji.group === 6) return 3;
if (emoji.group === 5) return 4;
if (emoji.group === 7) return 5;
if (emoji.group === 8 || typeof emoji.group === 'undefined') return 6;
if (emoji.group === 9) return 7;
return undefined;
}
emojisData.forEach((emoji) => {
const myShortCodes = getShortcodesFor(emoji.hexcode);
if (!myShortCodes) return;
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
const em: IEmoji = {
...emoji,
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
};
const groupIndex = getGroupIndex(em);
if (groupIndex !== undefined) {
addEmojiToGroup(groupIndex, em);
emojis.push(em);
}
});

View file

@ -1,8 +1,15 @@
import { CompactEmoji, fromUnicodeToHexcode } from 'emojibase';
import emojisData from 'emojibase-data/en/compact.json';
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
// Light emoji module. Holds the shortcode lookups + types + group enum — the
// only emoji surface the broadly-imported HTML parser (and thus the boot
// graph, via RoomNavItem/SpaceTabs → InviteUserPrompt → react-custom-html-parser)
// actually needs. The heavy `compact.json` dataset (~506 KB) that builds the
// `emojis` / `emojiGroups` arrays for the picker + autocomplete lives in
// `./emoji-data` so it only loads inside the lazy Room/Settings chunks instead
// of the initial bundle. Don't re-import `compact.json` here or the split
// regresses.
export type IEmoji = CompactEmoji & {
shortcode: string;
};
@ -33,82 +40,3 @@ export const getShortcodeFor = (hexcode: string): string | undefined => {
};
export const getHexcodeForEmoji = fromUnicodeToHexcode;
export const emojiGroups: IEmojiGroup[] = [
{
id: EmojiGroupId.People,
order: 0,
emojis: [],
},
{
id: EmojiGroupId.Nature,
order: 1,
emojis: [],
},
{
id: EmojiGroupId.Food,
order: 2,
emojis: [],
},
{
id: EmojiGroupId.Activity,
order: 3,
emojis: [],
},
{
id: EmojiGroupId.Travel,
order: 4,
emojis: [],
},
{
id: EmojiGroupId.Object,
order: 5,
emojis: [],
},
{
id: EmojiGroupId.Symbol,
order: 6,
emojis: [],
},
{
id: EmojiGroupId.Flag,
order: 7,
emojis: [],
},
];
export const emojis: IEmoji[] = [];
function addEmojiToGroup(groupIndex: number, emoji: IEmoji) {
emojiGroups[groupIndex].emojis.push(emoji);
}
function getGroupIndex(emoji: IEmoji): number | undefined {
if (emoji.group === 0 || emoji.group === 1) return 0;
if (emoji.group === 3) return 1;
if (emoji.group === 4) return 2;
if (emoji.group === 6) return 3;
if (emoji.group === 5) return 4;
if (emoji.group === 7) return 5;
if (emoji.group === 8 || typeof emoji.group === 'undefined') return 6;
if (emoji.group === 9) return 7;
return undefined;
}
emojisData.forEach((emoji) => {
const myShortCodes = getShortcodesFor(emoji.hexcode);
if (!myShortCodes) return;
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
const em: IEmoji = {
...emoji,
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
};
const groupIndex = getGroupIndex(em);
if (groupIndex !== undefined) {
addEmojiToGroup(groupIndex, em);
emojis.push(em);
}
});

View file

@ -1,6 +1,7 @@
import { MatrixClient } from 'matrix-js-sdk';
import { getAccountData } from '../utils/room';
import { IEmoji, emojis } from './emoji';
import { IEmoji } from './emoji';
import { emojis } from './emoji-data';
import { AccountDataEvent } from '../../types/matrix/accountData';
type EmojiUnicode = string;

View file

@ -306,6 +306,64 @@ export default defineConfig({
copyPublicDir: false,
rollupOptions: {
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
output: {
// Conservative vendor splitting: carve only the large, stable,
// leaf-ish dependency families out of the single ~1 MB-gzip app
// chunk into their own cacheable chunks. The win is cache stability
// — an app-code redeploy no longer busts the matrix-js-sdk / slate
// bytes for returning users, and the two load in parallel with the
// entry. We deliberately do NOT split react / folds / react-aria:
// they are boot-critical and tightly coupled, and aggressive vendor
// splitting under topLevelAwait + wasm can reorder init and break
// shared singletons (element-web likewise avoids hand-rolled vendor
// cacheGroups). Heavy *feature* code is code-split via React.lazy in
// Router.tsx instead — that's what actually shrinks first load.
manualChunks(id) {
// Pin the heavy emoji PICKER dataset (~506 KB `compact.json` + the
// `emoji-data.ts` module that processes it) to its own chunk so it
// stays OFF the initial bundle — only the lazy Room / Settings chunks
// import it (on first room open / picker mount). Runs BEFORE the
// node_modules guard so the src module is pinned too.
if (id.includes('/plugins/emoji-data') || id.includes('emojibase-data/en/compact')) {
return 'emoji-data';
}
// Pin the LIGHT emoji module (shortcode lookups + `getHexcodeForEmoji`)
// and its shortcode maps to a SEPARATE chunk. These are genuinely on the
// boot path — the HTML parser (react-custom-html-parser, reachable from
// RoomNavItem → InviteUserPrompt) statically imports them. Pinning them
// here is what KEEPS `emoji-data` off boot: without it Rollup folds the
// light module into the `emoji-data` chunk (it's a dependency of
// `emoji-data.ts`), and the entry's boot import of the light functions
// then drags the 506 KB `compact.json` into the initial load with it.
// `.ts` is matched specifically so this never catches `emoji-data.ts`
// (already returned above).
if (id.includes('/plugins/emoji.ts') || id.includes('emojibase-data/en/shortcodes')) {
return 'emoji-shortcodes';
}
if (!id.includes('node_modules')) return undefined;
// Conservative vendor splitting: carve only the large, stable,
// leaf-ish dependency families out of the single app chunk into their
// own cacheable chunks. The win is cache stability — an app-code
// redeploy no longer busts the matrix-js-sdk / slate bytes for
// returning users. We deliberately do NOT split react / folds /
// react-aria: they are boot-critical and tightly coupled, and
// aggressive vendor splitting under topLevelAwait + wasm can reorder
// init and break shared singletons (element-web likewise avoids
// hand-rolled vendor cacheGroups).
if (
id.includes('matrix-js-sdk') ||
id.includes('@matrix-org/') ||
id.includes('matrix-events-sdk') ||
id.includes('matrix-widget-api')
) {
return 'matrix-sdk';
}
if (/[\\/]node_modules[\\/](slate|slate-react|slate-dom|slate-history)[\\/]/.test(id)) {
return 'editor';
}
return undefined;
},
},
},
},
});