From 05ca15be48b79a105605543fe82e7d96f187222c Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 15 Jul 2026 17:58:06 +0800 Subject: [PATCH] perf(client): defer chat persistence dependencies --- docs/performance-baseline.md | 28 ++++++----- scripts/performance/report-bundle.mjs | 16 +++++-- .../hooks/use-first-recharge-offer-banner.ts | 5 +- .../repositories/chat_repository_loader.ts | 13 +++++ src/lib/chat/chat_media_cache_client.ts | 8 ++-- src/lib/chat/splash_latest_message.ts | 5 +- src/providers/chat-route-providers.tsx | 10 ++-- src/providers/payment-route-provider.tsx | 4 +- src/providers/root-providers.tsx | 2 +- src/stores/chat/chat-history-sync.ts | 6 +-- src/stores/chat/machine/actors/send.ts | 4 +- src/stores/chat/machine/actors/unlock.ts | 6 +-- src/stores/sync/chat-payment-success-sync.tsx | 47 +++++++++++++++++++ src/stores/sync/index.ts | 1 + src/stores/sync/payment-success-sync.tsx | 41 ---------------- 15 files changed, 115 insertions(+), 81 deletions(-) create mode 100644 src/data/repositories/chat_repository_loader.ts create mode 100644 src/stores/sync/chat-payment-success-sync.tsx diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index aeb76a3c..f7a96df0 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -30,17 +30,17 @@ Compressed sizes below are Turbopack module estimates. `Eager` is the route's sy | Route | Direct-import baseline | Current eager baseline | Change | Current async baseline | | --- | ---: | ---: | ---: | ---: | -| `/splash` | 646.2 KiB | 649.5 KiB | +3.3 KiB | 14.3 KiB | -| `/chat` | 669.8 KiB | 672.8 KiB | +3.0 KiB | 14.7 KiB | -| `/subscription` | 658.4 KiB | 662.0 KiB | +3.6 KiB | 20.6 KiB | -| `/tip` | 651.6 KiB | 656.0 KiB | +4.4 KiB | 20.6 KiB | +| `/splash` | 646.2 KiB | 599.7 KiB | -46.5 KiB | 49.2 KiB | +| `/chat` | 669.8 KiB | 637.8 KiB | -32.0 KiB | 49.6 KiB | +| `/subscription` | 658.4 KiB | 616.9 KiB | -41.5 KiB | 20.6 KiB | +| `/tip` | 651.6 KiB | 610.9 KiB | -40.7 KiB | 20.6 KiB | The earlier direct-import pass replaced root `@/utils` and `@/data/storage` -barrel imports with symbol-level module paths. The current column captures the -full application after the subsequent feature work and is the baseline enforced -by the budgets below. Dexie and UA Parser remain in the analyzed routes through -active feature dependencies and require separate feature-boundary work to -remove from the eager graph. +barrel imports with symbol-level module paths. The current Client-boundary pass +separates the Payment → User and Payment → Chat synchronizers, uses direct +provider imports, and loads the Dexie-backed chat repository through a dynamic +import. UA Parser remains an intentional eager dependency because the app still +requires its general browser, OS, and device detection behavior. Module loading baseline: @@ -51,6 +51,8 @@ Module loading baseline: | Stripe payment dialog | eager | async | | Chat reply animation | eager | async | | `lottie-react` | dependency present, not bundled | removed | +| `ua-parser-js` | eager | eager (retained intentionally) | +| Dexie | eager on all baseline routes | async on `/splash` and `/chat`; absent on `/subscription` and `/tip` | The total potential graph can grow slightly because an async chunk has its own loader metadata. The primary budget is the eager graph and the audited module phase, not the sum of eager and unopened async code. @@ -58,10 +60,10 @@ The initial budgets are intentionally above the baseline and are enforced by CI: | Route | Eager client JS budget | | --- | ---: | -| `/splash` | 650 KiB | -| `/chat` | 675 KiB | -| `/subscription` | 665 KiB | -| `/tip` | 660 KiB | +| `/splash` | 605 KiB | +| `/chat` | 645 KiB | +| `/subscription` | 625 KiB | +| `/tip` | 620 KiB | ## Lab Web Vitals baseline diff --git a/scripts/performance/report-bundle.mjs b/scripts/performance/report-bundle.mjs index d9e17259..e87ec820 100644 --- a/scripts/performance/report-bundle.mjs +++ b/scripts/performance/report-bundle.mjs @@ -17,13 +17,21 @@ const routes = parseList( const enforceBudgets = process.env.PERF_BUNDLE_ENFORCE === "1"; const eagerClientJsBudgets = new Map([ - ["/splash", 650 * 1024], - ["/chat", 675 * 1024], - ["/subscription", 665 * 1024], - ["/tip", 660 * 1024], + ["/splash", 605 * 1024], + ["/chat", 645 * 1024], + ["/subscription", 625 * 1024], + ["/tip", 620 * 1024], ]); const auditTargets = [ + { + name: "UA parser", + matches: (path) => path.includes("/node_modules/ua-parser-js/"), + }, + { + name: "Dexie", + matches: (path) => path.includes("/node_modules/dexie/"), + }, { name: "Stripe React SDK", matches: (path) => path.includes("/node_modules/@stripe/react-stripe-js/"), diff --git a/src/app/chat/hooks/use-first-recharge-offer-banner.ts b/src/app/chat/hooks/use-first-recharge-offer-banner.ts index c49519ee..46ae0c33 100644 --- a/src/app/chat/hooks/use-first-recharge-offer-banner.ts +++ b/src/app/chat/hooks/use-first-recharge-offer-banner.ts @@ -5,7 +5,10 @@ import { shallowEqual } from "@xstate/react"; import type { LoginStatus } from "@/data/dto/auth"; import { useAppNavigator } from "@/router/use-app-navigator"; -import { usePaymentDispatch, usePaymentSelector } from "@/stores/payment"; +import { + usePaymentDispatch, + usePaymentSelector, +} from "@/stores/payment/payment-context"; import { useUserSelector } from "@/stores/user/user-context"; import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; import { diff --git a/src/data/repositories/chat_repository_loader.ts b/src/data/repositories/chat_repository_loader.ts new file mode 100644 index 00000000..7aeac016 --- /dev/null +++ b/src/data/repositories/chat_repository_loader.ts @@ -0,0 +1,13 @@ +import type { IChatRepository } from "./interfaces/ichat_repository"; + +/** + * Loads the browser-only chat repository behind an explicit async boundary. + * + * The repository owns the Dexie-backed history and media stores. Keeping this + * loader separate prevents those stores from entering every route's eager + * client graph through shared providers. + */ +export async function loadChatRepository(): Promise { + const { getChatRepository } = await import("./chat_repository"); + return getChatRepository(); +} diff --git a/src/lib/chat/chat_media_cache_client.ts b/src/lib/chat/chat_media_cache_client.ts index cece084d..138f897c 100644 --- a/src/lib/chat/chat_media_cache_client.ts +++ b/src/lib/chat/chat_media_cache_client.ts @@ -1,7 +1,7 @@ "use client"; import type { ChatMediaKind } from "@/data/dto/chat"; -import { getChatRepository } from "@/data/repositories/chat_repository"; +import { loadChatRepository } from "@/data/repositories/chat_repository_loader"; import { Result, type Result as ResultT } from "@/utils/result"; import { localChatMediaRowToBlob } from "./chat_media_blob"; @@ -19,7 +19,8 @@ export interface CacheRemoteChatMediaBlobInput export async function resolveCachedChatMediaBlob( input: ResolveCachedChatMediaBlobInput, ): Promise> { - const result = await getChatRepository().getCachedMedia(input); + const repository = await loadChatRepository(); + const result = await repository.getCachedMedia(input); if (Result.isErr(result)) return result; if (!result.data) return Result.ok(null); return Result.ok(localChatMediaRowToBlob(result.data)); @@ -28,7 +29,8 @@ export async function resolveCachedChatMediaBlob( export async function cacheRemoteChatMediaBlob( input: CacheRemoteChatMediaBlobInput, ): Promise> { - const result = await getChatRepository().cacheRemoteMedia(input); + const repository = await loadChatRepository(); + const result = await repository.cacheRemoteMedia(input); if (Result.isErr(result)) return result; const blob = localChatMediaRowToBlob(result.data); if (!blob) return Result.err(new Error("Cached media has no readable bytes.")); diff --git a/src/lib/chat/splash_latest_message.ts b/src/lib/chat/splash_latest_message.ts index 06094ca0..e075d3ce 100644 --- a/src/lib/chat/splash_latest_message.ts +++ b/src/lib/chat/splash_latest_message.ts @@ -1,4 +1,4 @@ -import { getChatRepository } from "@/data/repositories/chat_repository"; +import { loadChatRepository } from "@/data/repositories/chat_repository_loader"; import { resolveChatCacheOwnerKey, type ChatCacheIdentityResolver, @@ -44,7 +44,8 @@ export async function loadSplashLatestMessagePreview({ export async function fetchSplashLatestMessagePreview(): Promise< ResultT > { - const result = await getChatRepository().getHistory(1, 0); + const repository = await loadChatRepository(); + const result = await repository.getHistory(1, 0); if (Result.isErr(result)) return Result.err(result.error); return Result.ok(getLatestSplashMessagePreview(result.data.messages)); diff --git a/src/providers/chat-route-providers.tsx b/src/providers/chat-route-providers.tsx index 0751c7f2..eda4f3a2 100644 --- a/src/providers/chat-route-providers.tsx +++ b/src/providers/chat-route-providers.tsx @@ -3,12 +3,10 @@ import type { ReactNode } from "react"; import { ChatProvider } from "@/stores/chat/chat-context"; -import { PaymentProvider } from "@/stores/payment"; -import { - ChatAuthSync, - ChatPaymentSuccessSync, - PaymentSuccessSync, -} from "@/stores/sync"; +import { PaymentProvider } from "@/stores/payment/payment-context"; +import { ChatAuthSync } from "@/stores/sync/chat-auth-sync"; +import { ChatPaymentSuccessSync } from "@/stores/sync/chat-payment-success-sync"; +import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync"; export function ChatRouteProviders({ children }: { children: ReactNode }) { return ( diff --git a/src/providers/payment-route-provider.tsx b/src/providers/payment-route-provider.tsx index 04fa0287..7c4a11bb 100644 --- a/src/providers/payment-route-provider.tsx +++ b/src/providers/payment-route-provider.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from "react"; -import { PaymentProvider } from "@/stores/payment"; -import { PaymentSuccessSync } from "@/stores/sync"; +import { PaymentProvider } from "@/stores/payment/payment-context"; +import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync"; export function PaymentRouteProvider({ children }: { children: ReactNode }) { return ( diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx index 57d93d0c..65332385 100644 --- a/src/providers/root-providers.tsx +++ b/src/providers/root-providers.tsx @@ -15,7 +15,7 @@ import { AppNavigationGuard } from "@/router/app-navigation-guard"; import { AuthProvider } from "@/stores/auth/auth-context"; import { AuthStatusChecker } from "@/stores/auth/auth-status-checker"; import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync"; -import { UserAuthSync } from "@/stores/sync"; +import { UserAuthSync } from "@/stores/sync/user-auth-sync"; import { UserProvider } from "@/stores/user/user-context"; export interface RootProvidersProps { diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts index d828a4f1..914a4ccb 100644 --- a/src/stores/chat/chat-history-sync.ts +++ b/src/stores/chat/chat-history-sync.ts @@ -1,6 +1,6 @@ import type { UiMessage } from "@/data/dto/chat"; import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity"; -import { getChatRepository } from "@/data/repositories/chat_repository"; +import { loadChatRepository } from "@/data/repositories/chat_repository_loader"; import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; import { todayString } from "@/utils/date"; @@ -47,7 +47,7 @@ export async function resolveHistoryCacheIdentity(): Promise { export async function readLocalHistorySnapshot( cacheIdentity: string | null, ): Promise { - const chatRepo = getChatRepository(); + const chatRepo = await loadChatRepository(); const greetingMessage = createGreetingMessage(); const localResult = cacheIdentity @@ -77,7 +77,7 @@ export async function syncNetworkHistory( localCount: number, cacheIdentity: string | null, ): Promise { - const chatRepo = getChatRepository(); + const chatRepo = await loadChatRepository(); const greetingMessage = createGreetingMessage(); const networkResult = await chatRepo.getHistory(CHAT_HISTORY_LIMIT, 0); diff --git a/src/stores/chat/machine/actors/send.ts b/src/stores/chat/machine/actors/send.ts index 6794be25..ab311c6b 100644 --- a/src/stores/chat/machine/actors/send.ts +++ b/src/stores/chat/machine/actors/send.ts @@ -3,7 +3,7 @@ import { fromCallback, fromPromise } from "xstate"; import { ExceptionHandler } from "@/core/errors"; import { MessageQueue } from "@/core/net/message-queue"; import type { ChatSendResponse } from "@/data/dto/chat"; -import { getChatRepository } from "@/data/repositories/chat_repository"; +import { loadChatRepository } from "@/data/repositories/chat_repository_loader"; import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity"; import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; @@ -66,7 +66,7 @@ async function sendMessageViaHttp(content: string): Promise<{ response: ChatSendResponse; reply: UiMessage | null; }> { - const chatRepo = getChatRepository(); + const chatRepo = await loadChatRepository(); const cacheIdentityResult = await resolveChatCacheOwnerKey(); const result = await chatRepo.sendMessage(content); if (Result.isErr(result)) { diff --git a/src/stores/chat/machine/actors/unlock.ts b/src/stores/chat/machine/actors/unlock.ts index d0c9e427..cb9d06c4 100644 --- a/src/stores/chat/machine/actors/unlock.ts +++ b/src/stores/chat/machine/actors/unlock.ts @@ -1,6 +1,6 @@ import { fromPromise } from "xstate"; -import { getChatRepository } from "@/data/repositories/chat_repository"; +import { loadChatRepository } from "@/data/repositories/chat_repository_loader"; import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity"; import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; @@ -23,7 +23,7 @@ export interface UnlockHistoryOutput { } export const unlockHistoryActor = fromPromise(async () => { - const chatRepo = getChatRepository(); + const chatRepo = await loadChatRepository(); const unlockResult = await chatRepo.unlockHistory(); if (Result.isErr(unlockResult)) { log.error("[chat-machine] unlockHistoryActor failed", { @@ -45,7 +45,7 @@ export const unlockMessageActor = fromPromise< UnlockMessageOutput, UnlockMessageRequest >(async ({ input }) => { - const chatRepo = getChatRepository(); + const chatRepo = await loadChatRepository(); const cacheIdentityResult = await resolveChatCacheOwnerKey(); const unlockResult = await chatRepo.unlockPrivateMessage({ ...(input.messageId ? { messageId: input.messageId } : {}), diff --git a/src/stores/sync/chat-payment-success-sync.tsx b/src/stores/sync/chat-payment-success-sync.tsx new file mode 100644 index 00000000..6dfc913c --- /dev/null +++ b/src/stores/sync/chat-payment-success-sync.tsx @@ -0,0 +1,47 @@ +"use client"; + +/** Payment → Chat bridge, mounted only while the chat route is active. */ +import { useEffect, useRef } from "react"; +import { shallowEqual } from "@xstate/react"; + +import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session"; +import { useChatDispatch } from "@/stores/chat/chat-context"; +import { usePaymentSelector } from "@/stores/payment/payment-context"; + +export function ChatPaymentSuccessSync() { + const paymentState = usePaymentSelector( + (state) => ({ + currentOrderId: state.context.currentOrderId, + isPaid: state.matches("paid"), + orderStatus: state.context.orderStatus, + }), + shallowEqual, + ); + const chatDispatch = useChatDispatch(); + const lastPaidKeyRef = useRef(null); + + useEffect(() => { + if (!paymentState.isPaid || !paymentState.currentOrderId) return; + + const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`; + if (lastPaidKeyRef.current === paidKey) return; + lastPaidKeyRef.current = paidKey; + + let cancelled = false; + void (async () => { + if (await hasPendingChatUnlock()) return; + if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" }); + })(); + + return () => { + cancelled = true; + }; + }, [ + chatDispatch, + paymentState.currentOrderId, + paymentState.isPaid, + paymentState.orderStatus, + ]); + + return null; +} diff --git a/src/stores/sync/index.ts b/src/stores/sync/index.ts index 71c150f4..98d7fcab 100644 --- a/src/stores/sync/index.ts +++ b/src/stores/sync/index.ts @@ -1,3 +1,4 @@ export * from "./chat-auth-sync"; +export * from "./chat-payment-success-sync"; export * from "./payment-success-sync"; export * from "./user-auth-sync"; diff --git a/src/stores/sync/payment-success-sync.tsx b/src/stores/sync/payment-success-sync.tsx index eb6afe24..95252962 100644 --- a/src/stores/sync/payment-success-sync.tsx +++ b/src/stores/sync/payment-success-sync.tsx @@ -8,9 +8,7 @@ import { useEffect, useRef } from "react"; import { shallowEqual } from "@xstate/react"; -import { useChatDispatch } from "@/stores/chat/chat-context"; import { useUserDispatch } from "@/stores/user/user-context"; -import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session"; import { usePaymentDispatch, usePaymentSelector, @@ -54,42 +52,3 @@ export function PaymentSuccessSync() { return null; } - -/** Payment → Chat bridge, mounted only while the chat route is active. */ -export function ChatPaymentSuccessSync() { - const paymentState = usePaymentSelector( - (state) => ({ - currentOrderId: state.context.currentOrderId, - isPaid: state.matches("paid"), - orderStatus: state.context.orderStatus, - }), - shallowEqual, - ); - const chatDispatch = useChatDispatch(); - const lastPaidKeyRef = useRef(null); - - useEffect(() => { - if (!paymentState.isPaid || !paymentState.currentOrderId) return; - - const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`; - if (lastPaidKeyRef.current === paidKey) return; - lastPaidKeyRef.current = paidKey; - - let cancelled = false; - void (async () => { - if (await hasPendingChatUnlock()) return; - if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" }); - })(); - - return () => { - cancelled = true; - }; - }, [ - chatDispatch, - paymentState.currentOrderId, - paymentState.isPaid, - paymentState.orderStatus, - ]); - - return null; -}