Skip to content
Draft
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
122 changes: 71 additions & 51 deletions src/app/api/snapshot/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
}
}
71 changes: 68 additions & 3 deletions src/components/features/ThemeSwitcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -62,6 +75,58 @@ export function ThemeSwitcher({
/>,
document.body
)}

{/* Dev Debug UI - Positioned for user visibility */}
{createPortal(
<div className="fixed bottom-24 right-4 z-[10001] flex flex-col items-end gap-2">
{showDebug && (
<div className="bg-white/95 dark:bg-black/95 p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 shadow-2xl backdrop-blur-md flex flex-col gap-3 text-sm font-sans min-w-[200px]">
<div className="font-bold border-b border-neutral-100 dark:border-neutral-900 pb-2 mb-1">
Theme Debug Controls
</div>
<label className="flex items-center justify-between gap-4 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-900 p-1 rounded transition-colors">
<span>Disable Puppeteer</span>
<input
type="checkbox"
className="w-4 h-4 accent-yellow-400"
checked={disabledMethods.puppeteer}
onChange={(e) => setDisabledMethods(prev => ({ ...prev, puppeteer: e.target.checked }))}
/>
</label>
<label className="flex items-center justify-between gap-4 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-900 p-1 rounded transition-colors">
<span>Disable Modern-screenshot</span>
<input
type="checkbox"
className="w-4 h-4 accent-yellow-400"
checked={disabledMethods.modernScreenshot}
onChange={(e) => setDisabledMethods(prev => ({ ...prev, modernScreenshot: e.target.checked }))}
/>
</label>
</div>
)}
<button
onClick={() => setShowDebug(!showDebug)}
className="p-3 bg-yellow-400 dark:bg-yellow-500 text-black rounded-full shadow-lg hover:scale-110 active:scale-95 transition-all duration-200 flex items-center justify-center"
title="Theme Debug Settings"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform duration-200 ${showDebug ? "rotate-45" : ""}`}
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>,
document.body
)}
</>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export function WipeAnimationOverlay({
translate: wipeDirection === "top-down" ? "0 -100%" : "0 0",
}}
/>

{/* Dev Debug Text */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-yellow-400 text-black text-[10px] font-bold uppercase tracking-widest rounded-full shadow-lg pointer-events-none">
Method: {snapshots.method}
</div>
</div>
)}
</AnimatePresence>
Expand Down
82 changes: 49 additions & 33 deletions src/hooks/useThemeWipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ type UseThemeWipeProps = {
setWipeDirection: Dispatch<SetStateAction<WipeDirection | null>>;
};

export type SnapshotMethod = "puppeteer" | "modern-screenshot" | "instant";

export type Snapshots = {
a: string; // Original theme
b: string; // Target theme
method: SnapshotMethod;
};

export function useThemeWipe({
Expand Down Expand Up @@ -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<any>, ms: number, errorMsg: string) => {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(errorMsg)), ms))
]);
};

const fetchSnapshotsFromApi = async (themes: (Theme | undefined)[]): Promise<string[]> => {
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<Snapshots> => {
const captureWithModernScreenshot = async (): Promise<{ a: string; b: string }> => {
const vh = window.innerHeight;
const scrollY = window.scrollY;
const options = {
Expand All @@ -129,58 +140,63 @@ 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)
const b = await domToPng(document.documentElement, options);
return { a, b };
};

const withTimeout = (promise: Promise<any>, 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);
Expand Down
2 changes: 1 addition & 1 deletion src/utils/puppeteer-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class PuppeteerManager {
}

public async getBrowser(): Promise<Browser> {
if (this.browser && this.browser.connected) {
if (this.browser && this.browser.isConnected()) {
return this.browser;
}

Expand Down