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
6 changes: 6 additions & 0 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -28,6 +29,7 @@ export default function RootLayout({
className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-surface-0 text-zinc-100`}
>
<QueryProvider>
<StaleDeployGuard />
<StacksProvider>
{children}
<Toaster
Expand Down
55 changes: 55 additions & 0 deletions frontend/src/components/stale-deploy-guard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

/**
* Self-healing for tabs left open across deploys.
*
* Every deploy replaces the content-hashed chunk files, so a tab still
* running the previous build fails as soon as it lazily loads anything
* ("module factory is not available", ChunkLoadError, ...). The only correct
* recovery is reloading onto the new build — this guard does it once,
* automatically, instead of showing users module gibberish.
*/

import { useEffect } from "react";
import { isStaleChunk } from "@/lib/wallet-errors";

const RELOADED_AT_KEY = "stale-deploy-reloaded-at";
const RELOAD_LOOP_WINDOW_MS = 30_000;

function reloadOnceForNewDeploy() {
try {
const last = Number(sessionStorage.getItem(RELOADED_AT_KEY) ?? 0);
// If we already reloaded very recently, don't loop — the failure is
// something else (offline, blocked CDN) and reloading won't help.
if (Date.now() - last < RELOAD_LOOP_WINDOW_MS) return;
sessionStorage.setItem(RELOADED_AT_KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable — still better to reload than stay broken */
}
window.location.reload();
}

export function StaleDeployGuard() {
useEffect(() => {
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;
}
19 changes: 16 additions & 3 deletions frontend/src/hooks/use-stacks-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/lib/wallet-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ""
);
}
80 changes: 47 additions & 33 deletions frontend/src/providers/stacks-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";

Expand All @@ -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}</>;
Expand All @@ -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.
Expand Down Expand Up @@ -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,
});
Expand All @@ -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]);

Expand Down
Loading