Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions scripts/check-bundle.mjs
Original file line number Diff line number Diff line change
@@ -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.`)
37 changes: 33 additions & 4 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -36,6 +41,7 @@ export function App() {
const [config, setConfig] = useState<PublicConfig | null>(null)
const [balance, setBalance] = useState('0')
const [error, setError] = useState<string | undefined>()
const [tonMounted, setTonMounted] = useState(false)
const [{ bali, meet, referId }] = useState(() => {
const start = getStartParam()
const bali = start?.startsWith('bali_') ? start.slice(5) : null
Expand All @@ -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')
Expand All @@ -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()
Expand Down Expand Up @@ -221,7 +244,13 @@ export function App() {
</header>

<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowY: 'auto', paddingBottom: inHub ? 70 : 0 }}>
{body}
{needsTon || tonMounted
? (
<Suspense fallback={<div className="px-label" style={{ padding: 24, color: 'var(--cw-text-dim)' }}>LOADING WALLET…</div>}>
<TonConnectGate>{body}</TonConnectGate>
</Suspense>
)
: body}
</main>

{inHub && (
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/components/HeroTab.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/components/PassScan.tsx
Original file line number Diff line number Diff line change
@@ -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' }
Expand Down
15 changes: 15 additions & 0 deletions src/frontend/src/components/TonConnectGate.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TonConnectUIProvider manifestUrl={`${window.location.origin}/tonconnect-manifest.json`}>
{children}
</TonConnectUIProvider>
)
}
5 changes: 1 addition & 4 deletions src/frontend/src/components/WalletScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { shortAddress } from '../format'
import { useWalletBind } from '../hooks/useWalletBind'
import { haptic } from '../telegram'

Expand All @@ -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.',
Expand Down
6 changes: 6 additions & 0 deletions src/frontend/src/format.ts
Original file line number Diff line number Diff line change
@@ -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)}`
}
10 changes: 4 additions & 6 deletions src/frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StrictMode>
<TonConnectUIProvider manifestUrl={manifestUrl}>
<App />
</TonConnectUIProvider>
<App />
</StrictMode>,
)