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

View file

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

View file

@ -11,7 +11,8 @@ import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks'; 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 { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';

View file

@ -15,7 +15,8 @@ import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual'; 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 { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; 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 { Outlet, useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { isNativePlatform } from '../../utils/capacitor'; import { isNativePlatform } from '../../utils/capacitor';
import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths'; 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/, // Router-level wrapper around the three listing tabs (/direct/,
// /channels/, /bots/). When all of (mobile breakpoint, Capacitor // /channels/, /bots/). When all of (mobile breakpoint, Capacitor
@ -44,5 +52,9 @@ export function MobileTabsLayout() {
if (!(mobile && native) || !onListingRoot) { if (!(mobile && native) || !onListingRoot) {
return <Outlet />; 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 { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk'; import { AuthType } from 'matrix-js-sdk';
import ReCAPTCHA from 'react-google-recaptcha';
import { StageComponentProps } from './types'; 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({ function ReCaptchaErrorDialog({
title, title,
message, message,
@ -57,7 +61,9 @@ export function ReCaptchaStageDialog({ stageData, submitAuthDict, onCancel }: St
<Dialog> <Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text>Please check the box below to proceed.</Text> <Text>Please check the box below to proceed.</Text>
<Suspense fallback={<Text size="T200">Loading</Text>}>
<ReCAPTCHA sitekey={publicKey} onChange={handleChange} /> <ReCAPTCHA sitekey={publicKey} onChange={handleChange} />
</Suspense>
</Box> </Box>
</Dialog> </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 { import {
Box, Box,
Text, Text,
@ -37,7 +44,6 @@ import { useRoom } from '../../../hooks/useRoom';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut'; import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { PowerColorBadge, PowerIcon } from '../../../components/power'; import { PowerColorBadge, PowerIcon } from '../../../components/power';
import { UseStateProvider } from '../../../components/UseStateProvider'; import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board';
import { useImagePackRooms } from '../../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@ -52,6 +58,17 @@ import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { creatorsSupported } from '../../../utils/matrix'; 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 = { type EditPowerProps = {
maxPower: number; maxPower: number;
power?: number; power?: number;
@ -208,6 +225,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
position="Bottom" position="Bottom"
anchor={cords} anchor={cords}
content={ content={
<Suspense fallback={null}>
<EmojiBoard <EmojiBoard
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false} returnFocusOnDeactivate={false}
@ -225,6 +243,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
setCords(undefined); setCords(undefined);
}} }}
/> />
</Suspense>
} }
> >
<Button <Button

View file

@ -3,7 +3,6 @@ import { Provider as JotaiProvider } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ClientConfigLoader } from '../components/ClientConfigLoader'; import { ClientConfigLoader } from '../components/ClientConfigLoader';
import { ClientConfigProvider } from '../hooks/useClientConfig'; import { ClientConfigProvider } from '../hooks/useClientConfig';
@ -16,6 +15,15 @@ import { installPushLanguageBridge } from '../utils/pushLanguageBridge';
const queryClient = new QueryClient(); 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() { function App() {
const screenSize = useScreenSize(); const screenSize = useScreenSize();
useCompositionEndTracking(); useCompositionEndTracking();
@ -48,7 +56,11 @@ function App() {
<JotaiProvider> <JotaiProvider>
<RouterProvider router={createRouter(clientConfig, screenSize)} /> <RouterProvider router={createRouter(clientConfig, screenSize)} />
</JotaiProvider> </JotaiProvider>
{ReactQueryDevtools && (
<React.Suspense fallback={null}>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</React.Suspense>
)}
</QueryClientProvider> </QueryClientProvider>
</ClientConfigProvider> </ClientConfigProvider>
)} )}

View file

@ -1,4 +1,5 @@
import React from 'react'; import React, { Suspense } from 'react';
import { Box, Spinner } from 'folds';
import { import {
Navigate, Navigate,
Outlet, Outlet,
@ -53,13 +54,9 @@ import { getMxIdServer, isUserId } from '../utils/matrix';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { HomeRouteRoomProvider } from './client/home'; import { HomeRouteRoomProvider } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { BotExperienceHost, Bots } from './client/bots'; import { ChannelPickPlaceholder } from './client/channels/ChannelPickPlaceholder';
import { Channels, ChannelsRootNav, ChannelPickPlaceholder } from './client/channels';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { Room } from '../features/room';
import { Lobby } from '../features/lobby';
import { WelcomePage } from './client/WelcomePage'; import { WelcomePage } from './client/WelcomePage';
import { PageRoot } from '../components/page'; import { PageRoot } from '../components/page';
import { ScreenSize } from '../hooks/useScreenSize'; import { ScreenSize } from '../hooks/useScreenSize';
@ -73,11 +70,10 @@ import { RoomSettingsRenderer } from '../features/room-settings';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { SpaceSettingsRenderer } from '../features/space-settings'; import { SpaceSettingsRenderer } from '../features/space-settings';
import { CreateRoomModalRenderer } from '../features/create-room'; import { CreateRoomModalRenderer } from '../features/create-room';
import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space'; import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search'; import { SearchModalRenderer } from '../features/search';
import { useShareTargetReceiver } from '../hooks/useShareTargetReceiver'; import { useShareTargetReceiver } from '../hooks/useShareTargetReceiver';
import { SettingsScreen } from '../features/settings'; import { SettingsScreen } from '../features/settings/SettingsScreen';
import { getFallbackSession } from '../state/sessions'; import { getFallbackSession } from '../state/sessions';
import { CallEmbedProvider } from '../components/CallEmbedProvider'; import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications'; import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
@ -129,6 +125,59 @@ function UserLinkRedirect() {
return <Navigate to={withSearchParam(getDirectCreatePath(), params)} replace />; 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) => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig; const { hashRouter } = clientConfig;
const mobile = screenSize === ScreenSize.Mobile; 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={_SEARCH_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
<Route <Route
path={_ROOM_PATH} path={_ROOM_PATH}
element={ element={<HomeRouteRoomProvider>{routeSuspense(<Room />)}</HomeRouteRoomProvider>}
<HomeRouteRoomProvider>
<Room />
</HomeRouteRoomProvider>
}
/> />
</Route> </Route>
{/* Mobile + Capacitor horizontal swipe pager. The layout-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={_CREATE_PATH} element={<DirectCreate />} />
<Route <Route
path={_ROOM_PATH} path={_ROOM_PATH}
element={ element={<DirectRouteRoomProvider>{routeSuspense(<Room />)}</DirectRouteRoomProvider>}
<DirectRouteRoomProvider>
<Room />
</DirectRouteRoomProvider>
}
/> />
</Route> </Route>
{/* Bots reuses StreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */} {/* 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 <PageRoot
nav={ nav={
<MobileFriendlyPageNav path={BOTS_PATH}> <MobileFriendlyPageNav path={BOTS_PATH}>
<Bots /> {routeSuspense(<Bots />)}
</MobileFriendlyPageNav> </MobileFriendlyPageNav>
} }
> >
@ -281,7 +322,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
} }
> >
{mobile ? null : <Route index element={<WelcomePage />} />} {mobile ? null : <Route index element={<WelcomePage />} />}
<Route path=":botId" element={<BotExperienceHost />} /> <Route path=":botId" element={routeSuspense(<BotExperienceHost />)} />
</Route> </Route>
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} /> <Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the {/* Channels segment. /channels/* is reserved before SPACE_PATH so the
@ -302,7 +343,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot <PageRoot
nav={ nav={
<MobileFriendlyPageNav path={CHANNELS_PATH}> <MobileFriendlyPageNav path={CHANNELS_PATH}>
<ChannelsRootNav /> {routeSuspense(<ChannelsRootNav />)}
</MobileFriendlyPageNav> </MobileFriendlyPageNav>
} }
> >
@ -317,7 +358,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot <PageRoot
nav={ nav={
<MobileFriendlyPageNav path={CHANNELS_SPACE_PATH}> <MobileFriendlyPageNav path={CHANNELS_SPACE_PATH}>
<Channels /> {routeSuspense(<Channels />)}
</MobileFriendlyPageNav> </MobileFriendlyPageNav>
} }
> >
@ -329,11 +370,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
{mobile ? null : <Route index element={<ChannelPickPlaceholder />} />} {mobile ? null : <Route index element={<ChannelPickPlaceholder />} />}
<Route <Route
path={CHANNELS_ROOM_PATH.slice(CHANNELS_SPACE_PATH.length)} path={CHANNELS_ROOM_PATH.slice(CHANNELS_SPACE_PATH.length)}
element={ element={<SpaceRouteRoomProvider>{routeSuspense(<Room />)}</SpaceRouteRoomProvider>}
<SpaceRouteRoomProvider>
<Room />
</SpaceRouteRoomProvider>
}
> >
{/* Thread drawer URL same Room element renders, drawer {/* Thread drawer URL same Room element renders, drawer
opens by reading `:rootId` via useParams. The opens by reading `:rootId` via useParams. The
@ -383,15 +420,11 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />} element={<WelcomePage />}
/> />
)} )}
<Route path={_LOBBY_PATH} element={<Lobby />} /> <Route path={_LOBBY_PATH} element={routeSuspense(<Lobby />)} />
<Route path={_SEARCH_PATH} element={<SpaceSearch />} /> <Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route <Route
path={_ROOM_PATH} path={_ROOM_PATH}
element={ element={<SpaceRouteRoomProvider>{routeSuspense(<Room />)}</SpaceRouteRoomProvider>}
<SpaceRouteRoomProvider>
<Room />
</SpaceRouteRoomProvider>
}
/> />
</Route> </Route>
<Route <Route
@ -400,7 +433,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot <PageRoot
nav={ nav={
<MobileFriendlyPageNav path={EXPLORE_PATH}> <MobileFriendlyPageNav path={EXPLORE_PATH}>
<Explore /> {routeSuspense(<Explore />)}
</MobileFriendlyPageNav> </MobileFriendlyPageNav>
} }
> >
@ -415,10 +448,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />} element={<WelcomePage />}
/> />
)} )}
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} /> <Route path={_FEATURED_PATH} element={routeSuspense(<FeaturedRooms />)} />
<Route path={_SERVER_PATH} element={<PublicRooms />} /> <Route path={_SERVER_PATH} element={routeSuspense(<PublicRooms />)} />
</Route> </Route>
<Route path={CREATE_PATH} element={<Create />} /> <Route path={CREATE_PATH} element={routeSuspense(<Create />)} />
{/* /settings shares the DIRECT_PATH shell left page-nav stays {/* /settings shares the DIRECT_PATH shell left page-nav stays
the DM list, the right pane swaps the chat outlet for the the DM list, the right pane swaps the chat outlet for the
Settings UI. The horseshoe rounded TL/BL on the right pane 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 { CompactEmoji, fromUnicodeToHexcode } from 'emojibase';
import emojisData from 'emojibase-data/en/compact.json';
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json'; import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
import emojibase from 'emojibase-data/en/shortcodes/emojibase.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 & { export type IEmoji = CompactEmoji & {
shortcode: string; shortcode: string;
}; };
@ -33,82 +40,3 @@ export const getShortcodeFor = (hexcode: string): string | undefined => {
}; };
export const getHexcodeForEmoji = fromUnicodeToHexcode; 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 { MatrixClient } from 'matrix-js-sdk';
import { getAccountData } from '../utils/room'; 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'; import { AccountDataEvent } from '../../types/matrix/accountData';
type EmojiUnicode = string; type EmojiUnicode = string;

View file

@ -306,6 +306,64 @@ export default defineConfig({
copyPublicDir: false, copyPublicDir: false,
rollupOptions: { rollupOptions: {
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })], 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;
},
},
}, },
}, },
}); });