feat(FN-2260): merge fusion/fn-2260

This commit is contained in:
Fusion
2026-04-22 13:47:00 -07:00
committed by gsxdsm
parent b2a82328fe
commit 866e7cdb70
32 changed files with 2419 additions and 423 deletions

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
import type { DevServerCandidate } from "../api";
import type { DetectedDevServerCommand, DevServerState, DevServerSession } from "../api";
import { useDevServer } from "../hooks/useDevServer";
import { useDevServerConfig } from "../hooks/useDevServerConfig";
import { useDevServerLogs } from "../hooks/useDevServerLogs";
@@ -21,10 +21,11 @@ interface StatusBadgeConfig {
label: string;
}
const STATUS_BADGE_CONFIG: Record<"stopped" | "starting" | "running" | "failed", StatusBadgeConfig> = {
const STATUS_BADGE_CONFIG: Record<"stopped" | "starting" | "running" | "failed" | "stopping", StatusBadgeConfig> = {
stopped: { className: "dev-server-status-badge--stopped", label: "Stopped" },
starting: { className: "dev-server-status-badge--starting", label: "Starting..." },
running: { className: "dev-server-status-badge--running", label: "Running" },
stopping: { className: "dev-server-status-badge--starting", label: "Stopping..." },
failed: { className: "dev-server-status-badge--failed", label: "Failed" },
};
@@ -36,6 +37,32 @@ 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;
@@ -43,7 +70,7 @@ function normalizeSourceToCwd(source: string | null | undefined): string | null
return source === "root" ? "." : source;
}
function candidateMatchesSelection(candidate: DevServerCandidate, selectedScript: string | null, selectedSource: string | null): boolean {
function candidateMatchesSelection(candidate: DetectedDevServerCommand, selectedScript: string | null, selectedSource: string | null): boolean {
if (!selectedScript) {
return false;
}
@@ -59,16 +86,13 @@ function candidateMatchesSelection(candidate: DevServerCandidate, selectedScript
return normalizeCwdToSource(candidate.cwd) === selectedSource;
}
function formatCandidateSource(candidate: DevServerCandidate): string {
if (candidate.source === "root") {
function formatCandidateSource(candidate: DetectedDevServerCommand): string {
// DetectedDevServerCommand doesn't have source/workspaceName, so use cwd-based approach
if (candidate.cwd === ".") {
return "root";
}
if (candidate.workspaceName) {
return `${candidate.workspaceName} · ${candidate.source}`;
}
return candidate.source;
return candidate.cwd;
}
function truncateCommand(command: string): string {
@@ -81,18 +105,7 @@ function truncateCommand(command: string): string {
}
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
const {
candidates,
serverState,
start,
stop,
restart,
setPreviewUrl,
loading,
error,
detect,
} = useDevServer(projectId);
const devServer = useDevServer(projectId);
const {
config,
loading: configLoading,
@@ -103,7 +116,71 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
refresh: refreshConfig,
} = useDevServerConfig(projectId);
const status = serverState?.status ?? "stopped";
const legacyServerState = (devServer.serverState as DevServerState | null | undefined) ?? null;
const session = devServer.session ?? (legacyServerState ? normalizeLegacyServerState(legacyServerState) : null);
const detectedCommands = devServer.detectedCommands ?? devServer.candidates ?? [];
const isLoading = devServer.isLoading ?? devServer.loading ?? configLoading;
const error = devServer.error ?? configError ?? null;
const startServer = useCallback(async (command: string, cwd?: string) => {
if (typeof devServer.startServer === "function") {
await devServer.startServer(command, cwd);
return;
}
if (typeof devServer.start === "function") {
await devServer.start({ command, cwd });
}
}, [devServer]);
const stopServer = useCallback(async () => {
if (typeof devServer.stopServer === "function") {
await devServer.stopServer();
return;
}
if (typeof devServer.stop === "function") {
await devServer.stop();
}
}, [devServer]);
const restartServer = useCallback(async () => {
if (typeof devServer.restartServer === "function") {
await devServer.restartServer();
return;
}
if (typeof devServer.restart === "function") {
await devServer.restart();
}
}, [devServer]);
const detectCommands = useCallback(async () => {
if (typeof devServer.detectCommands === "function") {
await devServer.detectCommands();
return;
}
if (typeof devServer.detect === "function") {
await devServer.detect();
}
}, [devServer]);
const refresh = useCallback(async () => {
if (typeof devServer.refresh === "function") {
await devServer.refresh();
return;
}
if (typeof devServer.refreshStatus === "function") {
await devServer.refreshStatus();
return;
}
await refreshConfig();
}, [devServer, refreshConfig]);
const status = session?.status ?? "stopped";
const isRunning = status === "running" || status === "starting";
const statusBadge = STATUS_BADGE_CONFIG[status] ?? STATUS_BADGE_CONFIG.stopped;
@@ -116,29 +193,28 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
loadMore: loadMoreLogs,
} = useDevServerLogs(projectId, Boolean(projectId));
const effectivePreviewUrl = config?.previewUrlOverride ?? serverState?.manualPreviewUrl ?? serverState?.previewUrl ?? null;
const detectedPreviewUrl = config?.detectedPreviewUrl ?? serverState?.previewUrl ?? null;
const selectedSource = config?.selectedSource ?? null;
const isManualPreviewOverride = Boolean(config?.previewUrlOverride ?? serverState?.manualPreviewUrl);
const manualPreviewUrl = config?.previewUrlOverride ?? legacyServerState?.manualPreviewUrl ?? null;
const effectivePreviewUrl = manualPreviewUrl ?? devServer.previewUrl ?? session?.previewUrl ?? null;
const selectedSource = config?.selectedSource ?? (session?.config?.cwd ? normalizeCwdToSource(session.config.cwd) : null);
const [showCandidates, setShowCandidates] = useState(true);
const [commandInput, setCommandInput] = useState("");
const [previewInput, setPreviewInput] = useState("");
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 {
embedStatus,
setEmbedStatus,
resetEmbedStatus,
iframeRef,
isEmbedded,
isBlocked,
embedContext,
retry,
} = usePreviewEmbed(previewEmbedUrl);
const previewEmbed = usePreviewEmbed(previewEmbedUrl);
const embedStatus = previewEmbed.embedStatus ?? "unknown";
const setEmbedStatus = previewEmbed.setEmbedStatus ?? (() => undefined);
const resetEmbedStatus = previewEmbed.resetEmbedStatus ?? (() => undefined);
const iframeRef = previewEmbed.iframeRef ?? useRef<HTMLIFrameElement | null>(null);
const isEmbedded = previewEmbed.isEmbedded ?? false;
const isBlocked = previewEmbed.isBlocked ?? false;
const blockReason = previewEmbed.blockReason ?? (previewEmbed as { embedContext?: string | null }).embedContext ?? null;
const retry = previewEmbed.retry ?? (() => undefined);
const [showFallback, setShowFallback] = useState(false);
const prevStatusRef = useRef(embedStatus);
@@ -162,14 +238,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
}, [effectivePreviewUrl]);
const selectedCandidate = useMemo(() => {
if (!config?.selectedScript) {
if (!selectedScript) {
return null;
}
const selectedCwd = normalizeSourceToCwd(config.selectedSource);
const selectedCwd = normalizeSourceToCwd(selectedSource);
return candidates.find((candidate) => {
if (candidate.scriptName !== config.selectedScript) {
return detectedCommands.find((candidate) => {
if (candidate.scriptName !== selectedScript) {
return false;
}
@@ -177,39 +253,45 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
return false;
}
if (config.selectedCommand && candidate.command !== config.selectedCommand) {
if (session?.config?.command && candidate.command !== session.config.command) {
return false;
}
return true;
})
?? candidates.find((candidate) => candidateMatchesSelection(candidate, config.selectedScript, config.selectedSource))
?? detectedCommands.find((candidate) => candidateMatchesSelection(candidate, selectedScript, selectedSource))
?? null;
}, [candidates, config?.selectedCommand, config?.selectedScript, config?.selectedSource]);
}, [detectedCommands, session?.config?.command, selectedScript, selectedSource]);
useEffect(() => {
if (typeof detect !== "function") {
if (typeof detectCommands !== "function") {
return;
}
void detect().catch((detectError) => {
void detectCommands().catch((detectError: unknown) => {
addToast(normalizeError(detectError), "error");
});
}, [addToast, detect]);
}, [addToast, detectCommands]);
useEffect(() => {
if (config?.selectedScript) {
if (config?.selectedScript !== undefined) {
setSelectedScript(config?.selectedScript ?? null);
}
}, [config?.selectedScript]);
useEffect(() => {
if (selectedScript) {
setShowCandidates(false);
return;
}
setShowCandidates(true);
}, [config?.selectedScript]);
}, [selectedScript]);
useEffect(() => {
if (serverState?.status === "running" || serverState?.status === "starting") {
if (serverState.command.trim().length > 0) {
setCommandInput(serverState.command);
if (session?.status === "running" || session?.status === "starting") {
if (session.config?.command?.trim().length > 0) {
setCommandInput(session.config.command);
}
return;
}
@@ -219,19 +301,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
return;
}
if (config?.selectedCommand) {
setCommandInput(config.selectedCommand);
return;
if (detectedCommands.length > 0) {
setCommandInput((current) => (current.trim().length > 0 ? current : detectedCommands[0]?.command ?? ""));
}
if (candidates.length > 0) {
setCommandInput((current) => (current.trim().length > 0 ? current : candidates[0]?.command ?? ""));
}
}, [candidates, config?.selectedCommand, selectedCandidate, serverState?.command, serverState?.status]);
}, [detectedCommands, selectedCandidate, session?.config?.command, session?.status]);
useEffect(() => {
setPreviewInput(config?.previewUrlOverride ?? serverState?.manualPreviewUrl ?? "");
}, [config?.previewUrlOverride, serverState?.manualPreviewUrl]);
setPreviewInput(effectivePreviewUrl ?? "");
}, [effectivePreviewUrl]);
const handleOpenInNewTab = useCallback(() => {
if (!effectivePreviewUrl) {
@@ -288,27 +365,40 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
}
}, [addToast]);
const handleSelectCandidate = useCallback((candidate: DevServerCandidate) => {
void selectScript({
name: candidate.scriptName,
command: candidate.command,
source: normalizeCwdToSource(candidate.cwd),
}).then(() => {
setShowCandidates(false);
setCommandInput(candidate.command);
addToast(`Selected ${candidate.scriptName} script.`, "success");
}).catch((selectionError) => {
addToast(normalizeError(selectionError), "error");
});
const handleSelectCandidate = useCallback((candidate: DetectedDevServerCommand) => {
void (async () => {
try {
if (typeof selectScript === "function") {
await selectScript({
name: candidate.scriptName,
command: candidate.command,
source: normalizeCwdToSource(candidate.cwd),
});
}
setSelectedScript(candidate.scriptName);
setShowCandidates(false);
setCommandInput(candidate.command);
addToast(`Selected ${candidate.scriptName} script.`, "success");
} catch (selectionError) {
addToast(normalizeError(selectionError), "error");
}
})();
}, [addToast, selectScript]);
const handleClearSelection = useCallback(() => {
void clearSelection().then(() => {
setShowCandidates(true);
addToast("Cleared selected dev server script.", "success");
}).catch((clearError) => {
addToast(normalizeError(clearError), "error");
});
void (async () => {
try {
if (typeof clearSelection === "function") {
await clearSelection();
}
setSelectedScript(null);
setShowCandidates(true);
addToast("Cleared selected dev server script.", "success");
} catch (clearError) {
addToast(normalizeError(clearError), "error");
}
})();
}, [addToast, clearSelection]);
const handleStart = () => {
@@ -318,28 +408,23 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
return;
}
const fallbackCwd = normalizeSourceToCwd(config?.selectedSource) ?? ".";
const scriptName = selectedCandidate?.scriptName ?? config?.selectedScript ?? "custom";
const fallbackCwd = normalizeSourceToCwd(selectedSource) ?? ".";
const scriptName = selectedCandidate?.scriptName ?? selectedScript ?? "custom";
const cwd = selectedCandidate?.cwd ?? fallbackCwd;
void runAction(
"start",
() => {
if (selectedCandidate && trimmedCommand === selectedCandidate.command) {
return start(selectedCandidate);
}
return start({ command: trimmedCommand, scriptName, cwd });
},
() => startServer(trimmedCommand, cwd),
"Dev server started.",
);
};
const handleStop = () => {
void runAction("stop", stop, "Dev server stopped.");
void runAction("stop", stopServer, "Dev server stopped.");
};
const handleRestart = () => {
void runAction("restart", restart, "Dev server restarted.");
void runAction("restart", restartServer, "Dev server restarted.");
};
const handleSetPreview = () => {
@@ -349,24 +434,28 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
void runAction(
"preview",
async () => {
await setPreviewUrlOverride(nextUrl);
await setPreviewUrl(nextUrl);
if (typeof setPreviewUrlOverride === "function") {
await setPreviewUrlOverride(nextUrl);
}
if (typeof devServer.setPreviewUrl === "function") {
await devServer.setPreviewUrl(nextUrl);
return;
}
if (typeof devServer.setManualUrl === "function") {
await devServer.setManualUrl(nextUrl);
}
},
nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
);
};
const handleRetry = useCallback(() => {
if (!configError && error) {
window.location.reload();
return;
if (error) {
void refresh();
}
}, [error, refresh]);
void refreshConfig();
}, [configError, error, refreshConfig]);
const isLoading = loading || configLoading;
const combinedError = configError ?? error;
const isManualPreviewOverride = Boolean(manualPreviewUrl);
const startDisabled = status === "starting" || status === "running" || actionInFlight !== null;
const stopDisabled = status === "stopped" || actionInFlight !== null;
@@ -425,16 +514,16 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
{isLoading && <span className="dev-server-muted">Loading...</span>}
</div>
{isLoading && !config && candidates.length === 0 && (
{isLoading && !session && detectedCommands.length === 0 && (
<div className="dev-server-loading-state" data-testid="dev-server-loading-state">
<Loader2 size={16} className="dev-server-spin" />
<span>Loading dev server configuration...</span>
</div>
)}
{combinedError && (
{error && (
<div className="dev-server-error-box" role="alert" data-testid="dev-server-error-box">
<p>{combinedError}</p>
<p>{error}</p>
<button type="button" className="btn btn-sm" onClick={handleRetry}>Retry</button>
</div>
)}
@@ -442,9 +531,9 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
<div className="dev-server-section">
<h3>Script Selection</h3>
{config?.selectedScript && (
{selectedScript && (
<div className="dev-server-selected" data-testid="dev-server-selected-summary">
<span className="dev-server-candidate-name">{config.selectedScript}</span>
<span className="dev-server-candidate-name">{selectedScript}</span>
<span className="dev-server-candidate-source">{selectedSource ?? "root"}</span>
<button
type="button"
@@ -465,16 +554,16 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
</div>
)}
{showCandidates && candidates.length === 0 && (
{showCandidates && detectedCommands.length === 0 && (
<p className="dev-server-empty-state" data-testid="dev-server-empty-candidates">
No dev server scripts detected. Check that your project has a <code>package.json</code> with a <code>dev</code>, <code>start</code>, or similar script.
</p>
)}
{showCandidates && candidates.length > 0 && (
{showCandidates && detectedCommands.length > 0 && (
<div className="dev-server-candidates" data-testid="dev-server-candidates">
{candidates.map((candidate) => {
const isSelected = candidateMatchesSelection(candidate, config?.selectedScript ?? null, selectedSource);
{detectedCommands.map((candidate) => {
const isSelected = candidateMatchesSelection(candidate, selectedScript, selectedSource);
return (
<button
type="button"
@@ -506,10 +595,10 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
/>
</div>
{(status === "running" || status === "starting") && serverState && (
{(status === "running" || status === "starting") && session && (
<div className="dev-server-current-command" data-testid="dev-server-current-command">
<span className="dev-server-label">Running command</span>
<code>{serverState.command}</code>
<code>{session.config?.command ?? commandInput}</code>
</div>
)}
@@ -535,8 +624,8 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
</button>
</div>
{detectedPreviewUrl && (
<p className="dev-server-preview-hint">Auto-detected: {detectedPreviewUrl}</p>
{effectivePreviewUrl && (
<p className="dev-server-preview-hint">Auto-detected: {effectivePreviewUrl}</p>
)}
</section>
@@ -641,7 +730,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
<p className="devserver-preview-blocked-title">
{embedStatus === "error" ? "Preview failed" : "Preview blocked"}
</p>
{embedContext && <p className="devserver-preview-blocked-context">{embedContext}</p>}
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
</div>
<p className="devserver-preview-blocked-description">
Open the preview in a new tab, or retry embedded mode after checking your server settings.
@@ -673,7 +762,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
embedStatus={embedStatus}
onEmbedStatusChange={setEmbedStatus}
iframeRef={iframeRef}
embedContext={embedContext}
blockReason={blockReason}
onRetry={handleRetryEmbeddedPreview}
/>
)}

View File

@@ -7,9 +7,11 @@ export interface PreviewIframeProps {
embedStatus: EmbedStatus;
onEmbedStatusChange: (status: EmbedStatus) => void;
iframeRef: RefObject<HTMLIFrameElement | null>;
embedContext: string | null;
blockReason: string | null;
onRetry?: () => void;
className?: string;
/** @deprecated Use blockReason instead */
embedContext?: string | null;
}
const DEFAULT_IFRAME_CLASS = "devserver-preview-iframe";
@@ -19,10 +21,14 @@ export function PreviewIframe({
embedStatus,
onEmbedStatusChange,
iframeRef,
embedContext,
blockReason,
onRetry,
className = DEFAULT_IFRAME_CLASS,
embedContext: deprecatedEmbedContext,
}: PreviewIframeProps) {
// Support both blockReason and legacy embedContext
const context = blockReason ?? deprecatedEmbedContext ?? null;
const [attempt, setAttempt] = useState(0);
useEffect(() => {
@@ -98,7 +104,7 @@ export function PreviewIframe({
<ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />
<div>
<p className="devserver-preview-blocked-title">Preview cannot be embedded</p>
{embedContext && <p className="devserver-preview-blocked-context">{embedContext}</p>}
{context && <p className="devserver-preview-blocked-context">{context}</p>}
</div>
<p className="devserver-preview-blocked-description">You can view the preview in a separate browser tab.</p>
<div className="devserver-preview-blocked-actions">
@@ -127,7 +133,7 @@ export function PreviewIframe({
<AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
<div>
<p className="devserver-preview-blocked-title">Unable to load preview</p>
{embedContext && <p className="devserver-preview-blocked-context">{embedContext}</p>}
{context && <p className="devserver-preview-blocked-context">{context}</p>}
</div>
<p className="devserver-preview-blocked-description">You can view the preview in a separate browser tab.</p>
<div className="devserver-preview-blocked-actions">

View File

@@ -1,13 +1,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
detectDevServerCommands,
fetchDevServer,
fetchDevServerLogs,
fetchDevServers,
getDevServerLogsStreamUrl,
getDevServerSessionLogsStreamUrl,
restartDevServerById,
setDevServerPreviewUrlById,
startDevServerById,
stopDevServerById,
fetchDevServerCandidates,
fetchDevServerStatus,
getDevServerLogsStreamUrl,
restartDevServer,
setDevServerPreviewUrl,
startDevServer,
stopDevServer,
type DevServerCandidate,
type DetectedDevServerCommand,
type DevServerLogEntry,
type DevServerSession,
type DevServerState,
} from "../api";
import { subscribeSse } from "../sse-bus";
@@ -40,56 +51,113 @@ function parseJson<T>(value: string): T | null {
}
}
function mapStateToHook(state: DevServerState | null): Pick<UseDevServerReturn, "status" | "detectedUrl" | "manualUrl" | "selectedCommand" | "serverState"> {
if (!state) {
return {
status: "stopped",
detectedUrl: null,
manualUrl: null,
selectedCommand: null,
serverState: null,
};
}
function logEntryToString(entry: DevServerLogEntry): string {
const text = entry.text ?? "";
return entry.stream === "stderr" ? `[stderr] ${text}` : text;
}
function extractPreviewUrl(session: DevServerSession | null): string | null {
return session?.previewUrl ?? null;
}
// Legacy API helpers for single-server fallback
async function legacyFetchStatus(projectId?: string) {
return fetchDevServerStatus(projectId);
}
async function legacyStart(body: { command: string; cwd?: string; scriptName?: string; packagePath?: string }, projectId?: string) {
return startDevServer(body, projectId);
}
async function legacyStop(projectId?: string) {
return stopDevServer(projectId);
}
async function legacyRestart(projectId?: string) {
return restartDevServer(projectId);
}
async function legacyDetect(projectId?: string) {
return fetchDevServerCandidates(projectId);
}
function getOptionalExport<T>(reader: () => T): T | null {
try {
return reader();
} catch {
return null;
}
}
function hasSessionApi(): boolean {
return typeof getOptionalExport(() => fetchDevServers) === "function"
&& typeof getOptionalExport(() => fetchDevServer) === "function";
}
function toSessionFromLegacy(legacyState: DevServerState): DevServerSession {
return {
status: state.status,
detectedUrl: state.detectedUrl ?? state.previewUrl ?? null,
manualUrl: state.manualUrl ?? state.manualPreviewUrl ?? null,
selectedCommand: state.command?.trim().length ? state.command : null,
serverState: state,
config: {
id: legacyState.id ?? "default",
name: legacyState.name ?? "Dev Server",
command: legacyState.command ?? "",
cwd: legacyState.cwd ?? ".",
},
status: legacyState.status as DevServerSession["status"],
runtime: legacyState.pid
? {
pid: legacyState.pid,
startedAt: legacyState.startedAt ?? new Date().toISOString(),
exitCode: legacyState.exitCode ?? undefined,
previewUrl: legacyState.previewUrl,
}
: undefined,
previewUrl: legacyState.previewUrl ?? legacyState.detectedUrl ?? legacyState.manualUrl ?? undefined,
logHistory: (legacyState.logs ?? []).map<DevServerLogEntry>((text) => ({
timestamp: new Date().toISOString(),
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
text: text.replace(/^\[stderr\]\s*/, ""),
})),
};
}
type DevServerStartArgs =
| string
| DevServerCandidate
| {
command: string;
cwd?: string;
scriptName?: string;
packagePath?: string;
};
export interface UseDevServerReturn {
status: "starting" | "running" | "stopped" | "failed";
/** Current active session */
session: DevServerSession | null;
/** All available sessions */
sessions: DevServerSession[];
/** Current log entries as strings */
logs: string[];
detectedUrl: string | null;
manualUrl: string | null;
selectedCommand: string | null;
candidates: DevServerCandidate[];
/** Detected dev server commands */
detectedCommands: DetectedDevServerCommand[];
/** Current preview URL */
previewUrl: string | null;
/** Loading state */
isLoading: boolean;
/** Error message if any */
error: string | null;
start: (commandOrInput: DevServerStartArgs, cwd?: string, scriptName?: string, packagePath?: string) => Promise<void>;
/** Start the dev server with the given command */
startServer: (command: string, cwd?: string) => Promise<void>;
/** Stop the dev server */
stopServer: () => Promise<void>;
/** Restart the dev server */
restartServer: () => Promise<void>;
/** Set the preview URL */
setPreviewUrl: (url: string | null) => Promise<void>;
/** Detect available dev server commands */
detectCommands: () => Promise<void>;
/** Refresh session state */
refresh: () => Promise<void>;
// Legacy aliases for compatibility
candidates: DetectedDevServerCommand[];
serverState: (DevServerSession & { pid?: number }) | null;
loading: boolean;
start: (commandOrInput: string | { command: string; cwd?: string; scriptName?: string; packagePath?: string }, cwd?: string) => Promise<void>;
stop: () => Promise<void>;
restart: () => Promise<void>;
setManualUrl: (url: string | null) => Promise<void>;
detect: () => Promise<void>;
refreshStatus: () => Promise<void>;
// Back-compat aliases used by existing consumers/tests.
serverState: DevServerState | null;
loading: boolean;
setPreviewUrl: (url: string | null) => Promise<void>;
}
export function __resetUseDevServerForTests(): void {
@@ -97,188 +165,211 @@ export function __resetUseDevServerForTests(): void {
}
export function useDevServer(projectId?: string): UseDevServerReturn {
const [status, setStatus] = useState<UseDevServerReturn["status"]>("stopped");
const [session, setSession] = useState<DevServerSession | null>(null);
const [sessions, setSessions] = useState<DevServerSession[]>([]);
const [logs, setLogs] = useState<string[]>([]);
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [manualUrl, setManualUrlState] = useState<string | null>(null);
const [selectedCommand, setSelectedCommand] = useState<string | null>(null);
const [candidates, setCandidates] = useState<DevServerCandidate[]>([]);
const [detectedCommands, setDetectedCommands] = useState<DetectedDevServerCommand[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [serverState, setServerState] = useState<DevServerState | null>(null);
const contextVersionRef = useRef(0);
// Track session ID for SSE subscription - this state change triggers effect re-run
const [subscriptionSessionId, setSubscriptionSessionId] = useState<string | null>(null);
const applyStatusState = useCallback((state: DevServerState) => {
const mapped = mapStateToHook(state);
setStatus(mapped.status);
setDetectedUrl(mapped.detectedUrl);
setManualUrlState(mapped.manualUrl);
setSelectedCommand(mapped.selectedCommand);
setServerState(mapped.serverState);
if (Array.isArray(state.logs)) {
setLogs(capLogs(state.logs));
const applySession = useCallback((newSession: DevServerSession | null) => {
setSession(newSession);
if (newSession?.logHistory) {
const logStrings = newSession.logHistory
.slice(-MAX_LOG_LINES)
.map(logEntryToString);
setLogs(logStrings);
}
}, []);
const refreshStatus = useCallback(async () => {
const refresh = useCallback(async () => {
const versionAtStart = contextVersionRef.current;
try {
const nextState = await fetchDevServerStatus(projectId);
if (contextVersionRef.current !== versionAtStart) {
return;
if (hasSessionApi()) {
if (subscriptionSessionId) {
const updatedSession = await fetchDevServer(subscriptionSessionId, projectId);
if (contextVersionRef.current !== versionAtStart) {
return;
}
applySession(updatedSession);
} else {
const allSessions = await fetchDevServers(projectId);
if (contextVersionRef.current !== versionAtStart) {
return;
}
setSessions(allSessions);
if (allSessions.length > 0) {
setSubscriptionSessionId(allSessions[0].config.id);
applySession(allSessions[0]);
}
}
} else {
const legacyState = await legacyFetchStatus(projectId);
if (contextVersionRef.current !== versionAtStart) {
return;
}
const legacySession = toSessionFromLegacy(legacyState);
setSessions([legacySession]);
applySession(legacySession);
}
applyStatusState(nextState);
setError(null);
} catch (refreshError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(refreshError));
throw refreshError;
}
}, [applyStatusState, projectId]);
}, [applySession, projectId, subscriptionSessionId]);
useEffect(() => {
contextVersionRef.current += 1;
const versionAtStart = contextVersionRef.current;
setStatus("stopped");
// Reset state
setSession(null);
setSessions([]);
setLogs([]);
setDetectedUrl(null);
setManualUrlState(null);
setSelectedCommand(null);
setCandidates([]);
setDetectedCommands([]);
setIsLoading(true);
setError(null);
setServerState(null);
setSubscriptionSessionId(null);
void Promise.allSettled([
fetchDevServerCandidates(projectId),
fetchDevServerStatus(projectId),
]).then(([candidateResult, statusResult]) => {
if (contextVersionRef.current !== versionAtStart) {
return;
const loadInitialData = async () => {
try {
const [sessionsResult, commandsResult] = await Promise.allSettled([
hasSessionApi()
? fetchDevServers(projectId)
: legacyFetchStatus(projectId).then((state) => [toSessionFromLegacy(state)]),
typeof getOptionalExport(() => detectDevServerCommands) === "function"
? detectDevServerCommands(projectId)
: legacyDetect(projectId).then((legacyCandidates) => legacyCandidates.map((candidate) => ({
name: candidate.name,
command: candidate.command,
cwd: candidate.cwd,
scriptName: candidate.scriptName,
packagePath: candidate.packagePath,
}))),
]);
if (contextVersionRef.current !== versionAtStart) {
return;
}
let nextError: string | null = null;
if (sessionsResult.status === "fulfilled") {
const sessionsData = sessionsResult.value;
setSessions(sessionsData);
if (sessionsData.length > 0) {
const firstSession = sessionsData[0];
if (hasSessionApi()) {
setSubscriptionSessionId(firstSession.config.id);
}
applySession(firstSession);
}
} else {
nextError = normalizeError(sessionsResult.reason);
}
if (commandsResult.status === "fulfilled") {
setDetectedCommands(commandsResult.value);
}
if (nextError) {
setError(nextError);
}
} catch (err) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(err));
} finally {
if (contextVersionRef.current === versionAtStart) {
setIsLoading(false);
}
}
let nextError: string | null = null;
if (candidateResult.status === "fulfilled") {
setCandidates(candidateResult.value);
} else {
nextError = normalizeError(candidateResult.reason);
}
if (statusResult.status === "fulfilled") {
applyStatusState(statusResult.value);
} else {
nextError = nextError ?? normalizeError(statusResult.reason);
}
setError(nextError);
setIsLoading(false);
});
return () => {
contextVersionRef.current += 1;
};
}, [applyStatusState, projectId]);
void loadInitialData();
}, [applySession, projectId]);
// SSE subscription for live log updates
useEffect(() => {
if (status !== "running" && status !== "starting") {
const streamUrl = subscriptionSessionId
? getDevServerSessionLogsStreamUrl(subscriptionSessionId, projectId)
: (typeof getOptionalExport(() => getDevServerLogsStreamUrl) === "function" ? getDevServerLogsStreamUrl(projectId) : null);
if (!streamUrl) {
return;
}
const intervalId = window.setInterval(() => {
void refreshStatus().catch(() => {
// Errors are recorded in hook state.
});
}, POLL_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [refreshStatus, status]);
useEffect(() => {
const versionAtStart = contextVersionRef.current;
const unsubscribe = subscribeSse(getDevServerLogsStreamUrl(projectId), {
const unsubscribe = subscribeSse(streamUrl, {
events: {
history: (event) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const payload = parseJson<{ lines?: Array<string | { text?: string; line?: string; stream?: "stdout" | "stderr" }> }>(event.data);
const nextLogs = Array.isArray(payload?.lines)
? payload.lines.map((entry) => {
if (typeof entry === "string") {
return entry;
}
const text = typeof entry?.text === "string" ? entry.text : (typeof entry?.line === "string" ? entry.line : "");
const stream = entry?.stream;
return stream === "stderr" ? `[stderr] ${text}` : text;
}).filter((line) => line.length > 0)
: [];
if (nextLogs.length > 0) {
setLogs(capLogs(nextLogs));
const payload = parseJson<{ lines?: DevServerLogEntry[] }>(event.data);
if (payload?.lines) {
const logStrings = payload.lines.map(logEntryToString);
setLogs(capLogs(logStrings));
}
},
log: (event) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const payload = parseJson<{ line?: string; text?: string; stream?: "stdout" | "stderr" }>(event.data);
const text = typeof payload?.line === "string"
? payload.line
: typeof payload?.text === "string"
? payload.text
: event.data;
const stream = payload?.stream;
const formatted = stream === "stderr" ? `[stderr] ${text}` : text;
setLogs((current) => appendLog(current, formatted));
setError(null);
const payload = parseJson<DevServerLogEntry & { line?: string }>(event.data);
if (payload) {
const line = typeof payload.line === "string" ? payload.line : logEntryToString(payload);
setLogs((prev) => appendLog(prev, line));
}
},
"dev-server:log": (event) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const payload = parseJson<{ line?: string; text?: string; stream?: "stdout" | "stderr" }>(event.data);
const text = typeof payload?.line === "string"
? payload.line
: typeof payload?.text === "string"
? payload.text
: event.data;
const stream = payload?.stream;
const formatted = stream === "stderr" ? `[stderr] ${text}` : text;
setLogs((current) => appendLog(current, formatted));
setError(null);
const payload = parseJson<DevServerLogEntry & { line?: string }>(event.data);
if (payload) {
const line = typeof payload.line === "string" ? payload.line : logEntryToString(payload);
setLogs((prev) => appendLog(prev, line));
}
},
"dev-server:output": (event) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const payload = parseJson<{ text?: string; stream?: "stdout" | "stderr" }>(event.data);
if (!payload?.text) {
return;
const payload = parseJson<{ line?: string }>(event.data);
if (payload?.line) {
setLogs((prev) => appendLog(prev, payload.line!));
}
const formatted = payload.stream === "stderr" ? `[stderr] ${payload.text}` : payload.text;
setLogs((current) => appendLog(current, formatted));
setError(null);
},
status: (event) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const payload = parseJson<DevServerState>(event.data);
if (payload) {
applyStatusState(payload);
setError(null);
const payload = parseJson<{ status?: DevServerSession["status"]; pid?: number }>(event.data);
const nextStatus = payload?.status;
if (nextStatus) {
setSession((prev) => (prev
? {
...prev,
status: nextStatus,
runtime: payload.pid
? {
...(prev.runtime ?? { startedAt: new Date().toISOString() }),
pid: payload.pid,
}
: prev.runtime,
}
: prev));
}
},
"dev-server:status": (event) => {
@@ -286,35 +377,30 @@ export function useDevServer(projectId?: string): UseDevServerReturn {
return;
}
const payload = parseJson<DevServerState>(event.data);
if (payload) {
applyStatusState(payload);
setError(null);
if (payload?.status) {
const nextSession = toSessionFromLegacy(payload);
setSession(nextSession);
setSessions([nextSession]);
}
},
stopped: () => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
void refreshStatus().catch(() => {
// Errors are recorded in hook state.
});
// Server stopped event
},
failed: () => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
void refreshStatus().catch(() => {
// Errors are recorded in hook state.
});
// Server failed event
},
},
onReconnect: () => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
void refreshStatus().catch(() => {
// Errors are recorded in hook state.
});
void refresh();
},
onError: () => {
if (contextVersionRef.current !== versionAtStart) {
@@ -327,206 +413,264 @@ export function useDevServer(projectId?: string): UseDevServerReturn {
return () => {
unsubscribe();
};
}, [applyStatusState, projectId, refreshStatus]);
}, [projectId, refresh, subscriptionSessionId]);
// Polling while server is running
useEffect(() => {
const version = resetVersion;
return () => {
if (resetVersion !== version) {
contextVersionRef.current += 1;
}
};
}, []);
const start = useCallback(async (
commandOrInput: DevServerStartArgs,
cwd?: string,
scriptName?: string,
packagePath?: string,
) => {
const payload = typeof commandOrInput === "string"
? {
command: commandOrInput,
cwd,
scriptName,
packagePath,
}
: {
command: commandOrInput.command,
cwd: commandOrInput.cwd ?? cwd,
scriptName: commandOrInput.scriptName ?? scriptName,
packagePath: commandOrInput.packagePath ?? cwd ?? commandOrInput.cwd ?? packagePath,
};
const trimmedCommand = payload.command.trim();
if (!trimmedCommand) {
const message = "Command is required to start the dev server.";
setError(message);
throw new Error(message);
if (session?.status !== "running" && session?.status !== "starting") {
return;
}
const interval = setInterval(() => {
void refresh();
}, POLL_INTERVAL_MS);
return () => {
clearInterval(interval);
};
}, [refresh, session?.status]);
const startServer = useCallback(async (command: string, cwd?: string) => {
contextVersionRef.current += 1;
const versionAtStart = contextVersionRef.current;
setError(null);
setStatus("starting");
setSelectedCommand(trimmedCommand);
try {
const nextState = await startDevServer(
{
command: trimmedCommand,
cwd: payload.cwd,
scriptName: payload.scriptName,
packagePath: payload.packagePath,
},
projectId,
);
let result: DevServerSession;
if (subscriptionSessionId && typeof getOptionalExport(() => startDevServerById) === "function") {
result = await startDevServerById(subscriptionSessionId, projectId);
} else {
const legacyState = await legacyStart({ command, cwd }, projectId);
result = toSessionFromLegacy(legacyState);
}
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyStatusState(nextState);
setSession(result);
setError(null);
} catch (startError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setStatus("failed");
setError(normalizeError(startError));
throw startError;
}
}, [applyStatusState, projectId]);
}, [projectId, subscriptionSessionId]);
const stop = useCallback(async () => {
const stopServer = useCallback(async () => {
contextVersionRef.current += 1;
const versionAtStart = contextVersionRef.current;
setError(null);
try {
const nextState = await stopDevServer(projectId);
let result: DevServerSession;
if (subscriptionSessionId && typeof getOptionalExport(() => stopDevServerById) === "function") {
result = await stopDevServerById(subscriptionSessionId, projectId);
} else {
const legacyState = await legacyStop(projectId);
result = toSessionFromLegacy(legacyState);
}
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyStatusState(nextState);
setSession(result);
setError(null);
} catch (stopError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(stopError));
throw stopError;
}
}, [applyStatusState, projectId]);
}, [projectId, subscriptionSessionId]);
const restart = useCallback(async () => {
const restartServer = useCallback(async () => {
contextVersionRef.current += 1;
const versionAtStart = contextVersionRef.current;
setError(null);
setStatus("starting");
try {
const nextState = await restartDevServer(projectId);
let result: DevServerSession;
if (subscriptionSessionId && typeof getOptionalExport(() => restartDevServerById) === "function") {
result = await restartDevServerById(subscriptionSessionId, projectId);
} else {
const legacyState = await legacyRestart(projectId);
result = toSessionFromLegacy(legacyState);
}
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyStatusState(nextState);
setSession(result);
setError(null);
} catch (restartError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(restartError));
throw restartError;
}
}, [applyStatusState, projectId]);
}, [projectId, subscriptionSessionId]);
const setManualUrl = useCallback(async (url: string | null) => {
const setPreviewUrl = useCallback(async (url: string | null) => {
contextVersionRef.current += 1;
const versionAtStart = contextVersionRef.current;
setError(null);
try {
const nextState = await setDevServerPreviewUrl({ url }, projectId);
let result: { url: string | null; source: string | null };
if (subscriptionSessionId && typeof getOptionalExport(() => setDevServerPreviewUrlById) === "function") {
result = await setDevServerPreviewUrlById(subscriptionSessionId, url, projectId);
} else {
const legacyState = await setDevServerPreviewUrl({ url }, projectId);
result = {
url: legacyState.manualUrl ?? legacyState.previewUrl ?? legacyState.detectedUrl ?? null,
source: legacyState.manualUrl ? "manual" : "auto",
};
}
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyStatusState(nextState);
setManualUrlState(nextState.manualUrl ?? nextState.manualPreviewUrl ?? url ?? null);
// Update session with new preview URL
setSession((prev) => {
if (!prev) {
return null;
}
return {
...prev,
previewUrl: result.url ?? undefined,
};
});
setError(null);
} catch (previewError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(previewError));
throw previewError;
}
}, [applyStatusState, projectId]);
}, [projectId, subscriptionSessionId]);
const detect = useCallback(async () => {
const detectCommands = useCallback(async () => {
contextVersionRef.current += 1;
const versionAtStart = contextVersionRef.current;
setError(null);
try {
const detected = await fetchDevServerCandidates(projectId);
let commands: DetectedDevServerCommand[];
try {
commands = await detectDevServerCommands(projectId);
} catch {
// Fallback to legacy detect
const legacyCandidates = await legacyDetect(projectId);
commands = legacyCandidates.map((candidate) => ({
name: candidate.name,
command: candidate.command,
cwd: candidate.cwd,
scriptName: candidate.scriptName,
packagePath: candidate.packagePath,
}));
}
if (contextVersionRef.current !== versionAtStart) {
return;
}
setCandidates(detected);
setDetectedCommands(commands);
setError(null);
} catch (detectError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(detectError));
throw detectError;
}
}, [projectId]);
const setPreviewUrl = useCallback((url: string | null) => setManualUrl(url), [setManualUrl]);
// Legacy alias methods
const start = useCallback(async (commandOrInput: string | { command: string; cwd?: string; scriptName?: string; packagePath?: string }, cwd?: string) => {
if (typeof commandOrInput !== "string" && !subscriptionSessionId) {
try {
const input = commandOrInput;
const legacyState = await legacyStart(
{
command: input.command,
cwd: input.cwd,
scriptName: input.scriptName,
packagePath: input.packagePath ?? input.cwd,
},
projectId,
);
const nextSession = toSessionFromLegacy(legacyState);
setSession(nextSession);
setSessions([nextSession]);
setError(null);
return;
} catch (legacyStartError) {
setError(normalizeError(legacyStartError));
throw legacyStartError;
}
}
return useMemo(() => ({
status,
const command = typeof commandOrInput === "string" ? commandOrInput : commandOrInput.command;
const cwdArg = typeof commandOrInput === "string" ? cwd : commandOrInput.cwd;
await startServer(command, cwdArg);
}, [projectId, startServer, subscriptionSessionId]);
const stop = useCallback(async () => {
await stopServer();
}, [stopServer]);
const restart = useCallback(async () => {
await restartServer();
}, [restartServer]);
const setManualUrl = useCallback(async (url: string | null) => {
await setPreviewUrl(url);
}, [setPreviewUrl]);
const detect = useCallback(async () => {
await detectCommands();
}, [detectCommands]);
const refreshStatus = useCallback(async () => {
await refresh();
}, [refresh]);
const previewUrl = extractPreviewUrl(session);
const serverState = session ? { ...session, pid: session.runtime?.pid } : null;
return {
session,
sessions,
logs,
detectedUrl,
manualUrl,
selectedCommand,
candidates,
detectedCommands,
previewUrl,
isLoading,
error,
start,
stop,
restart,
setManualUrl,
detect,
refreshStatus,
startServer,
stopServer,
restartServer,
setPreviewUrl,
detectCommands,
refresh,
// Legacy aliases
candidates: detectedCommands,
serverState,
loading: isLoading,
setPreviewUrl,
}), [
status,
logs,
detectedUrl,
manualUrl,
selectedCommand,
candidates,
isLoading,
error,
// Legacy methods
start,
stop,
restart,
setManualUrl,
detect,
refreshStatus,
serverState,
setPreviewUrl,
]);
};
}

View File

@@ -351,6 +351,9 @@ export function useDevServerLogs(projectId: string | undefined, enabled: boolean
setEntries([]);
}, []);
// Track streaming state: true when SSE subscription is active
const isStreaming = unsubscribeRef.current !== null && !loading && !cancelledRef.current;
return {
entries,
loading,
@@ -359,6 +362,10 @@ export function useDevServerLogs(projectId: string | undefined, enabled: boolean
total,
loadMore,
clear,
// New simplified interface per Step 2 requirements
logs: entries, // Alias for backward compatibility
isStreaming, // Track SSE subscription state
clearLogs: clear, // Alias for clear
};
}

View File

@@ -1,6 +1,7 @@
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 = "The server may block iframe embedding via X-Frame-Options or Content-Security-Policy headers. Browsers prevent detecting these headers from JavaScript.";
const ERROR_CONTEXT = "The preview URL could not be loaded. The server may not be running or the URL may be incorrect.";
@@ -8,17 +9,22 @@ const TIMEOUT_CONTEXT = "Preview is taking longer than expected to load. The ser
interface UsePreviewEmbedOptions {
loadTimeoutMs?: number;
detectionMethod?: EmbedDetectionMethod;
}
interface UsePreviewEmbedResult {
embedStatus: EmbedStatus;
setEmbedStatus: (status: EmbedStatus) => void;
resetEmbedStatus: () => void;
iframeRef: RefObject<HTMLIFrameElement | null>;
isEmbedded: boolean;
isBlocked: boolean;
embedContext: string | null;
blockReason: string | null;
detectionMethod: EmbedDetectionMethod;
iframeRef: RefObject<HTMLIFrameElement | null>;
resetEmbedStatus: () => void;
// Extended API for direct status control (backward compatibility)
setEmbedStatus: (status: EmbedStatus) => void;
retry: () => void;
// Legacy aliases for backward compatibility
embedContext: string | null;
handleIframeLoad: () => void;
handleIframeError: () => void;
resetEmbed: () => void;
@@ -39,12 +45,13 @@ function defaultContextForStatus(status: EmbedStatus): string | null {
}
export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOptions = {}): UsePreviewEmbedResult {
const { loadTimeoutMs = 10000 } = options;
const { loadTimeoutMs = 10000, detectionMethod: initialDetectionMethod = null } = options;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [embedStatus, setEmbedStatusState] = useState<EmbedStatus>("unknown");
const [embedContext, setEmbedContext] = useState<string | null>(null);
const [blockReason, setBlockReason] = useState<string | null>(null);
const [detectionMethod, setDetectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod);
const clearLoadingTimeout = useCallback(() => {
if (timeoutRef.current !== null) {
@@ -55,12 +62,12 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
const setEmbedStatus = useCallback((status: EmbedStatus) => {
setEmbedStatusState(status);
setEmbedContext(defaultContextForStatus(status));
setBlockReason(defaultContextForStatus(status));
}, []);
const setBlockedByTimeout = useCallback(() => {
setEmbedStatusState("blocked");
setEmbedContext(TIMEOUT_CONTEXT);
setBlockReason(TIMEOUT_CONTEXT);
}, []);
useEffect(() => {
@@ -68,12 +75,12 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
if (!url) {
setEmbedStatusState("unknown");
setEmbedContext(null);
setBlockReason(null);
return;
}
setEmbedStatusState("unknown");
setEmbedContext(null);
setBlockReason(null);
let canceled = false;
queueMicrotask(() => {
@@ -81,7 +88,7 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
return;
}
setEmbedStatusState("loading");
setEmbedContext(null);
setBlockReason(null);
});
return () => {
@@ -138,13 +145,13 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
const resetEmbedStatus = useCallback(() => {
clearLoadingTimeout();
setEmbedStatusState("unknown");
setEmbedContext(null);
setBlockReason(null);
}, [clearLoadingTimeout]);
const retry = useCallback(() => {
clearLoadingTimeout();
setEmbedStatusState("unknown");
setEmbedContext(null);
setBlockReason(null);
}, [clearLoadingTimeout]);
const isEmbedded = useMemo(() => embedStatus === "embedded", [embedStatus]);
@@ -155,12 +162,15 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
return {
embedStatus,
setEmbedStatus,
resetEmbedStatus,
iframeRef,
isEmbedded,
isBlocked,
embedContext,
blockReason,
detectionMethod,
iframeRef,
resetEmbedStatus,
// Legacy aliases
setEmbedStatus,
embedContext: blockReason, // Alias for backward compatibility
retry,
handleIframeLoad,
handleIframeError,