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 && ( +
+
+ Theme Debug Controls +
+ + +
+ )} + +
, + 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; }