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
4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},
Expand Down Expand Up @@ -40,4 +40,4 @@
"tailwindcss": "^4",
"typescript": "^5"
}
}
}
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;
}
14 changes: 13 additions & 1 deletion frontend/src/hooks/use-stacks-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { PostConditionMode } from "@stacks/transactions";
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 @@ -142,6 +147,13 @@ export function useStacksTx() {
setStatus("idle");
return null;
}
if (isStaleChunk(err)) {
// A deploy replaced the bundle files under this open tab — reload
// onto the current 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 ?? ""
);
}
8 changes: 8 additions & 0 deletions frontend/src/providers/stacks-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
isUserCancel,
isNoResponse,
isNoWalletFound,
isStaleChunk,
walletErrorDetail,
} from "@/lib/wallet-errors";

Expand Down Expand Up @@ -142,6 +143,13 @@ export function useStacksAuth() {
);
} catch (err) {
console.error("[wallet connect]", err);
if (isStaleChunk(err)) {
// A deploy replaced the bundle files under this open tab — reload
// onto the current 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 Down
Loading