diff --git a/frontend/next.config.ts b/frontend/next.config.ts index a5c09e5..d7858d3 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -8,6 +8,12 @@ const nextConfig: NextConfig = { turbopack: { root: path.resolve(__dirname), }, + // @stacks/connect is imported statically (so the wallet path ships in the + // initial client bundle and can't lose a lazy chunk across deploys), but + // Turbopack's server bundle can't wire its module graph during prerender + // ("module factory is not available"). Keep it external on the server — + // Node resolves it natively there. + serverExternalPackages: ["@stacks/connect"], }; export default nextConfig; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 9a74d28..8069c0b 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import { GeistSans } from "geist/font/sans"; import { GeistMono } from "geist/font/mono"; import { QueryProvider } from "@/providers/query-provider"; import { StacksProvider } from "@/providers/stacks-provider"; +import { StaleDeployGuard } from "@/components/stale-deploy-guard"; import { Toaster } from "sonner"; import "./globals.css"; @@ -28,6 +29,7 @@ export default function RootLayout({ className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-surface-0 text-zinc-100`} > + {children} { + const onError = (event: ErrorEvent) => { + if (isStaleChunk(event.error ?? event.message)) { + event.preventDefault(); + reloadOnceForNewDeploy(); + } + }; + const onRejection = (event: PromiseRejectionEvent) => { + if (isStaleChunk(event.reason)) { + event.preventDefault(); + reloadOnceForNewDeploy(); + } + }; + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); + return () => { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); + }; + }, []); + + return null; +} diff --git a/frontend/src/hooks/use-stacks-tx.ts b/frontend/src/hooks/use-stacks-tx.ts index c66adc3..142622a 100644 --- a/frontend/src/hooks/use-stacks-tx.ts +++ b/frontend/src/hooks/use-stacks-tx.ts @@ -4,8 +4,16 @@ import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { PostConditionMode } from "@stacks/transactions"; +// Static import on purpose: keeps the signing path out of lazy chunks that +// deploys invalidate under open tabs (see stacks-provider.tsx). +import { request } from "@stacks/connect"; import { waitForTxConfirmation, clarityErrorMessage } from "@/lib/stacks"; -import { isUserCancel, isNoResponse, walletErrorDetail } from "@/lib/wallet-errors"; +import { + isUserCancel, + isNoResponse, + isStaleChunk, + walletErrorDetail, +} from "@/lib/wallet-errors"; type TxStatus = "idle" | "pending" | "confirming" | "success" | "error"; @@ -49,8 +57,6 @@ export function useStacksTx() { // JSON-RPC bridge. The legacy openContractCall/StacksProvider path is // deprecated by Leather and silently hangs (no popup, dangling // promise) on current wallet versions. - const { request } = await import("@stacks/connect"); - const callParams = { contract: `${options.contractAddress}.${options.contractName}` as `${string}.${string}`, @@ -142,6 +148,13 @@ export function useStacksTx() { setStatus("idle"); return null; } + if (isStaleChunk(err)) { + // A deploy replaced the bundle under this open tab — reload onto + // the new build instead of surfacing module internals. + toast.info("StackStream was updated — refreshing…"); + setTimeout(() => window.location.reload(), 800); + return null; + } setError(walletErrorDetail(err)); setStatus("error"); return null; diff --git a/frontend/src/lib/wallet-errors.ts b/frontend/src/lib/wallet-errors.ts index 4038792..310f030 100644 --- a/frontend/src/lib/wallet-errors.ts +++ b/frontend/src/lib/wallet-errors.ts @@ -45,3 +45,14 @@ export function walletErrorDetail(err: unknown): string { const msg = e.message?.slice(0, 140) ?? "Unknown error"; return e.code !== undefined ? `${msg} (code ${e.code})` : msg; } + +/** + * A lazily-loaded chunk vanished because a new deploy replaced the hashed + * bundle files while this tab was still running the old build. The only + * correct recovery is a one-time page reload onto the new build. + */ +export function isStaleChunk(err: unknown): boolean { + return /module factory|ChunkLoadError|Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed/i.test( + asErrorLike(err).message ?? "" + ); +} diff --git a/frontend/src/providers/stacks-provider.tsx b/frontend/src/providers/stacks-provider.tsx index 3920bba..c4dc0e4 100644 --- a/frontend/src/providers/stacks-provider.tsx +++ b/frontend/src/providers/stacks-provider.tsx @@ -9,6 +9,11 @@ * `StacksProvider` object, which left the previous `showConnect`/ * `openContractCall` calls hanging with no popup. * + * The library is imported STATICALLY on purpose: it ships in the initial + * bundle (~10 KB gz), so the wallet path can never lose its lazy chunk when + * a deploy replaces the hashed bundle files under an open tab ("module + * factory is not available"). + * * Connect is deliberately stateless-first: we clear any cached approval and * force the wallet chooser on every connect, so switching accounts in the * wallet (e.g. sender -> recipient) always takes effect. Cached data made @@ -18,12 +23,19 @@ */ import { type ReactNode, useEffect, useCallback } from "react"; +import { + connect, + disconnect as walletDisconnect, + isConnected, + getLocalStorage, +} from "@stacks/connect"; import { toast } from "sonner"; import { useWalletStore } from "@/stores/wallet-store"; import { isUserCancel, isNoResponse, isNoWalletFound, + isStaleChunk, walletErrorDetail, } from "@/lib/wallet-errors"; @@ -35,37 +47,34 @@ export function StacksProvider({ children }: { children: ReactNode }) { // Rehydrate connection on mount from @stacks/connect localStorage useEffect(() => { - (async () => { + try { + // Purge pre-v8 leftovers. Returning visitors carry the legacy + // UserSession blob, and our persisted store may claim "connected" + // from that era — without a matching v8 session the UI would lie + // and wallet actions would misbehave. try { - // Purge pre-v8 leftovers. Returning visitors carry the legacy - // UserSession blob, and our persisted store may claim "connected" - // from that era — without a matching v8 session the UI would lie - // and wallet actions would misbehave. - try { - localStorage.removeItem("blockstack-session"); - localStorage.removeItem("blockstack-gaia-hub-config"); - } catch { - /* storage unavailable — nothing to clean */ - } + localStorage.removeItem("blockstack-session"); + localStorage.removeItem("blockstack-gaia-hub-config"); + } catch { + /* storage unavailable — nothing to clean */ + } - const { isConnected, getLocalStorage } = await import("@stacks/connect"); - if (isConnected()) { - const stx = getLocalStorage()?.addresses.stx.find((a) => - STX_ADDRESS_RE.test(a.address) - ); - if (stx) { - setAddress(stx.address); - return; - } + if (isConnected()) { + const stx = getLocalStorage()?.addresses.stx.find((a) => + STX_ADDRESS_RE.test(a.address) + ); + if (stx) { + setAddress(stx.address); + return; } - // No live v8 session: make the UI agree (clears any stale - // persisted "connected" state from before the migration). - disconnect(); - } catch { - // Stale or incompatible connect data — start fresh - disconnect(); } - })(); + // No live v8 session: make the UI agree (clears any stale + // persisted "connected" state from before the migration). + disconnect(); + } catch { + // Stale or incompatible connect data — start fresh + disconnect(); + } }, [setAddress, disconnect]); return <>{children}; @@ -78,10 +87,6 @@ export function useStacksAuth() { const handleConnect = useCallback(async () => { setConnecting(true); try { - const { connect, disconnect: walletDisconnect } = await import( - "@stacks/connect" - ); - // Drop any cached approval/addresses so the wallet is always asked // fresh — otherwise a reconnect can silently return the previously // approved account even after the user switched accounts in the wallet. @@ -142,6 +147,13 @@ export function useStacksAuth() { ); } catch (err) { console.error("[wallet connect]", err); + if (isStaleChunk(err)) { + // A deploy replaced the bundle under this open tab — reload onto + // the new build instead of surfacing module internals. + toast.info("StackStream was updated — refreshing…"); + setTimeout(() => window.location.reload(), 800); + return; + } toast.error(`Wallet connection failed: ${walletErrorDetail(err)}`, { duration: 8000, }); @@ -150,9 +162,11 @@ export function useStacksAuth() { }, [setAddress, setConnecting]); const handleDisconnect = useCallback(() => { - import("@stacks/connect").then(({ disconnect: walletDisconnect }) => { + try { walletDisconnect(); - }); + } catch { + /* nothing cached */ + } disconnect(); }, [disconnect]);