diff --git a/src/app/api/snapshot/route.ts b/src/app/api/snapshot/route.ts
index 74e1c63..8affdfc 100644
--- a/src/app/api/snapshot/route.ts
+++ b/src/app/api/snapshot/route.ts
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import puppeteer from "puppeteer-core";
+import { Browser } from "puppeteer-core";
import { puppeteerManager } from "@/utils/puppeteer-manager";
export const maxDuration = 60;
@@ -31,67 +31,87 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- let browser: any = null;
- let page: any = null;
+ let browser: Browser | null = null;
try {
- const { html, width, height, devicePixelRatio = 2 } = await req.json();
+ const body = await req.json();
- if (!html) {
- return NextResponse.json({ error: "HTML content is required" }, { status: 400 });
- }
-
- // Connect to the persistent browser instance
- const wsEndpoint = await puppeteerManager.getWsEndpoint();
- browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint });
-
- page = await browser.newPage();
-
- // Set viewport correctly for this specific snapshot
- const safeWidth = Math.min(Math.max(width || 1280, 100), 3840);
- const safeHeight = Math.min(Math.max(height || 720, 100), 2160);
- const safeScale = Math.min(Math.max(devicePixelRatio || 2, 1), 3);
-
- await page.setViewport({
- width: safeWidth,
- height: safeHeight,
- deviceScaleFactor: safeScale,
- });
-
- // Performance: Disable JS
- await page.setJavaScriptEnabled(false);
+ // Support both single task (backward compatibility) and multiple tasks
+ const isMulti = Array.isArray(body.tasks);
+ const tasks = isMulti ? body.tasks : [body];
- // Wait for full load
- await page.setContent(html, { waitUntil: "load" });
-
- // Tiny delay for layout/font rendering
- await new Promise(r => setTimeout(r, 100));
+ if (tasks.length === 0) {
+ return NextResponse.json({ error: "No tasks provided" }, { status: 400 });
+ }
- await page.evaluate(() => {
- const htmlEl = document.documentElement;
- const x = parseInt(htmlEl.getAttribute('data-scroll-x') || '0');
- const y = parseInt(htmlEl.getAttribute('data-scroll-y') || '0');
- window.scrollTo(x, y);
- });
+ if (tasks.some((t: any) => !t.html)) {
+ return NextResponse.json({ error: "HTML content is required for all tasks" }, { status: 400 });
+ }
- const buffer = await page.screenshot({
- type: "png",
- fullPage: false,
- });
+ // Get the shared persistent browser instance
+ browser = await puppeteerManager.getBrowser();
+
+ const results: string[] = [];
+
+ // Process tasks SEQUENTIALLY to stay within the 2-concurrency limit
+ for (const task of tasks) {
+ if (!browser || !browser.isConnected()) {
+ throw new Error("Browser connection lost");
+ }
+
+ const page = await browser.newPage();
+ try {
+ const { html, width, height, devicePixelRatio = 2 } = task;
+
+ // Set viewport correctly for this specific snapshot
+ const safeWidth = Math.min(Math.max(width || 1280, 100), 3840);
+ const safeHeight = Math.min(Math.max(height || 720, 100), 2160);
+ const safeScale = Math.min(Math.max(devicePixelRatio || 2, 1), 3);
+
+ await page.setViewport({
+ width: safeWidth,
+ height: safeHeight,
+ deviceScaleFactor: safeScale,
+ });
+
+ // Performance: Disable JS
+ await page.setJavaScriptEnabled(false);
+
+ // Wait for DOM to be ready
+ await page.setContent(html, { waitUntil: "load" });
+
+ // Tiny delay for layout/font rendering
+ await new Promise(r => setTimeout(r, 100));
+
+ await page.evaluate(() => {
+ const htmlEl = document.documentElement;
+ const x = parseInt(htmlEl.getAttribute('data-scroll-x') || '0');
+ const y = parseInt(htmlEl.getAttribute('data-scroll-y') || '0');
+ window.scrollTo(x, y);
+ });
+
+ const buffer = await page.screenshot({
+ type: "png",
+ fullPage: false,
+ });
+
+ results.push(`data:image/png;base64,${Buffer.from(buffer).toString("base64")}`);
+ } catch (err: any) {
+ console.error("Task failed:", err);
+ throw err;
+ } finally {
+ // Close EACH page immediately to free up connection/resources
+ await page.close().catch(() => {});
+ }
+ }
- const base64 = `data:image/png;base64,${buffer.toString("base64")}`;
- return NextResponse.json({ snapshot: base64 });
+ return NextResponse.json(isMulti ? { snapshots: results } : { snapshot: results[0] });
} catch (error: any) {
console.error("Snapshot API error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
} finally {
- if (page) {
- await page.close().catch(() => {});
- }
- if (browser) {
- // We disconnect from the persistent browser, NOT close it
- await browser.disconnect();
- }
+ // We do NOT disconnect from the shared singleton browser here.
+ // That's what was causing the "Target closed" and "Navigating frame was detached" errors.
}
}
diff --git a/src/components/features/ThemeSwitcher/index.tsx b/src/components/features/ThemeSwitcher/index.tsx
index ff287fd..8ffa228 100644
--- a/src/components/features/ThemeSwitcher/index.tsx
+++ b/src/components/features/ThemeSwitcher/index.tsx
@@ -33,10 +33,23 @@ export function ThemeSwitcher({
setWipeDirection,
});
+ const [showDebug, setShowDebug] = useState(false);
+ const [disabledMethods, setDisabledMethods] = useState({
+ puppeteer: false,
+ modernScreenshot: false,
+ });
+
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ (window as any).FORCE_FALLBACK = {
+ disablePuppeteer: disabledMethods.puppeteer,
+ disableModernScreenshot: disabledMethods.modernScreenshot,
+ };
+ }
+ }, [disabledMethods]);
+
useEffect(() => {
setMounted(true);
- // Warm up the snapshot API on mount
- fetch("/api/snapshot").catch(() => {});
}, []);
if (!mounted) {
@@ -62,6 +75,58 @@ export function ThemeSwitcher({
/>,
document.body
)}
+
+ {/* Dev Debug UI - Positioned for user visibility */}
+ {createPortal(
+
+ {showDebug && (
+
+ )}
+
+
,
+ document.body
+ )}
>
);
-}
\ No newline at end of file
+}
diff --git a/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx b/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx
index 25cf973..19dbab3 100644
--- a/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx
+++ b/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx
@@ -58,6 +58,11 @@ export function WipeAnimationOverlay({
translate: wipeDirection === "top-down" ? "0 -100%" : "0 0",
}}
/>
+
+ {/* Dev Debug Text */}
+
+ Method: {snapshots.method}
+
)}
diff --git a/src/hooks/useThemeWipe.ts b/src/hooks/useThemeWipe.ts
index 9fdb616..47bce08 100644
--- a/src/hooks/useThemeWipe.ts
+++ b/src/hooks/useThemeWipe.ts
@@ -14,9 +14,12 @@ type UseThemeWipeProps = {
setWipeDirection: Dispatch>;
};
+export type SnapshotMethod = "puppeteer" | "modern-screenshot" | "instant";
+
export type Snapshots = {
a: string; // Original theme
b: string; // Target theme
+ method: SnapshotMethod;
};
export function useThemeWipe({
@@ -88,23 +91,31 @@ export function useThemeWipe({
const direction: WipeDirection =
currentTheme === "dark" ? "bottom-up" : "top-down";
- const fetchSnapshot = async (themeOverride?: "light" | "dark") => {
- const html = getFullPageHTML(themeOverride);
+ const withTimeout = (promise: Promise, ms: number, errorMsg: string) => {
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => setTimeout(() => reject(new Error(errorMsg)), ms))
+ ]);
+ };
+
+ const fetchSnapshotsFromApi = async (themes: (Theme | undefined)[]): Promise => {
+ const tasks = themes.map(theme => ({
+ html: getFullPageHTML(theme),
+ width: window.innerWidth,
+ height: window.innerHeight,
+ devicePixelRatio: window.devicePixelRatio,
+ }));
+
const response = await fetch("/api/snapshot", {
method: "POST",
- body: JSON.stringify({
- html,
- width: window.innerWidth,
- height: window.innerHeight,
- devicePixelRatio: window.devicePixelRatio,
- }),
+ body: JSON.stringify({ tasks }),
});
const data = await response.json();
if (data.error) throw new Error(data.error);
- return data.snapshot;
+ return data.snapshots;
};
- const captureWithModernScreenshot = async (): Promise => {
+ const captureWithModernScreenshot = async (): Promise<{ a: string; b: string }> => {
const vh = window.innerHeight;
const scrollY = window.scrollY;
const options = {
@@ -129,12 +140,14 @@ export function useThemeWipe({
// 1. Snapshot A (current)
const a = await domToPng(document.documentElement, options);
- // Mask switch
- setSnapshots({ a, b: a });
+ // Mask switch (freeze the current view)
+ setSnapshots({ a, b: a, method: "modern-screenshot" });
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// 2. Switch theme
setTheme(newTheme);
+ // Wait for DOM to update and themes to apply
+ await new Promise(r => setTimeout(r, 100));
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// 3. Snapshot B (new)
@@ -142,45 +155,48 @@ export function useThemeWipe({
return { a, b };
};
- const withTimeout = (promise: Promise, ms: number, errorMsg: string) => {
- return Promise.race([
- promise,
- new Promise((_, reject) => setTimeout(() => reject(new Error(errorMsg)), ms))
- ]);
- };
+ const forceFallback = typeof window !== 'undefined' ? (window as any).FORCE_FALLBACK || {} : {};
try {
- // PHASE 1: Try Puppeteer (3s timeout)
- console.log("Attempting Puppeteer snapshot...");
- const [snapshotA, snapshotB] = await withTimeout(
- Promise.all([fetchSnapshot(), fetchSnapshot(newTheme)]),
- 3000,
+ // --- PHASE 1: PUPPETEER ---
+ if (forceFallback.disablePuppeteer) throw new Error("Puppeteer manually disabled");
+
+ console.log("Attempting Puppeteer snapshots...");
+ const results = await withTimeout(
+ fetchSnapshotsFromApi([undefined, newTheme]),
+ 10000,
"Puppeteer timeout"
- ) as [string, string];
+ );
- setSnapshots({ a: snapshotA, b: snapshotB });
+ if (!results || !Array.isArray(results) || results.length < 2) throw new Error("Invalid Puppeteer response");
+ const [snapshotA, snapshotB] = results;
+
+ setSnapshots({ a: snapshotA, b: snapshotB, method: "puppeteer" });
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
setTheme(newTheme);
setWipeDirection(direction);
} catch (e: any) {
- console.warn("Puppeteer failed or timed out, falling back to modern-screenshot:", e.message);
+ console.warn("Puppeteer failed, falling back to modern-screenshot:", e.message);
try {
- // PHASE 2: Try modern-screenshot (2s timeout)
- const snapshots = await withTimeout(
+ // --- PHASE 2: MODERN SCREENSHOT ---
+ if (forceFallback.disableModernScreenshot) throw new Error("modern-screenshot manually disabled");
+
+ console.log("Attempting modern-screenshot snapshots...");
+ const result = await withTimeout(
captureWithModernScreenshot(),
- 2000,
+ 7000,
"modern-screenshot timeout"
- ) as Snapshots;
+ );
- setSnapshots(snapshots);
+ setSnapshots({ a: result.a, b: result.b, method: "modern-screenshot" });
setWipeDirection(direction);
} catch (e2: any) {
- console.warn("modern-screenshot failed or timed out, changing theme instantly:", e2.message);
+ console.warn("modern-screenshot failed, changing theme instantly:", e2.message);
- // PHASE 3: Fallback instantly
+ // --- PHASE 3: INSTANT FALLBACK ---
setTheme(newTheme);
setSnapshots(null);
setScrollLock(false);
diff --git a/src/utils/puppeteer-manager.ts b/src/utils/puppeteer-manager.ts
index a9471d2..f6df728 100644
--- a/src/utils/puppeteer-manager.ts
+++ b/src/utils/puppeteer-manager.ts
@@ -71,7 +71,7 @@ class PuppeteerManager {
}
public async getBrowser(): Promise {
- if (this.browser && this.browser.connected) {
+ if (this.browser && this.browser.isConnected()) {
return this.browser;
}