From eed3ac5fb3d0760326c4e6082b5b48bb23386e44 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 9 Sep 2026 13:19:53 +0800 Subject: [PATCH] =?UTF-8?q?frontend:=20code-split=20TON=20Connect=20out=20?= =?UTF-8?q?of=20the=20entry=20chunk=20(672=20kB=20=E2=86=92=20237=20kB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @tonconnect/{ui,sdk} stack was ~430 kB of the 672 kB entry bundle, loaded on every boot even though the title → login → hub path never touches a wallet. TonConnectUIProvider moves out of main.tsx into a lazy TonConnectGate that App.tsx mounts only around the screens that need it (wallet, earn, scan without a bound wallet), sticky once mounted so the TonConnectUI instance and its connection survive navigation. WalletScreen and EarnPanel are lazy too — a single static import of either re-inlines the whole stack. shortAddress moved to format.ts for the same reason: PassScan and HeroTab imported it from WalletScreen, which was a bridge back into the entry chunk. The chunk is prefetched right after login resolves, so pressing CONNECT does not wait on a cold fetch. scripts/check-bundle.mjs guards the split in CI (build:frontend runs it, build:all picks it up) — vite only warns at 500 kB and a warning does not fail a build. Entry 672.60 → 236.65 kB (gzip 200.42 → 73.09). --- package.json | 2 +- scripts/check-bundle.mjs | 38 +++++++++++++++++++ src/frontend/src/App.tsx | 37 ++++++++++++++++-- src/frontend/src/components/HeroTab.tsx | 2 +- src/frontend/src/components/PassScan.tsx | 2 +- .../src/components/TonConnectGate.tsx | 15 ++++++++ src/frontend/src/components/WalletScreen.tsx | 5 +-- src/frontend/src/format.ts | 6 +++ src/frontend/src/main.tsx | 10 ++--- 9 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 scripts/check-bundle.mjs create mode 100644 src/frontend/src/components/TonConnectGate.tsx create mode 100644 src/frontend/src/format.ts diff --git a/package.json b/package.json index f66addad..bfcf5dcd 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "tunnel": "ngrok http --domain dominant-annually-lobster.ngrok-free.app 3000", "xtunnel": "xtunnel http 3000", "build:bot": "tsc --noEmit false", - "build:frontend": "npm --prefix src/frontend run build", + "build:frontend": "npm --prefix src/frontend run build && node scripts/check-bundle.mjs", "build:landing": "tsx scripts/build-landing.ts", "build:all": "npm run build:bot && npm run build:landing && npm run build:frontend", "update:bot": "npx npm-check-updates -u && npm install", diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs new file mode 100644 index 00000000..370c834a --- /dev/null +++ b/scripts/check-bundle.mjs @@ -0,0 +1,38 @@ +// Budget guard for the frontend entry chunk. +// +// The TON Connect stack (~430 kB) is deliberately behind React.lazy — see +// src/frontend/src/components/TonConnectGate.tsx. That split is undone by a +// single careless static import (App.tsx importing WalletScreen or EarnPanel +// eagerly, or anything importing @tonconnect/* from a boot-path module), and +// the only symptom is a silently fatter first load. Vite only warns at 500 kB, +// and a warning does not fail CI — hence this. +// +// Run after `npm run build:frontend`. +import { readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const ASSETS = 'src/frontend/dist/assets' +// Entry today is ~237 kB (react-dom dominates). 320 kB leaves room for normal +// growth while still tripping long before a re-inlined TON Connect (+430 kB). +const ENTRY_LIMIT_BYTES = 320 * 1024 + +const entries = readdirSync(ASSETS).filter((f) => /^index-.*\.js$/.test(f)) +if (entries.length !== 1) { + console.error(`check-bundle: expected exactly one entry chunk in ${ASSETS}, found ${entries.length}. Did the build run?`) + process.exit(1) +} + +const size = statSync(path.join(ASSETS, entries[0])).size +const kb = (n) => `${(n / 1024).toFixed(1)} kB` + +if (size > ENTRY_LIMIT_BYTES) { + console.error( + `check-bundle: entry chunk ${entries[0]} is ${kb(size)}, over the ${kb(ENTRY_LIMIT_BYTES)} budget.\n` + + 'Most likely a boot-path module gained a static import of a lazy screen ' + + '(WalletScreen / EarnPanel / TonConnectGate) or of @tonconnect/*.', + ) + process.exit(1) +} + +console.log(`check-bundle: entry chunk ${entries[0]} ${kb(size)} — within the ${kb(ENTRY_LIMIT_BYTES)} budget.`) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index d555bfc6..17089567 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,17 +1,22 @@ import type { LoginResponse, PublicConfig } from './api' -import { useCallback, useEffect, useRef, useState } from 'react' +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' import { login, publicConfig } from './api' import { BaliTab } from './components/BaliTab' -import { EarnPanel } from './components/EarnPanel' import { Fork } from './components/Fork' import { HeroTab } from './components/HeroTab' import { Hub } from './components/Hub' import { MintFlow } from './components/MintFlow' import { PassScan } from './components/PassScan' import { TitleScreen } from './components/TitleScreen' -import { WalletScreen } from './components/WalletScreen' import { expand, getStartParam, haptic } from './telegram' +// ~400 kB of TON Connect — two thirds of the bundle — and nothing on the boot +// path needs it. These three are the only modules that reach it, so they share +// one async chunk that loads when a wallet screen is first opened. +const TonConnectGate = lazy(() => import('./components/TonConnectGate')) +const WalletScreen = lazy(() => import('./components/WalletScreen').then((m) => ({ default: m.WalletScreen }))) +const EarnPanel = lazy(() => import('./components/EarnPanel').then((m) => ({ default: m.EarnPanel }))) + // title → holder ? hub : fork // fork → forge | wallet(pass) // forge → wallet(forge) → forge ; forge(minted) → scan @@ -36,6 +41,7 @@ export function App() { const [config, setConfig] = useState(null) const [balance, setBalance] = useState('0') const [error, setError] = useState() + const [tonMounted, setTonMounted] = useState(false) const [{ bali, meet, referId }] = useState(() => { const start = getStartParam() const bali = start?.startsWith('bali_') ? start.slice(5) : null @@ -57,6 +63,9 @@ export function App() { setUser(result) setBalance(result.balance) setPhase('ready') + // Warm the TON Connect chunk now that the boot requests are done, so + // pressing CONNECT later doesn't wait on a cold fetch. + void import('./components/TonConnectGate') }) .catch(() => { setError('Cannot reach the realm — open the app from Telegram') @@ -69,6 +78,20 @@ export function App() { boot() }, [boot]) + // The only screens that call into TON Connect. WalletScreen also stands in + // for `scan` when no wallet is bound yet, hence the second clause. + const needsTon + = screen.name === 'wallet' + || screen.name === 'earn' + || (screen.name === 'scan' && !user?.wallet) + || (screen.name === 'hub' && screen.tab === 'earn') + + // Sticky: once the provider is up it stays up. Unmounting it would destroy + // the TonConnectUI instance and the wallet connection along with it. + useEffect(() => { + if (needsTon) setTonMounted(true) + }, [needsTon]) + // Re-login (username gate REFRESH, wallet bound). Keeps the current screen. const refreshLogin = useCallback(() => { void login() @@ -221,7 +244,13 @@ export function App() {
- {body} + {needsTon || tonMounted + ? ( + LOADING WALLET…}> + {body} + + ) + : body}
{inHub && ( diff --git a/src/frontend/src/components/HeroTab.tsx b/src/frontend/src/components/HeroTab.tsx index d61c4729..7e4d5644 100644 --- a/src/frontend/src/components/HeroTab.tsx +++ b/src/frontend/src/components/HeroTab.tsx @@ -1,7 +1,7 @@ import type { Pass } from '../api' +import { shortAddress } from '../format' import { PassPublicBlock, usePublicPass } from './PassPeek' import { PassImage } from './PassScan' -import { shortAddress } from './WalletScreen' interface HeroTabProps { pass: Pass diff --git a/src/frontend/src/components/PassScan.tsx b/src/frontend/src/components/PassScan.tsx index 771d059b..a0ddda5f 100644 --- a/src/frontend/src/components/PassScan.tsx +++ b/src/frontend/src/components/PassScan.tsx @@ -1,8 +1,8 @@ import type { LoginResponse, Pass } from '../api' import { useCallback, useEffect, useState } from 'react' import { scanPasses, selectPass } from '../api' +import { shortAddress } from '../format' import { haptic } from '../telegram' -import { shortAddress } from './WalletScreen' type ScanState = | { kind: 'scanning' } diff --git a/src/frontend/src/components/TonConnectGate.tsx b/src/frontend/src/components/TonConnectGate.tsx new file mode 100644 index 00000000..1e6ceb86 --- /dev/null +++ b/src/frontend/src/components/TonConnectGate.tsx @@ -0,0 +1,15 @@ +import { TonConnectUIProvider } from '@tonconnect/ui-react' + +// The whole @tonconnect/{ui,sdk} stack is ~400 kB minified — two thirds of the +// bundle — and nothing on the boot path (title → login → hub) needs it. Keeping +// the provider behind this default export lets App.tsx React.lazy() it, so the +// weight only lands when a wallet screen is actually reached. +// +// Default export on purpose: React.lazy resolves `.default`. +export default function TonConnectGate({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/frontend/src/components/WalletScreen.tsx b/src/frontend/src/components/WalletScreen.tsx index a94a98e3..77d4b687 100644 --- a/src/frontend/src/components/WalletScreen.tsx +++ b/src/frontend/src/components/WalletScreen.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { shortAddress } from '../format' import { useWalletBind } from '../hooks/useWalletBind' import { haptic } from '../telegram' @@ -12,10 +13,6 @@ interface WalletScreenProps { onBack: () => void } -export function shortAddress(address: string): string { - return `${address.slice(0, 4)}…${address.slice(-4)}` -} - const REASON_COPY = { forge: 'Your minted pass needs a home. Bind the TON wallet that will receive it. We only read it; the proof shows you own it.', pass: 'Connect the wallet that holds your Cube Worlds NFT. We only read it; the proof shows you own it.', diff --git a/src/frontend/src/format.ts b/src/frontend/src/format.ts new file mode 100644 index 00000000..e4fe6a3e --- /dev/null +++ b/src/frontend/src/format.ts @@ -0,0 +1,6 @@ +// Lives here rather than in WalletScreen so that PassScan and HeroTab can +// format an address without statically importing a TON Connect screen — that +// import is what used to pull the whole wallet stack into the entry chunk. +export function shortAddress(address: string): string { + return `${address.slice(0, 4)}…${address.slice(-4)}` +} diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index e6298f19..848baabb 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -1,15 +1,13 @@ -import { TonConnectUIProvider } from '@tonconnect/ui-react' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' import './theme.css' -const manifestUrl = `${window.location.origin}/tonconnect-manifest.json` - +// TonConnectUIProvider used to live here, which pulled the whole TON Connect +// stack into the entry chunk. It now sits in the lazy TonConnectGate that +// App.tsx mounts around the wallet screens only. createRoot(document.getElementById('root')!).render( - - - + , )