diff --git a/apps/web/src/api/mutations/sign-transfer.ts b/apps/web/src/api/mutations/sign-transfer.ts index f0f6d488b0..ef5dbb8b95 100644 --- a/apps/web/src/api/mutations/sign-transfer.ts +++ b/apps/web/src/api/mutations/sign-transfer.ts @@ -3,7 +3,7 @@ import { UseQueryResult } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { hpToVests } from "@/features/shared/transfer/hp-to-vests"; -import { TransferAsset, TransferMode } from "@/features/shared"; +import type { TransferAsset, TransferMode } from "@/features/shared/transfer"; import { DEFAULT_DYNAMIC_PROPS } from "@/consts/default-dynamic-props"; import { getDynamicPropsQueryOptions } from "@ecency/sdk"; import { useActiveAccount } from "@/core/hooks/use-active-account"; diff --git a/apps/web/src/api/sdk-mutations/use-upload-image-mutation.ts b/apps/web/src/api/sdk-mutations/use-upload-image-mutation.ts index 585493a25a..01f211bbe2 100644 --- a/apps/web/src/api/sdk-mutations/use-upload-image-mutation.ts +++ b/apps/web/src/api/sdk-mutations/use-upload-image-mutation.ts @@ -4,7 +4,7 @@ import { useAddImage, useUploadImage } from "@ecency/sdk"; import { useActiveUsername } from "@/core/hooks/use-active-username"; import { ensureValidToken } from "@/utils"; import { useMutation } from "@tanstack/react-query"; -import { error, success } from "@/features/shared"; +import { error, success } from "@/features/shared/feedback"; import i18next from "i18next"; import { EcencyConfigManager } from "@/config"; diff --git a/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx b/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx index d3a82bbffd..bc6ab9e8ff 100644 --- a/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx +++ b/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx @@ -5,9 +5,9 @@ import { SelectionPopover } from "./selection-popover"; import { EntryPageViewerManager } from "./entry-page-viewer-manager"; import { useContext, useEffect, useState } from "react"; import { SafeTweet } from "@/features/shared/safe-tweet"; -import TransactionSigner from "@/features/shared/transactions/transaction-signer"; import { EntryPageContext } from "./context"; import dynamic from "next/dynamic"; +import { DialogChunkSpinner } from "@/features/shared/dialog-chunk-spinner"; import { makeEntryPath } from "@/utils"; import i18next from "i18next"; @@ -15,6 +15,14 @@ import i18next from "i18next"; // polls, gif picker). It only renders in edit mode, so keep it out of the // read-path bundle and load it on demand when the user enters editing. The // loading fallback avoids a blank slot during the first chunk download. +// Interaction-only: the signing dialog opens when a reader clicks an embedded +// hive:// operation link. Keep it out of the read-path bundle (#1668) and load +// it on demand; `show` is false until then, so nothing renders during SSR. +const TransactionSigner = dynamic( + () => import("@/features/shared/transactions/transaction-signer"), + { ssr: false, loading: () => } +); + const EntryPageEdit = dynamic( () => import("./entry-page-edit").then((m) => m.EntryPageEdit), { @@ -152,11 +160,13 @@ export function EntryPageBodyViewer({ entry }: Props) { )} {isEdit && entry.parent_author && } - setSigningOperation(undefined)} - operation={signingOperation} - /> + {signingOperation !== undefined && ( + setSigningOperation(undefined)} + operation={signingOperation} + /> + )} ); } diff --git a/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/page.tsx b/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/page.tsx index 3c2ffb35bb..617b8e5464 100644 --- a/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/page.tsx +++ b/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/page.tsx @@ -271,11 +271,27 @@ export default async function EntryPage({ params, searchParams }: Props) { its bounded feed fetches stream as a later chunk instead of gating the post body's flush / LCP; the anchors still ship in the same streamed HTML response. */} + {/* + content-visibility on the two big below-fold sections keeps + them out of the first layout/paint pass; the intrinsic-size + hints reserve approximate scroll height until they render. + The discussions hint matches the ANON SSR shell (heading + + "Show N comments" button, ~150px), not loaded comments: on a + zero-comment post an oversized hint would reserve blank space + and visibly collapse (#1669 review). The auto keyword memoizes + the real size after first render. + Both wrappers start off-screen on any normal post, which is + the precondition for content-visibility:auto to help (#1668). + */} - +
+ +
- +
+ +
diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/_components/profile-referrals/profile-referrals-table.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/_components/profile-referrals/profile-referrals-table.tsx index 37bb033790..b4ce57f797 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/_components/profile-referrals/profile-referrals-table.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/_components/profile-referrals/profile-referrals-table.tsx @@ -2,7 +2,8 @@ import { Table, Td, Th, Tr } from "@ui/table"; import dayjs from "@/utils/dayjs"; import React, { useCallback, useMemo, useState } from "react"; import i18next from "i18next"; -import { ProfileLink, Transfer, TransferAsset, TransferMode, UserAvatar } from "@/features/shared"; +import { ProfileLink, UserAvatar } from "@/features/shared"; +import { Transfer, TransferAsset, TransferMode } from "@/features/shared/transfer"; import { getReferralsInfiniteQueryOptions } from "@ecency/sdk"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Account } from "@/entities"; diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/account-recovery.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/account-recovery.tsx index 159819f9f8..3b402d1122 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/account-recovery.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/account-recovery.tsx @@ -2,7 +2,8 @@ import { formatError } from "@/api/format-error"; import { useActiveAccount } from "@/core/hooks/use-active-account"; -import { error, KeyOrHot, success } from "@/features/shared"; +import { error, success } from "@/features/shared"; +import { KeyOrHot } from "@/features/shared/key-or-hot"; import { getAccountFullQueryOptions, getAccountPendingRecoveryQueryOptions, diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/add-keys-steps/step-4-confirm.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/add-keys-steps/step-4-confirm.tsx index eece0bd3f4..38cd1096a9 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/add-keys-steps/step-4-confirm.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/add-keys-steps/step-4-confirm.tsx @@ -1,7 +1,8 @@ import { formatError } from "@/api/format-error"; import { updateAccountKeysCache } from "@/api/mutations/update-account-keys-cache"; import { useActiveAccount } from "@/core/hooks/use-active-account"; -import { error, success, KeyOrHot } from "@/features/shared"; +import { error, success } from "@/features/shared"; +import { KeyOrHot } from "@/features/shared/key-or-hot"; import { Button } from "@/features/ui"; import { getAccountFullQueryOptions, dedupeAndSortKeyAuths } from "@ecency/sdk"; import { deriveHiveMasterPasswordKeys } from "@ecency/wallets"; diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-authorities.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-authorities.tsx index 74992d46ac..5aab2849ea 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-authorities.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-authorities.tsx @@ -1,7 +1,8 @@ "use client"; import { useActiveAccount } from "@/core/hooks/use-active-account"; -import { error, KeyOrHot, UserAvatar } from "@/features/shared"; +import { error, UserAvatar } from "@/features/shared"; +import { KeyOrHot } from "@/features/shared/key-or-hot"; import { ProfilePreview } from "@/features/shared/profile-popover/profile-preview"; import { Popover } from "@/features/ui"; import { getAccountFullQueryOptions, useAccountRevokePosting } from "@ecency/sdk"; diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-keys-dialog.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-keys-dialog.tsx index 751200e84f..26c5a4ffbe 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-keys-dialog.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-keys-dialog.tsx @@ -1,7 +1,8 @@ import { formatError } from "@/api/format-error"; import { updateAccountKeysCache } from "@/api/mutations/update-account-keys-cache"; import { useActiveAccount } from "@/core/hooks/use-active-account"; -import { error, success, KeyOrHot } from "@/features/shared"; +import { error, success } from "@/features/shared"; +import { KeyOrHot } from "@/features/shared/key-or-hot"; import { Button, Modal, ModalBody, ModalHeader } from "@/features/ui"; import { getAccountFullQueryOptions, diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hp/_components/hp-delegations-card.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hp/_components/hp-delegations-card.tsx index 64d057b499..1a57a29c74 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hp/_components/hp-delegations-card.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hp/_components/hp-delegations-card.tsx @@ -14,7 +14,7 @@ import { useQuery } from "@tanstack/react-query"; import { ReceivedVesting } from "./received-vesting-dialog"; import { useEffect, useMemo, useState } from "react"; import { DelegatedVesting } from "./delegated-vesting-dialog"; -import { Transfer } from "@/features/shared"; +import { Transfer } from "@/features/shared/transfer"; import { useActiveAccount } from "@/core/hooks/use-active-account"; import useLocalStorage from "react-use/lib/useLocalStorage"; diff --git a/apps/web/src/app/client-providers.tsx b/apps/web/src/app/client-providers.tsx index 2738e2a049..56da127aea 100644 --- a/apps/web/src/app/client-providers.tsx +++ b/apps/web/src/app/client-providers.tsx @@ -3,6 +3,7 @@ import "@/polyfills"; import "@/core/sdk-init"; import { ClientInit } from "@/app/client-init"; +import { EntryStatsPrefetch } from "@/app/entry-stats-prefetch"; import { EcencyConfigManager } from "@/config"; import { getQueryClient } from "@/core/react-query"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -69,6 +70,10 @@ export function ClientProviders(props: PropsWithChildren) { > + {/* Inside UIManager but OUTSIDE DeferredRender on purpose: its + effect must flush with root hydration, not after the lazy + feature cascade (#1668). */} + {props.children} {/* Defer non-critical components for LCP optimization */} diff --git a/apps/web/src/app/communities/create/_components/community-create-sign-step.tsx b/apps/web/src/app/communities/create/_components/community-create-sign-step.tsx index a57bcb1c60..e208d0b2a7 100644 --- a/apps/web/src/app/communities/create/_components/community-create-sign-step.tsx +++ b/apps/web/src/app/communities/create/_components/community-create-sign-step.tsx @@ -3,7 +3,7 @@ import { useCreateCommunityByHivesigner, useCreateCommunityByKeychain } from "@/api/mutations"; -import { KeyOrHot } from "@/features/shared"; +import { KeyOrHot } from "@/features/shared/key-or-hot"; import { PrivateKey } from "@ecency/sdk"; import { useCallback } from "react"; import { CommunityCreateCardLayout } from "./community-create-card-layout"; diff --git a/apps/web/src/app/decks/_components/columns/deck-wallet-column.tsx b/apps/web/src/app/decks/_components/columns/deck-wallet-column.tsx index 9141b3fd4f..5c219cff2c 100644 --- a/apps/web/src/app/decks/_components/columns/deck-wallet-column.tsx +++ b/apps/web/src/app/decks/_components/columns/deck-wallet-column.tsx @@ -10,7 +10,7 @@ import { Transaction } from "@/entities"; import { getDynamicPropsQueryOptions, getTransactionsInfiniteQueryOptions } from "@ecency/sdk"; import i18next from "i18next"; import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; -import { TransactionRow } from "@/features/shared"; +import { TransactionRow } from "@/features/shared/transactions"; interface Props { id: string; diff --git a/apps/web/src/app/decks/_components/deck-toolbar/deck-toolbar.tsx b/apps/web/src/app/decks/_components/deck-toolbar/deck-toolbar.tsx index 5897bd6513..1984704fc7 100644 --- a/apps/web/src/app/decks/_components/deck-toolbar/deck-toolbar.tsx +++ b/apps/web/src/app/decks/_components/deck-toolbar/deck-toolbar.tsx @@ -6,7 +6,8 @@ import { DeckToolbarToggleArea } from "./deck-toolbar-toggle-area"; import { DeckToolbarManager } from "./deck-toolbar-manager"; import { DeckToolbarCreate } from "./deck-toolbar-create"; import { useGlobalStore } from "@/core/global-store"; -import { LoginDialog, NotificationHandler, PurchaseQrDialog } from "@/features/shared"; +import { LoginDialog, NotificationHandler } from "@/features/shared"; +import { PurchaseQrDialog } from "@/features/shared/purchase-qr"; import { FragmentsDialog } from "@/features/shared/fragments"; import { SchedulesDialog } from "@/features/shared/schedules"; import { BookmarksDialog } from "@/features/shared/bookmarks"; diff --git a/apps/web/src/app/entry-stats-prefetch.tsx b/apps/web/src/app/entry-stats-prefetch.tsx new file mode 100644 index 0000000000..3d1e21786c --- /dev/null +++ b/apps/web/src/app/entry-stats-prefetch.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { getPostTipsQueryOptions, getProMembersQueryOptions } from "@ecency/sdk"; + +/* + Warms the two auth-free queries that paint an entry page's final pixels + (post tips and pro-members badges), firing with root hydration (#1668). + + Without this, both fetch only when their components mount — and those sit + under the route-level streamed Suspense boundary, which React hydrates + lazily AFTER the DeferredRender feature cascade, pushing the requests to + ~4.5s although hydration completes ~2.9s. Prefetching from the root client + tree dedupes cleanly: the later component mounts hit the fresh cache entry + (staleTime 60s tips / 5min pro-members). + + Section names below must stay in lockstep with the entry-vs-section split + in features/next-middleware/cache-policy.ts, which keeps the union in TWO + places: NO_CACHE_PROFILE_SECTIONS (wallet/settings/permissions/referrals/ + insights) plus the inline section array in its 2-segment entry branch. A + future section missing here costs exactly one throwaway 404 GET, nothing + more. +*/ +const PROFILE_SECTIONS = new Set([ + "wallet", + "settings", + "permissions", + "referrals", + "insights", + "posts", + "blog", + "comments", + "replies", + "communities", + "trail", + "followers", + "following", + "rss", + "rss.xml", + "feed" +]); + +// /@author/permlink or /:category/@author/permlink +const ENTRY_2 = /^\/@([\w.-]+)\/([a-z0-9-]+)\/?$/; +const ENTRY_3 = /^\/[^/@]+\/@([\w.-]+)\/([a-z0-9-]+)\/?$/; + +export function EntryStatsPrefetch(): null { + const pathname = usePathname(); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!pathname) { + return; + } + const match = ENTRY_2.exec(pathname) ?? ENTRY_3.exec(pathname); + if (!match) { + return; + } + // The query key must match what the entry components build from + // entry.author, which is always lowercase on chain. + const author = match[1].toLowerCase(); + const permlink = match[2]; + if (ENTRY_2.exec(pathname) && PROFILE_SECTIONS.has(permlink)) { + return; + } + void queryClient.prefetchQuery(getPostTipsQueryOptions(author, permlink)); + void queryClient.prefetchQuery(getProMembersQueryOptions()); + }, [pathname, queryClient]); + + return null; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c0e61a69de..9a6029e52a 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,7 +1,7 @@ import "@/styles/style.scss"; import "@/core/sdk-init"; // Initialize SDK DMCA filters immediately (SSR) import Providers from "@/app/providers"; -import { HiringConsoleLog } from "@/app/_components"; +import { HiringConsoleLog } from "@/app/_components/hiring-console-log"; import { cookies } from "next/headers"; import { Theme } from "@/enums"; import { BannerManager } from "@/features/banners"; diff --git a/apps/web/src/app/market/_components/hive-barter/index.tsx b/apps/web/src/app/market/_components/hive-barter/index.tsx index cbca3205de..844b0c1083 100644 --- a/apps/web/src/app/market/_components/hive-barter/index.tsx +++ b/apps/web/src/app/market/_components/hive-barter/index.tsx @@ -3,7 +3,8 @@ import { FormControl, InputGroup } from "@ui/input"; import { Button } from "@ui/button"; import { Form } from "@ui/form"; import { BuySellHiveTransactionType } from "@/enums"; -import { BuySellHiveDialog, error, Skeleton } from "@/features/shared"; +import { error, Skeleton } from "@/features/shared"; +import { BuySellHiveDialog } from "@/features/shared/buy-sell-hive"; import i18next from "i18next"; interface Props { diff --git a/apps/web/src/app/market/_components/open-orders/index.tsx b/apps/web/src/app/market/_components/open-orders/index.tsx index b431e1168b..e839a428b3 100644 --- a/apps/web/src/app/market/_components/open-orders/index.tsx +++ b/apps/web/src/app/market/_components/open-orders/index.tsx @@ -3,7 +3,8 @@ import { Button } from "@ui/button"; import { Table, Td, Th, Tr } from "@ui/table"; import i18next from "i18next"; import { OpenOrdersData } from "@/entities"; -import { BuySellHiveDialog, Skeleton } from "@/features/shared"; +import { Skeleton } from "@/features/shared"; +import { BuySellHiveDialog } from "@/features/shared/buy-sell-hive"; import { BuySellHiveTransactionType } from "@/enums"; import { dateToFormatted, dateToFullRelative, formattedNumber } from "@/utils"; diff --git a/apps/web/src/app/wallet/setup-external/_components/setup-external-metamask.tsx b/apps/web/src/app/wallet/setup-external/_components/setup-external-metamask.tsx index 4458080f7b..03260408d3 100644 --- a/apps/web/src/app/wallet/setup-external/_components/setup-external-metamask.tsx +++ b/apps/web/src/app/wallet/setup-external/_components/setup-external-metamask.tsx @@ -3,7 +3,8 @@ import { formatError } from "@/api/format-error"; import { useActiveAccount } from "@/core/hooks/use-active-account"; import { getQueryClient } from "@/core/react-query"; -import { error, KeyOrHot, Stepper } from "@/features/shared"; +import { error, Stepper } from "@/features/shared"; +import { KeyOrHot } from "@/features/shared/key-or-hot"; import { Button, Spinner } from "@/features/ui"; import { EcencyWalletCurrency, diff --git a/apps/web/src/core/caches/pin-tracker-cache.ts b/apps/web/src/core/caches/pin-tracker-cache.ts index a6a8cb848b..dfe7446e52 100644 --- a/apps/web/src/core/caches/pin-tracker-cache.ts +++ b/apps/web/src/core/caches/pin-tracker-cache.ts @@ -8,7 +8,7 @@ import { usePinPostMutation } from "@/api/sdk-mutations"; import { Community, Entry } from "@/entities"; import { isCommunity } from "@/utils"; import { clone } from "remeda"; -import { error, success } from "@/features/shared"; +import { error, success } from "@/features/shared/feedback"; import i18next from "i18next"; /** diff --git a/apps/web/src/core/global-store/modules/global-module.ts b/apps/web/src/core/global-store/modules/global-module.ts index bf25f144b1..e3c5debddf 100644 --- a/apps/web/src/core/global-store/modules/global-module.ts +++ b/apps/web/src/core/global-store/modules/global-module.ts @@ -1,7 +1,8 @@ import Cookies from "js-cookie"; import { AllFilter, ListStyle, Theme } from "@/enums"; import * as ls from "@/utils/local-storage"; -import { success } from "@/features/shared"; +import { setDayjsLocale } from "@/utils/dayjs"; +import { success } from "@/features/shared/feedback"; import i18next from "i18next"; import { loadLocale } from "@/features/i18n"; import { getCurrencyRate } from "@ecency/sdk"; @@ -86,7 +87,10 @@ export function createGlobalActions(set: (state: Partial) => void, getSta setLang: async (lang: string) => { ls.set("lang", lang); ls.set("current-language", lang); - await loadLocale(lang); + // Both loads complete BEFORE i18next switches and BEFORE the state + // publish below, so every render triggered by the change already has + // the translation resources AND the dayjs table (#1669 review). + await Promise.all([loadLocale(lang), setDayjsLocale(lang)]); await i18next.changeLanguage(lang); set({ lang: lang ?? "en-US" diff --git a/apps/web/src/entities/private-api/drafts.ts b/apps/web/src/entities/private-api/drafts.ts index 8330ac7b4a..783de5f234 100644 --- a/apps/web/src/entities/private-api/drafts.ts +++ b/apps/web/src/entities/private-api/drafts.ts @@ -1,5 +1,5 @@ import { BeneficiaryRoute, DecentMemesEntry, MetaData, RewardType } from "../operations"; -import { PollSnapshot } from "@/features/polls"; +import type { PollSnapshot } from "@/features/polls"; export interface DraftMetadata extends MetaData { // Optional because the drafts API genuinely omits them, which is how diff --git a/apps/web/src/features/i18n/index.ts b/apps/web/src/features/i18n/index.ts index 5bab79145c..9ec4d79b46 100644 --- a/apps/web/src/features/i18n/index.ts +++ b/apps/web/src/features/i18n/index.ts @@ -1,6 +1,6 @@ import i18n from "i18next"; -import dayjs from "@/utils/dayjs"; +import { setDayjsLocale } from "@/utils/dayjs"; import * as ls from "@/utils/local-storage"; export const langOptions = [ @@ -164,7 +164,7 @@ export function initI18next(): Promise { i18n.on("languageChanged", async function (lang) { await loadLocale(lang); - dayjs.locale(lang); + await setDayjsLocale(lang); }); // Load the user's preferred locale on demand @@ -173,6 +173,7 @@ export function initI18next(): Promise { if (userLang !== "en-US") { await loadLocale(userLang); } + await setDayjsLocale(userLang); await i18n.changeLanguage(userLang); })(); diff --git a/apps/web/src/features/i18n/navigation-locale-watcher-client.tsx b/apps/web/src/features/i18n/navigation-locale-watcher-client.tsx index 24dd718b7d..d81b277313 100644 --- a/apps/web/src/features/i18n/navigation-locale-watcher-client.tsx +++ b/apps/web/src/features/i18n/navigation-locale-watcher-client.tsx @@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo } from "react"; import { useGlobalStore } from "@/core/global-store"; import useUnmount from "react-use/lib/useUnmount"; import i18next from "i18next"; -import dayjs from "@/utils/dayjs"; +import { setDayjsLocale } from "@/utils/dayjs"; interface Props { targetLanguage?: string | null; @@ -25,12 +25,15 @@ export function NavigationLocaleWatcherClient({ targetLanguage }: Props) { const lang = useGlobalStore((state) => state.lang); const setLang = useGlobalStore((state) => state.setLang); - const localeChanged = useCallback((lang: string) => dayjs.locale(lang), []); + const localeChanged = useCallback((lang: string) => void setDayjsLocale(lang), []); useEffect(() => { if (derivedLanguage && lang !== derivedLanguage) { + // setLang is the ordered pipeline: it loads the i18n resources and the + // dayjs table, switches i18next and only then publishes the state. A + // separate direct changeLanguage() here would re-render with a + // half-switched locale (#1669 review). setLang(derivedLanguage); - void i18next.changeLanguage(derivedLanguage); } }, [derivedLanguage, lang, setLang]); @@ -45,7 +48,6 @@ export function NavigationLocaleWatcherClient({ targetLanguage }: Props) { useUnmount(() => { const currentLang = ls.get("current-language"); setLang(currentLang); - i18next.changeLanguage(currentLang); i18next.off("languageChanged", localeChanged); }); diff --git a/apps/web/src/features/market/market-swap-form/market-swap-active-orders/index.tsx b/apps/web/src/features/market/market-swap-form/market-swap-active-orders/index.tsx index f91036a6f8..a0c59dd505 100644 --- a/apps/web/src/features/market/market-swap-form/market-swap-active-orders/index.tsx +++ b/apps/web/src/features/market/market-swap-form/market-swap-active-orders/index.tsx @@ -4,7 +4,7 @@ import "./index.scss"; import { OpenOrdersData } from "@/entities"; import { getOpenOrdersQueryOptions } from "@ecency/sdk"; import i18next from "i18next"; -import { BuySellHiveDialog } from "@/features/shared"; +import { BuySellHiveDialog } from "@/features/shared/buy-sell-hive"; import { BuySellHiveTransactionType } from "@/enums"; import { useActiveAccount } from "@/core/hooks/use-active-account"; import { useQueryClient } from "@tanstack/react-query"; diff --git a/apps/web/src/features/shared/available-credits/index.tsx b/apps/web/src/features/shared/available-credits/index.tsx index 433c8a8247..1795555159 100644 --- a/apps/web/src/features/shared/available-credits/index.tsx +++ b/apps/web/src/features/shared/available-credits/index.tsx @@ -20,7 +20,16 @@ import i18next from "i18next"; import dayjs, { Dayjs } from "@/utils/dayjs"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; -import { PurchaseQrDialog } from "../purchase-qr"; +import dynamic from "next/dynamic"; +import { DialogChunkSpinner } from "../dialog-chunk-spinner"; + +// Modal-on-demand: available-credits stays in the shared barrel, and a static +// import here was the last path dragging the purchase-qr family into every +// route's pre-paint graph (#1668). The chunk loads on first open. +const PurchaseQrDialog = dynamic( + () => import("../purchase-qr").then((m) => ({ default: m.PurchaseQrDialog })), + { ssr: false, loading: () => } +); interface Props { username: string; operation: RcOperation; @@ -210,7 +219,9 @@ export const AvailableCredits = ({ username, payload, className }: Props) => { , portalContainer )} - setShowPurchaseDialog(v)} /> + {showPurchaseDialog && ( + setShowPurchaseDialog(v)} /> + )} ) : ( <> diff --git a/apps/web/src/features/shared/dialog-chunk-spinner.tsx b/apps/web/src/features/shared/dialog-chunk-spinner.tsx new file mode 100644 index 0000000000..c2b607b6ca --- /dev/null +++ b/apps/web/src/features/shared/dialog-chunk-spinner.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Spinner } from "@ui/spinner"; +import type { JSX } from "react"; + +/** + * Fallback for dynamically imported modals (#1669 review): rendered from the + * moment a dialog is requested until its chunk mounts, so a first open never + * shows nothing while the network fetch runs. Same layer as the modal that + * replaces it. + */ +export function DialogChunkSpinner(): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/features/shared/index.ts b/apps/web/src/features/shared/index.ts index c9a3e95d38..d1e1e90388 100644 --- a/apps/web/src/features/shared/index.ts +++ b/apps/web/src/features/shared/index.ts @@ -13,9 +13,7 @@ export * from "./notification-handler"; export * from "./switch-lang"; export * from "./search-suggester"; export * from "./suggestion-list"; -export * from "./key-or-hot-dialog"; export * from "./entry-link"; -export * from "./purchase-qr"; export * from "./follow-controls"; export * from "./image-upload-button"; export * from "./list-style-toggle"; @@ -29,17 +27,20 @@ export * from "./entry-list-content"; export * from "./entry-list-item"; export * from "./entry-menu"; export * from "./skeleton"; -export * from "./promote"; export * from "./profile-popover"; export * from "./entry-vote-btn"; export * from "./entry-tip-btn"; -export * from "./transfer"; export * from "./entry-payout"; export * from "./entry-votes"; export * from "./entry-reblog-btn"; export * from "./message-no-data"; export * from "./entry-info"; export * from "./bookmark-btn"; +// NOTE: the interaction-only dialog families (./transfer, ./promote, ./boost, +// ./purchase-qr, ./buy-sell-hive, ./key-or-hot, ./transactions) are intentionally +// NOT re-exported from this barrel. Re-exporting them dragged their chunks into +// EVERY route's pre-paint graph through the navbar's client boundary (#1668). +// Deep-import them: "@/features/shared/". // NOTE: ./discussion is intentionally NOT re-exported from this barrel. The // Discussion tree (DiscussionList -> DiscussionItem) imports the reply/edit // composer, so re-exporting it here dragged that whole path into any page that @@ -51,19 +52,14 @@ export * from "./entry-delete-btn"; // autocomplete, polls and video upload (~80KB) — importing any unrelated symbol // from this barrel dragged all of that into pages that never render a composer. // Import it directly: `import { Comment } from "@/features/shared/comment"`. -export * from "./transactions"; export * from "./click-away-listener"; export * from "./available-credits"; export * from "./login"; export * from "./wallet-badge"; -export * from "./buy-sell-hive"; -export * from "./boost"; -export * from "./key-or-hot"; export * from "./metamask-sign-button"; export * from "./image-upload-button"; export * from "./notifications"; export * from "./gallery"; -export * from "./boost"; export * from "./static-navbar"; export * from "./edit-history"; // NOTE: ./editor-toolbar is intentionally NOT re-exported here — see the ./comment diff --git a/apps/web/src/features/shared/key-or-hot-dialog.tsx b/apps/web/src/features/shared/key-or-hot-dialog.tsx deleted file mode 100644 index cb87b21cd8..0000000000 --- a/apps/web/src/features/shared/key-or-hot-dialog.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import React, { PropsWithChildren, useState } from "react"; -import { PrivateKey } from "@ecency/sdk"; -import { PopoverConfirm } from "@ui/popover-confirm"; -import { Modal, ModalBody, ModalHeader } from "@ui/modal"; -import { KeyOrHot } from "@/features/shared/key-or-hot"; - -interface Props { - popOver?: boolean; - onKey?: (key: PrivateKey) => void; - onHot?: () => void; - onKc?: () => void; - onMetaMask?: () => void; - onToggle?: () => void; -} - -export function KeyOrHotDialog({ - children, - onKey, - onHot, - onKc, - onMetaMask, - popOver = false, - onToggle -}: PropsWithChildren) { - const [keyDialog, setKeyDialog] = useState(false); - - const toggleKeyDialog = () => { - setKeyDialog(!keyDialog); - onToggle?.(); - }; - - return ( - <> - {popOver ? ( -
- toggleKeyDialog()}> -
e.stopPropagation()}>{children}
-
-
- ) : ( -
setKeyDialog(true)}>{children}
- )} - - {keyDialog && ( - - - - { - toggleKeyDialog(); - onKey?.(key); - }} - onHot={() => { - toggleKeyDialog(); - if (onHot) { - onHot(); - } - }} - onKc={() => { - toggleKeyDialog(); - if (onKc) { - onKc(); - } - }} - onMetaMask={() => { - toggleKeyDialog(); - onMetaMask?.(); - }} - inProgress={false} - /> - - - )} - - ); -} diff --git a/apps/web/src/features/shared/login/login-user-item.tsx b/apps/web/src/features/shared/login/login-user-item.tsx index 9459425043..9cdf887857 100644 --- a/apps/web/src/features/shared/login/login-user-item.tsx +++ b/apps/web/src/features/shared/login/login-user-item.tsx @@ -1,6 +1,6 @@ import { useActiveAccount } from "@/core/hooks/use-active-account"; import { User } from "@/entities"; -import { UserAvatar } from "@/features/shared"; +import { UserAvatar } from "@/features/shared/user-avatar"; import { UilTrash } from "@tooni/iconscout-unicons-react"; import { Button } from "@ui/button"; import { PopoverConfirm } from "@ui/popover-confirm"; diff --git a/apps/web/src/features/shared/navbar/anon-user-buttons.tsx b/apps/web/src/features/shared/navbar/anon-user-buttons.tsx index cd0cdcadff..d689c56694 100644 --- a/apps/web/src/features/shared/navbar/anon-user-buttons.tsx +++ b/apps/web/src/features/shared/navbar/anon-user-buttons.tsx @@ -3,7 +3,6 @@ import React from "react"; import { useActiveAccount } from "@/core/hooks/use-active-account"; import { useGlobalStore } from "@/core/global-store"; import i18next from "i18next"; -import { preloadLoginDialog } from "@/features/shared"; export function AnonUserButtons() { const { activeUser } = useActiveAccount(); @@ -19,9 +18,6 @@ export function AnonUserButtons() { className="btn-login" outline={true} onClick={() => toggleUIProp("login")} - onMouseEnter={preloadLoginDialog} - onFocus={preloadLoginDialog} - onPointerDown={preloadLoginDialog} > {i18next.t("g.login")} diff --git a/apps/web/src/features/shared/navbar/navbar-desktop.tsx b/apps/web/src/features/shared/navbar/navbar-desktop.tsx index 67d0410a03..ef410a9d4d 100644 --- a/apps/web/src/features/shared/navbar/navbar-desktop.tsx +++ b/apps/web/src/features/shared/navbar/navbar-desktop.tsx @@ -1,5 +1,5 @@ import { useGlobalStore } from "@/core/global-store"; -import { UserAvatar } from "@/features/shared"; +import { UserAvatar } from "@/features/shared/user-avatar"; import { AnonUserButtons } from "@/features/shared/navbar/anon-user-buttons"; import { NavbarMainSidebar } from "@/features/shared/navbar/navbar-main-sidebar"; import { NavbarMainSidebarToggle } from "@/features/shared/navbar/navbar-main-sidebar-toggle"; diff --git a/apps/web/src/features/shared/navbar/navbar-main-sidebar.tsx b/apps/web/src/features/shared/navbar/navbar-main-sidebar.tsx index 2f82a86dda..ef00364a66 100644 --- a/apps/web/src/features/shared/navbar/navbar-main-sidebar.tsx +++ b/apps/web/src/features/shared/navbar/navbar-main-sidebar.tsx @@ -18,7 +18,7 @@ import { NavbarSideThemeSwitcher } from "@/features/shared/navbar/sidebar"; import { Button } from "@ui/button"; import i18next from "i18next"; import { closeSvg, WavyDashIcon } from "@ui/svg"; -import { SwitchLang } from "@/features/shared"; +import { SwitchLang } from "@/features/shared/switch-lang"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import Image from "next/image"; diff --git a/apps/web/src/features/shared/navbar/navbar-mobile.tsx b/apps/web/src/features/shared/navbar/navbar-mobile.tsx index edc5281fed..6ed15f7a72 100644 --- a/apps/web/src/features/shared/navbar/navbar-mobile.tsx +++ b/apps/web/src/features/shared/navbar/navbar-mobile.tsx @@ -3,7 +3,7 @@ import { useHydrated } from "@/api/queries"; import { useGlobalStore } from "@/core/global-store"; import { useActiveAccount } from "@/core/hooks/use-active-account"; -import { UserAvatar, preloadLoginDialog } from "@/features/shared"; +import { UserAvatar } from "@/features/shared/user-avatar"; import { NavbarMainSidebar } from "@/features/shared/navbar/navbar-main-sidebar"; import { NavbarNotificationsButton } from "@/features/shared/navbar/navbar-notifications-button"; import { NavbarSide } from "@/features/shared/navbar/sidebar/navbar-side"; @@ -210,9 +210,6 @@ export function NavbarMobile({ diff --git a/apps/web/src/specs/features/ui/key-input.spec.tsx b/apps/web/src/specs/features/ui/key-input.spec.tsx index 757df687fa..0fe6586fa8 100644 --- a/apps/web/src/specs/features/ui/key-input.spec.tsx +++ b/apps/web/src/specs/features/ui/key-input.spec.tsx @@ -12,7 +12,9 @@ const h = vi.hoisted(() => ({ // KeyInput imports only `error` from the shared barrel; stub it to a spy so we // can count exactly how many user-facing toasts are produced. -vi.mock("@/features/shared", () => ({ +// key-input imports error from the feedback module directly since the barrel +// stopped carrying pre-paint routes through it (#1668). +vi.mock("@/features/shared/feedback", () => ({ error: (message: string) => h.errorSpy(message) })); diff --git a/apps/web/src/specs/setup-any-spec.ts b/apps/web/src/specs/setup-any-spec.ts index 168c6f2047..b3c84777eb 100644 --- a/apps/web/src/specs/setup-any-spec.ts +++ b/apps/web/src/specs/setup-any-spec.ts @@ -89,6 +89,7 @@ vi.mock("@ecency/sdk", async () => ({ getBoostPlusPricesQueryOptions: vi.fn(() => ({ queryKey: ["boost-prices"], queryFn: vi.fn() })), getPointsQueryOptions: vi.fn(() => ({ queryKey: ["points"], queryFn: vi.fn() })), getProMembersQueryOptions: vi.fn(() => ({ queryKey: ["accounts", "pro-members"], queryFn: vi.fn() })), + getPostTipsQueryOptions: vi.fn((author: string, permlink: string) => ({ queryKey: ["posts", "tips", author, permlink], queryFn: vi.fn() })), getTrendingTagsQueryOptions: vi.fn((limit?: number) => ({ queryKey: ["tags", "trending", limit], queryFn: vi.fn(() => []), diff --git a/apps/web/src/specs/utils/dayjs-locale.spec.ts b/apps/web/src/specs/utils/dayjs-locale.spec.ts new file mode 100644 index 0000000000..f968ae8b46 --- /dev/null +++ b/apps/web/src/specs/utils/dayjs-locale.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import dayjs, { setDayjsLocale } from "@/utils/dayjs"; + +/* + Pins for #1668 item 1: locale tables must not ship eagerly (they rode in the + pre-paint chunk wave), but the language-change path must still localize. + Test order matters: the eager-registration check has to run before any + setDayjsLocale call registers a table into the shared dayjs instance. +*/ +describe("dayjs locale loading (#1668)", () => { + it("registers no locale tables at import time", () => { + const registered = Object.keys((dayjs as unknown as { Ls: Record }).Ls); + expect(registered).toEqual(["en"]); + }); + + it("loads and applies a table on demand from a regional i18next code", async () => { + await setDayjsLocale("es-ES"); + expect(dayjs.locale()).toBe("es"); + await setDayjsLocale("zh-CN"); + expect(dayjs.locale()).toBe("zh-cn"); + }); + + it("keeps the current locale for languages without a bundled table", async () => { + await setDayjsLocale("es-ES"); + await setDayjsLocale("fr-FR"); + expect(dayjs.locale()).toBe("es"); + }); + + it("returns to built-in english", async () => { + await setDayjsLocale("en-US"); + expect(dayjs.locale()).toBe("en"); + }); +}); + +describe("dayjs locale race protection (#1669 review)", () => { + it("ignores an older request that resolves after a newer one", async () => { + await setDayjsLocale("es-ES"); // warm the es table so the newer call applies instantly + // Older request needs an uncached chunk (slow); newer hits the cache + // (instant). Without the stale-request guard the late-resolving older + // request would overwrite the newer selection with "ru". + const older = setDayjsLocale("ru-RU"); + const newer = setDayjsLocale("es-ES"); + await Promise.all([older, newer]); + expect(dayjs.locale()).toBe("es"); + }); +}); + +describe("dayjs locale server safety (#1669 review)", () => { + it("is a no-op without a window (dayjs.locale is a process-global on the server)", async () => { + const before = dayjs.locale(); + const win = globalThis.window; + // @ts-expect-error deliberately simulating the server environment + delete globalThis.window; + try { + await setDayjsLocale("th-TH"); + } finally { + globalThis.window = win; + } + expect(dayjs.locale()).toBe(before); + }); +}); diff --git a/apps/web/src/utils/dayjs.ts b/apps/web/src/utils/dayjs.ts index d2bd2b9393..f6bfe5f2eb 100644 --- a/apps/web/src/utils/dayjs.ts +++ b/apps/web/src/utils/dayjs.ts @@ -8,20 +8,6 @@ import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; import calendar from "dayjs/plugin/calendar"; import localizedFormat from "dayjs/plugin/localizedFormat"; import minMax from "dayjs/plugin/minMax"; -import "dayjs/locale/bg"; -import "dayjs/locale/es"; -import "dayjs/locale/fi"; -import "dayjs/locale/hi"; -import "dayjs/locale/id"; -import "dayjs/locale/it"; -import "dayjs/locale/pt"; -import "dayjs/locale/ru"; -import "dayjs/locale/sr"; -import "dayjs/locale/uk"; -import "dayjs/locale/uz"; -import "dayjs/locale/zh-cn"; -import "dayjs/locale/th"; -import "dayjs/locale/tr"; // Extend dayjs with commonly used plugins // These are needed for replacing moment.js features across the codebase @@ -41,5 +27,65 @@ dayjs.extend(calendar); dayjs.extend(localizedFormat); dayjs.extend(minMax); +/* + Locale tables load on demand (#1668). The 14 eager imports above used to ride + in the pre-paint chunk wave (chunk 2738, 135 KB wire) although a visitor uses + at most one; English ships built into dayjs. The explicit loader map keeps + the bundler emitting exactly one tiny lazy chunk per supported locale. +*/ +const DAYJS_LOCALE_LOADERS: Record Promise> = { + bg: () => import("dayjs/locale/bg"), + es: () => import("dayjs/locale/es"), + fi: () => import("dayjs/locale/fi"), + hi: () => import("dayjs/locale/hi"), + id: () => import("dayjs/locale/id"), + it: () => import("dayjs/locale/it"), + pt: () => import("dayjs/locale/pt"), + ru: () => import("dayjs/locale/ru"), + sr: () => import("dayjs/locale/sr"), + uk: () => import("dayjs/locale/uk"), + uz: () => import("dayjs/locale/uz"), + "zh-cn": () => import("dayjs/locale/zh-cn"), + th: () => import("dayjs/locale/th"), + tr: () => import("dayjs/locale/tr") +}; + +/** + * Loads (if needed) and applies the dayjs locale for an i18next language code. + * "es-ES" resolves to "es", "zh-CN" to "zh-cn". A language with no bundled + * table (fr, de, ja, nl, pl) keeps dates in the current locale, exactly as the + * old eager-import setup behaved. Dates rendered between the language change + * and the table arriving stay in the previous locale for that one render. + */ +let localeRequestId = 0; + +export async function setDayjsLocale(lang: string): Promise { + // CLIENT-ONLY by design. dayjs.locale() mutates a process-global singleton; + // on the server one request's ?lang= would leak its locale into every + // concurrent request's SSR (and the request-id below would race across + // requests). Server renders always use the built-in default and the client + // pipeline corrects after hydration, same contract as TimeLabel (#1669). + if (typeof window === "undefined") { + return; + } + // Stale-request guard: rapid language switches fetch chunks that can + // resolve out of order, and only the LATEST request may apply its locale. + const requestId = ++localeRequestId; + const lower = lang.toLowerCase(); + const key = lower in DAYJS_LOCALE_LOADERS ? lower : lower.split("-")[0]; + const hasTable = key in DAYJS_LOCALE_LOADERS; + if (hasTable) { + try { + await DAYJS_LOCALE_LOADERS[key](); + } catch { + return; // chunk fetch failed: keep the current locale + } + } + if (requestId !== localeRequestId) { + return; // a newer switch superseded this one while the chunk loaded + } + dayjs.locale(hasTable ? key : lang); +} + export default dayjs; export type { Dayjs } from "dayjs";