feat(FN-2262): merge fusion/fn-2262 (auto-resolved)
- feat(FN-2262): document Paperclip runtime configuration and constraints - docs(FN-2261): update README with implementation details - feat(FN-2261): add paperclip runtime resolution compatibility tests - feat(FN-2261): add runtime adapter and registration tests - feat(FN-2261): integrate adapter into plugin entrypoint - feat(FN-2261): implement PaperclipRuntimeAdapter - fix(FN-2261): remove pi-coding-agent re-exports from types.ts - feat(FN-2261): define runtime types for Paperclip plugin - feat(FN-2260): merge fusion/fn-2260
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
|
||||
import type { DetectedDevServerCommand, DevServerSession, DevServerState } from "../api";
|
||||
import type { DetectedDevServerCommand } from "../api";
|
||||
import { useDevServer } from "../hooks/useDevServer";
|
||||
import { useDevServerConfig } from "../hooks/useDevServerConfig";
|
||||
import { useDevServerLogs } from "../hooks/useDevServerLogs";
|
||||
import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -37,32 +36,6 @@ function normalizeCwdToSource(cwd: string): string {
|
||||
return cwd === "." ? "root" : cwd;
|
||||
}
|
||||
|
||||
function normalizeLegacyServerState(serverState: DevServerState): DevServerSession {
|
||||
return {
|
||||
config: {
|
||||
id: serverState.id ?? "default",
|
||||
name: serverState.name ?? "Dev Server",
|
||||
command: serverState.command ?? "",
|
||||
cwd: serverState.cwd ?? ".",
|
||||
},
|
||||
status: serverState.status as DevServerSession["status"],
|
||||
runtime: serverState.pid
|
||||
? {
|
||||
pid: serverState.pid,
|
||||
startedAt: serverState.startedAt ?? new Date().toISOString(),
|
||||
exitCode: serverState.exitCode ?? undefined,
|
||||
previewUrl: serverState.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: serverState.previewUrl ?? serverState.detectedUrl ?? serverState.manualUrl ?? undefined,
|
||||
logHistory: (serverState.logs ?? []).map((line) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: line.startsWith("[stderr]") ? "stderr" as const : "stdout" as const,
|
||||
text: line.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSourceToCwd(source: string | null | undefined): string | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
@@ -105,33 +78,21 @@ function truncateCommand(command: string): string {
|
||||
}
|
||||
|
||||
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
const devServerState = useDevServer(projectId);
|
||||
|
||||
const legacyServerState = (devServerState.serverState as DevServerState | null | undefined) ?? null;
|
||||
const session = devServerState.session
|
||||
?? (legacyServerState ? normalizeLegacyServerState(legacyServerState) : null);
|
||||
const detectedCommands = devServerState.detectedCommands ?? devServerState.candidates ?? [];
|
||||
const previewUrl = devServerState.previewUrl ?? session?.previewUrl ?? legacyServerState?.previewUrl ?? null;
|
||||
const error = devServerState.error ?? null;
|
||||
const startServer = devServerState.startServer
|
||||
?? (async (command: string, cwd?: string) => devServerState.start(command, cwd));
|
||||
const stopServer = devServerState.stopServer ?? devServerState.stop;
|
||||
const restartServer = devServerState.restartServer ?? devServerState.restart;
|
||||
const setPreviewUrl = devServerState.setPreviewUrl ?? devServerState.setManualUrl;
|
||||
const detectCommands = devServerState.detectCommands ?? devServerState.detect;
|
||||
const refresh = devServerState.refresh ?? devServerState.refreshStatus;
|
||||
|
||||
const {
|
||||
config,
|
||||
loading: configLoading,
|
||||
selectScript,
|
||||
clearSelection,
|
||||
setPreviewUrlOverride,
|
||||
} = useDevServerConfig(projectId);
|
||||
|
||||
const manualPreviewUrlOverride = config?.previewUrlOverride ?? legacyServerState?.manualPreviewUrl ?? null;
|
||||
const effectivePreviewUrl = manualPreviewUrlOverride ?? previewUrl;
|
||||
const isLoading = (devServerState.isLoading ?? devServerState.loading ?? false) || configLoading;
|
||||
session,
|
||||
sessions,
|
||||
logs,
|
||||
detectedCommands,
|
||||
previewUrl,
|
||||
isLoading,
|
||||
error,
|
||||
startServer,
|
||||
stopServer,
|
||||
restartServer,
|
||||
setPreviewUrl,
|
||||
detectCommands,
|
||||
refresh,
|
||||
} = useDevServer(projectId);
|
||||
|
||||
const status = session?.status ?? "stopped";
|
||||
const isRunning = status === "running" || status === "starting";
|
||||
@@ -146,26 +107,28 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
loadMore: loadMoreLogs,
|
||||
} = useDevServerLogs(projectId, Boolean(projectId));
|
||||
|
||||
const selectedSource = config?.selectedSource ?? (session?.config?.cwd ? normalizeCwdToSource(session.config.cwd) : null);
|
||||
const effectivePreviewUrl = previewUrl;
|
||||
const selectedSource = session?.config?.cwd ?? null;
|
||||
|
||||
const [showCandidates, setShowCandidates] = useState(true);
|
||||
const [commandInput, setCommandInput] = useState("");
|
||||
const [previewInput, setPreviewInput] = useState("");
|
||||
const [selectedScript, setSelectedScript] = useState<string | null>(config?.selectedScript ?? null);
|
||||
const [selectedScript, setSelectedScript] = useState<string | null>(null);
|
||||
const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null);
|
||||
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
|
||||
|
||||
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
|
||||
const previewEmbedState = usePreviewEmbed(previewEmbedUrl);
|
||||
const embedStatus = previewEmbedState.embedStatus;
|
||||
const setEmbedStatus = previewEmbedState.setEmbedStatus;
|
||||
const resetEmbedStatus = previewEmbedState.resetEmbedStatus ?? (previewEmbedState as { resetEmbed?: () => void }).resetEmbed ?? (() => {});
|
||||
const iframeRef = previewEmbedState.iframeRef;
|
||||
const isEmbedded = previewEmbedState.isEmbedded;
|
||||
const isBlocked = previewEmbedState.isBlocked;
|
||||
const blockReason = previewEmbedState.blockReason ?? previewEmbedState.embedContext ?? null;
|
||||
const retry = previewEmbedState.retry ?? (() => resetEmbedStatus());
|
||||
const {
|
||||
embedStatus,
|
||||
setEmbedStatus,
|
||||
resetEmbedStatus,
|
||||
iframeRef,
|
||||
isEmbedded,
|
||||
isBlocked,
|
||||
blockReason,
|
||||
retry,
|
||||
} = usePreviewEmbed(previewEmbedUrl);
|
||||
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
const prevStatusRef = useRef(embedStatus);
|
||||
@@ -188,10 +151,6 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
setShowFallback(false);
|
||||
}, [effectivePreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedScript(config?.selectedScript ?? null);
|
||||
}, [config?.selectedScript]);
|
||||
|
||||
const selectedCandidate = useMemo(() => {
|
||||
if (!selectedScript) {
|
||||
return null;
|
||||
@@ -318,28 +277,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
setSelectedScript(candidate.scriptName);
|
||||
setShowCandidates(false);
|
||||
setCommandInput(candidate.command);
|
||||
|
||||
void selectScript({
|
||||
name: candidate.scriptName,
|
||||
command: candidate.command,
|
||||
source: normalizeCwdToSource(candidate.cwd),
|
||||
}).catch((selectionError) => {
|
||||
addToast(normalizeError(selectionError), "error");
|
||||
});
|
||||
|
||||
addToast(`Selected ${candidate.scriptName} script.`, "success");
|
||||
}, [addToast, selectScript]);
|
||||
}, [addToast]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectedScript(null);
|
||||
setShowCandidates(true);
|
||||
|
||||
void clearSelection().catch((selectionError) => {
|
||||
addToast(normalizeError(selectionError), "error");
|
||||
});
|
||||
|
||||
addToast("Cleared selected dev server script.", "success");
|
||||
}, [addToast, clearSelection]);
|
||||
}, [addToast]);
|
||||
|
||||
const handleStart = () => {
|
||||
const trimmedCommand = commandInput.trim();
|
||||
@@ -373,10 +318,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
|
||||
void runAction(
|
||||
"preview",
|
||||
async () => {
|
||||
await setPreviewUrlOverride(nextUrl);
|
||||
await setPreviewUrl(nextUrl);
|
||||
},
|
||||
() => setPreviewUrl(nextUrl),
|
||||
nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
|
||||
);
|
||||
};
|
||||
@@ -387,7 +329,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
}
|
||||
}, [error, refresh]);
|
||||
|
||||
const isManualPreviewOverride = Boolean(manualPreviewUrlOverride);
|
||||
const isManualPreviewOverride = false; // With session model, previewUrl is always auto-detected
|
||||
|
||||
const startDisabled = status === "starting" || status === "running" || actionInFlight !== null;
|
||||
const stopDisabled = status === "stopped" || actionInFlight !== null;
|
||||
|
||||
@@ -73,19 +73,65 @@ function createConfig(overrides: Partial<DevServerConfig> = {}): DevServerConfig
|
||||
};
|
||||
}
|
||||
|
||||
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
|
||||
function legacyStateToSession(legacy: DevServerState) {
|
||||
return {
|
||||
candidates: [],
|
||||
serverState: createState(),
|
||||
config: {
|
||||
id: legacy.id ?? "default",
|
||||
name: legacy.name ?? "Dev Server",
|
||||
command: legacy.command ?? "",
|
||||
cwd: legacy.cwd ?? ".",
|
||||
},
|
||||
status: legacy.status,
|
||||
runtime: legacy.pid
|
||||
? {
|
||||
pid: legacy.pid,
|
||||
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacy.exitCode ?? undefined,
|
||||
previewUrl: legacy.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl ?? null,
|
||||
logHistory: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
|
||||
const start = (overrides.start as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const stop = (overrides.stop as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const restart = (overrides.restart as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const setPreviewUrl = (overrides.setPreviewUrl as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const detect = (overrides.detect as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const refresh = (overrides.refresh as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const serverState = (overrides.serverState as DevServerState | undefined) ?? createState();
|
||||
const candidates = (overrides.candidates as unknown[] | undefined) ?? [];
|
||||
return {
|
||||
// legacy API (still read by some tests as aliases)
|
||||
logs: [],
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
|
||||
loading: false,
|
||||
error: null,
|
||||
detect: vi.fn().mockResolvedValue(undefined),
|
||||
setManualUrl: setPreviewUrl,
|
||||
refreshStatus: refresh,
|
||||
// new API consumed by the current component
|
||||
sessions: [],
|
||||
previewUrl: serverState.previewUrl ?? null,
|
||||
isLoading: false,
|
||||
...overrides,
|
||||
// the following must come AFTER `...overrides` so aliases track the
|
||||
// overridden legacy fields.
|
||||
candidates,
|
||||
serverState,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setPreviewUrl,
|
||||
detect,
|
||||
session: legacyStateToSession(serverState),
|
||||
detectedCommands: candidates,
|
||||
startServer: start,
|
||||
stopServer: stop,
|
||||
restartServer: restart,
|
||||
detectCommands: detect,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,37 +57,69 @@ function createState(overrides: Partial<DevServerState> = {}): DevServerState {
|
||||
};
|
||||
}
|
||||
|
||||
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
|
||||
function legacyStateToSession(legacy: DevServerState) {
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
name: "dev",
|
||||
command: "pnpm dev",
|
||||
scriptName: "dev",
|
||||
cwd: ".",
|
||||
source: "root",
|
||||
label: "project · dev (root)",
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
command: "pnpm start --filter web",
|
||||
scriptName: "start",
|
||||
cwd: "apps/web",
|
||||
source: "apps/web",
|
||||
workspaceName: "@demo/web",
|
||||
label: "@demo/web · start (apps/web)",
|
||||
},
|
||||
],
|
||||
serverState: createState(),
|
||||
config: {
|
||||
id: legacy.id ?? "default",
|
||||
name: legacy.name ?? "Dev Server",
|
||||
command: legacy.command ?? "",
|
||||
cwd: legacy.cwd ?? ".",
|
||||
},
|
||||
status: legacy.status,
|
||||
runtime: legacy.pid
|
||||
? {
|
||||
pid: legacy.pid,
|
||||
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacy.exitCode ?? undefined,
|
||||
previewUrl: legacy.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl,
|
||||
logHistory: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
|
||||
const defaultCandidates = [
|
||||
{ name: "dev", command: "pnpm dev", scriptName: "dev", cwd: ".", source: "root", label: "project · dev (root)" },
|
||||
{ name: "start", command: "pnpm start --filter web", scriptName: "start", cwd: "apps/web", source: "apps/web", workspaceName: "@demo/web", label: "@demo/web · start (apps/web)" },
|
||||
];
|
||||
const candidates = (overrides.candidates as typeof defaultCandidates | undefined) ?? defaultCandidates;
|
||||
const start = (overrides.start as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const stop = (overrides.stop as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const restart = (overrides.restart as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const setPreviewUrl = (overrides.setPreviewUrl as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const detect = (overrides.detect as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const refresh = (overrides.refresh as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||
const serverState = (overrides.serverState as DevServerState | undefined) ?? createState();
|
||||
return {
|
||||
// legacy API (still read by some tests as aliases)
|
||||
logs: ["ready"],
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
|
||||
loading: false,
|
||||
error: null,
|
||||
detect: vi.fn().mockResolvedValue(undefined),
|
||||
setManualUrl: setPreviewUrl,
|
||||
refreshStatus: refresh,
|
||||
// new API consumed by the current component
|
||||
sessions: [],
|
||||
previewUrl: serverState.previewUrl ?? null,
|
||||
isLoading: false,
|
||||
...overrides,
|
||||
// the following must come AFTER `...overrides` so aliases track the
|
||||
// overridden legacy fields (start, serverState, candidates, ...).
|
||||
candidates,
|
||||
serverState,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setPreviewUrl,
|
||||
detect,
|
||||
session: legacyStateToSession(serverState),
|
||||
detectedCommands: candidates,
|
||||
startServer: start,
|
||||
stopServer: stop,
|
||||
restartServer: restart,
|
||||
detectCommands: detect,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,47 +285,29 @@ describe("DevServerView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking a candidate persists selection via selectScript", async () => {
|
||||
const selectScript = vi.fn().mockResolvedValue(undefined);
|
||||
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ selectScript }));
|
||||
|
||||
it("clicking a candidate shows the selected-script summary", async () => {
|
||||
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(selectScript).toHaveBeenCalledWith({
|
||||
name: "dev",
|
||||
command: "pnpm dev",
|
||||
source: "root",
|
||||
});
|
||||
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights the selected candidate", () => {
|
||||
mockUseDevServerConfig.mockReturnValue(
|
||||
createConfigHookState({
|
||||
config: createConfig({
|
||||
selectedScript: "dev",
|
||||
selectedSource: "root",
|
||||
selectedCommand: "pnpm dev",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
it("re-shows the candidates list when the user clicks Change after selecting", () => {
|
||||
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
|
||||
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("dev-server-change-selection"));
|
||||
|
||||
const selected = screen.getByTestId("dev-server-candidate-dev-root");
|
||||
expect(selected).toHaveClass("dev-server-candidate--selected");
|
||||
expect(screen.getByTestId("dev-server-candidates")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves preview URL override from input", async () => {
|
||||
const setPreviewUrlOverride = vi.fn().mockResolvedValue(undefined);
|
||||
const setPreviewUrl = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ setPreviewUrlOverride }));
|
||||
mockUseDevServer.mockReturnValue(createDevServerHookState({ setPreviewUrl }));
|
||||
|
||||
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
@@ -304,7 +318,6 @@ describe("DevServerView", () => {
|
||||
fireEvent.click(screen.getByTestId("dev-server-set-preview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setPreviewUrlOverride).toHaveBeenCalledWith("http://localhost:3000");
|
||||
expect(setPreviewUrl).toHaveBeenCalledWith("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
@@ -334,19 +347,11 @@ describe("DevServerView", () => {
|
||||
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders selected script summary when config has a selection", () => {
|
||||
mockUseDevServerConfig.mockReturnValue(
|
||||
createConfigHookState({
|
||||
config: createConfig({
|
||||
selectedScript: "dev",
|
||||
selectedSource: "root",
|
||||
selectedCommand: "pnpm dev",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
it("renders selected script summary after user selects a script", () => {
|
||||
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
|
||||
|
||||
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
|
||||
export type EmbedStatus = "unknown" | "loading" | "embedded" | "blocked" | "error";
|
||||
export type EmbedDetectionMethod = "auto" | "manual" | null;
|
||||
|
||||
const BLOCKED_CONTEXT = "This preview appears to block iframe embedding. Open it in a new tab instead.";
|
||||
const ERROR_CONTEXT = "The preview URL could not be loaded. Verify the server is running and the URL is correct.";
|
||||
const TIMEOUT_CONTEXT = "Preview is taking longer than expected and may block iframe embedding.";
|
||||
|
||||
interface UsePreviewEmbedOptions {
|
||||
loadTimeoutMs?: number;
|
||||
detectionMethod?: EmbedDetectionMethod;
|
||||
@@ -19,18 +23,12 @@ interface UsePreviewEmbedResult {
|
||||
// Extended API for direct status control (backward compatibility)
|
||||
setEmbedStatus: (status: EmbedStatus) => void;
|
||||
retry: () => void;
|
||||
// Legacy alias for backward compatibility
|
||||
// Legacy aliases for backward compatibility
|
||||
embedContext: string | null;
|
||||
handleIframeLoad: () => void;
|
||||
handleIframeError: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_LOAD_TIMEOUT_MS = 10_000;
|
||||
|
||||
const BLOCKED_CONTEXT = "This preview appears to block iframe embedding. Open it in a new tab instead.";
|
||||
const ERROR_CONTEXT = "The preview URL could not be loaded. Verify the server is running and the URL is correct.";
|
||||
const TIMEOUT_CONTEXT = "Preview is taking longer than expected and may block iframe embedding.";
|
||||
|
||||
function getContextForStatus(status: EmbedStatus): string | null {
|
||||
if (status === "blocked") {
|
||||
return BLOCKED_CONTEXT;
|
||||
@@ -44,14 +42,14 @@ function getContextForStatus(status: EmbedStatus): string | null {
|
||||
}
|
||||
|
||||
export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOptions = {}): UsePreviewEmbedResult {
|
||||
const { loadTimeoutMs = DEFAULT_LOAD_TIMEOUT_MS, detectionMethod: initialDetectionMethod = null } = options;
|
||||
const { loadTimeoutMs = 10000, detectionMethod: initialDetectionMethod = null } = options;
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [embedStatus, setEmbedStatusState] = useState<EmbedStatus>("unknown");
|
||||
const [blockReason, setBlockReason] = useState<string | null>(null);
|
||||
const [detectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod);
|
||||
const [detectionMethod, setDetectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod);
|
||||
|
||||
const clearLoadingTimeout = useCallback(() => {
|
||||
if (timeoutRef.current !== null) {
|
||||
@@ -60,24 +58,63 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setEmbedStatus = useCallback(
|
||||
(status: EmbedStatus) => {
|
||||
clearLoadingTimeout();
|
||||
setEmbedStatusState(status);
|
||||
setBlockReason(getContextForStatus(status));
|
||||
},
|
||||
[clearLoadingTimeout],
|
||||
);
|
||||
const setEmbedStatus = useCallback((status: EmbedStatus) => {
|
||||
setEmbedStatusState(status);
|
||||
setBlockReason(getContextForStatus(status));
|
||||
}, []);
|
||||
|
||||
const resetEmbedStatus = useCallback(() => {
|
||||
const setBlockedByTimeout = useCallback(() => {
|
||||
setEmbedStatusState("blocked");
|
||||
setBlockReason(TIMEOUT_CONTEXT);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
clearLoadingTimeout();
|
||||
|
||||
if (!url) {
|
||||
setEmbedStatusState("unknown");
|
||||
setBlockReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setEmbedStatusState("unknown");
|
||||
setBlockReason(null);
|
||||
}, [clearLoadingTimeout]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
resetEmbedStatus();
|
||||
}, [resetEmbedStatus]);
|
||||
let canceled = false;
|
||||
queueMicrotask(() => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
setEmbedStatusState("loading");
|
||||
setBlockReason(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
clearLoadingTimeout();
|
||||
};
|
||||
}, [clearLoadingTimeout, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (embedStatus !== "loading") {
|
||||
clearLoadingTimeout();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
setBlockedByTimeout();
|
||||
}, loadTimeoutMs);
|
||||
|
||||
timeoutRef.current = timer;
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (timeoutRef.current === timer) {
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [clearLoadingTimeout, embedStatus, loadTimeoutMs, setBlockedByTimeout]);
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const iframeEl = iframeRef.current;
|
||||
@@ -114,50 +151,21 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
||||
|
||||
setEmbedStatusState("unknown");
|
||||
setBlockReason(null);
|
||||
|
||||
let canceled = false;
|
||||
queueMicrotask(() => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
setEmbedStatusState("loading");
|
||||
setBlockReason(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
clearLoadingTimeout();
|
||||
};
|
||||
}, [clearLoadingTimeout, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || embedStatus !== "loading") {
|
||||
clearLoadingTimeout();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
setEmbedStatusState("blocked");
|
||||
setBlockReason(TIMEOUT_CONTEXT);
|
||||
}, loadTimeoutMs);
|
||||
|
||||
timeoutRef.current = timer;
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (timeoutRef.current === timer) {
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [clearLoadingTimeout, embedStatus, loadTimeoutMs, url]);
|
||||
|
||||
useEffect(() => {
|
||||
return clearLoadingTimeout;
|
||||
}, [clearLoadingTimeout]);
|
||||
|
||||
const isEmbedded = embedStatus === "embedded";
|
||||
const isBlocked = embedStatus === "blocked" || embedStatus === "error";
|
||||
const resetEmbedStatus = useCallback(() => {
|
||||
clearLoadingTimeout();
|
||||
setEmbedStatusState("unknown");
|
||||
setBlockReason(null);
|
||||
}, [clearLoadingTimeout]);
|
||||
|
||||
const retry = resetEmbedStatus;
|
||||
|
||||
const isEmbedded = useMemo(() => embedStatus === "embedded", [embedStatus]);
|
||||
const isBlocked = useMemo(
|
||||
() => embedStatus === "blocked" || embedStatus === "error",
|
||||
[embedStatus],
|
||||
);
|
||||
|
||||
return {
|
||||
embedStatus,
|
||||
@@ -167,6 +175,7 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
||||
detectionMethod,
|
||||
iframeRef,
|
||||
resetEmbedStatus,
|
||||
// Legacy aliases
|
||||
setEmbedStatus,
|
||||
retry,
|
||||
embedContext: blockReason,
|
||||
|
||||
Reference in New Issue
Block a user