diff --git a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/ContentPreviewConfig.tsx b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/ContentPreviewConfig.tsx
index 4654de3ff43..ca61eae7a8f 100644
--- a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/ContentPreviewConfig.tsx
+++ b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/ContentPreviewConfig.tsx
@@ -4,6 +4,7 @@ import { Breadcrumbs } from "./Breadcrumbs/index.js";
import { DocumentPreview } from "./Preview/DocumentPreview.js";
import { AddressBar } from "./AddressBar/AddressBar.js";
import { BreakpointSelector } from "./AddressBar/BreakpointSelector.js";
+import { SampleFrontendBanner } from "./SampleFrontendBanner.js";
export const ContentPreviewConfig = () => {
const { Ui } = EditorConfig;
@@ -16,6 +17,11 @@ export const ContentPreviewConfig = () => {
element={}
/>
} />
+ }
+ />
} />
void;
+ /**
+ * Rendered on top of the preview instead of the loader. Used to explain why the preview is
+ * empty, while keeping the iframe mounted so that a late connection still resolves on its own.
+ */
+ overlay?: React.ReactNode;
}
export const Iframe = observer(({ url, timestamp, ...props }: IframeProps) => {
@@ -49,14 +54,15 @@ export const Iframe = observer(({ url, timestamp, ...props }: IframeProps) => {
return (
- {props.showLoading ? (
-
- ) : null}
+ {props.overlay ??
+ (props.showLoading ? (
+
+ ) : null)}
{/* Content wrapper - sized by iframe content */}
void;
+}
+
+const COPY: Record = {
+ unreachable: {
+ title: "No frontend detected",
+ description:
+ "The editor renders your pages inside your own frontend, and nothing is currently running at"
+ },
+ unresponsive: {
+ title: "Frontend didn't connect",
+ description:
+ "Something is running, but it never connected to the editor. Make sure the Website Builder SDK is set up in the app running at"
+ }
+};
+
+export const NoFrontendConnected = ({ origin, status, onRetry }: NoFrontendConnectedProps) => {
+ const { previewDomain, setPreviewDomain } = usePreviewDomain();
+
+ const loadSampleFrontend = useCallback(() => {
+ setPreviewDomain(SAMPLE_FRONTEND_DOMAIN);
+ }, [setPreviewDomain]);
+
+ const openInstructions = useCallback(() => {
+ window.open(FRONTEND_SETUP_DOCS_URL, "_blank", "noopener,noreferrer");
+ }, []);
+
+ const copy = COPY[status];
+
+ // No point in offering the sample frontend when it's the domain that just failed to connect.
+ const canLoadSampleFrontend = previewDomain !== SAMPLE_FRONTEND_DOMAIN;
+
+ return (
+
+
+ {copy.description} {origin}.
+ >
+ }
+ actions={
+ <>
+ }
+ text={"Read the instructions"}
+ onClick={openInstructions}
+ />
+ {canLoadSampleFrontend ? (
+ }
+ text={"Load sample frontend"}
+ onClick={loadSampleFrontend}
+ />
+ ) : null}
+ }
+ text={"Try again"}
+ onClick={onRetry}
+ />
+ >
+ }
+ />
+ {canLoadSampleFrontend ? (
+
+ The sample frontend lets you build pages and see them here in the editor.
+ Viewing or previewing them outside the editor needs a frontend of your own, and
+ locally uploaded images and content-bound data won't render in the sample
+ either.
+
+ ) : null}
+
+ );
+};
diff --git a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/Preview.tsx b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/Preview.tsx
index 2ae468ddafd..903e5c97df0 100644
--- a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/Preview.tsx
+++ b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/Preview.tsx
@@ -1,9 +1,8 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import type { Messenger } from "@webiny/website-builder-sdk";
+import React, { useEffect, useMemo, useState } from "react";
import { ViewportManager } from "@webiny/website-builder-sdk";
import { mouseTracker } from "@webiny/website-builder-sdk";
import { useDocumentEditor } from "~/DocumentEditor/index.js";
-import { Iframe } from "./Iframe.js";
+import { PreviewFrame } from "./PreviewFrame.js";
import { DropZoneManager } from "./DropZoneManager.js";
import { DropZoneManagerProvider } from "./DropZoneManagerProvider.js";
import { KeyboardShortcuts } from "./KeyboardShortcuts.js";
@@ -82,21 +81,20 @@ export const Preview = () => {
};
}, []);
- const onConnected = useCallback((messenger: Messenger) => {
- previewEvents.onConnected(messenger);
- }, []);
-
return (
<>
{({ url }) => (
-
)}
diff --git a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/PreviewFrame.tsx b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/PreviewFrame.tsx
new file mode 100644
index 00000000000..030a7c66b5f
--- /dev/null
+++ b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/PreviewFrame.tsx
@@ -0,0 +1,51 @@
+import React, { useCallback, useMemo, useState } from "react";
+import type { Messenger, ViewportManager } from "@webiny/website-builder-sdk";
+import { Iframe } from "./Iframe.js";
+import { NoFrontendConnected } from "./NoFrontendConnected.js";
+import type { PreviewEvents } from "./PreviewEvents.js";
+import { usePreviewConnection } from "./usePreviewConnection.js";
+
+interface PreviewFrameProps {
+ url: string;
+ timestamp: number;
+ showLoading: boolean;
+ viewportManager: ViewportManager;
+ previewEvents: PreviewEvents;
+}
+
+/**
+ * Owns everything that belongs to a single load of the preview: the handshake with the frontend,
+ * and the resulting connection status. The parent remounts this component on every page load, so
+ * the connection state always starts over with the iframe.
+ */
+export const PreviewFrame = ({ url, timestamp, showLoading, ...props }: PreviewFrameProps) => {
+ const [connected, setConnected] = useState(false);
+ const { status, retry } = usePreviewConnection({ url, connected });
+
+ const onConnected = useCallback(
+ (messenger: Messenger) => {
+ setConnected(true);
+ props.previewEvents.onConnected(messenger);
+ },
+ [props.previewEvents]
+ );
+
+ const overlay = useMemo(() => {
+ if (status !== "unreachable" && status !== "unresponsive") {
+ return null;
+ }
+
+ return ;
+ }, [status, url, retry]);
+
+ return (
+
+ );
+};
diff --git a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/usePreviewConnection.ts b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/usePreviewConnection.ts
new file mode 100644
index 00000000000..5dbddfdce1a
--- /dev/null
+++ b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/Preview/usePreviewConnection.ts
@@ -0,0 +1,127 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Commands } from "~/BaseEditor/index.js";
+import { useDocumentEditor } from "~/DocumentEditor/index.js";
+
+/**
+ * `connecting` - the preview was just (re)loaded, and we're still waiting for the handshake.
+ * `connected` - the frontend sent `preview.ready`, which means the editor can talk to it.
+ * `unreachable` - nothing is listening on the preview domain, usually because no frontend is running.
+ * `unresponsive` - something is listening, but it never sent `preview.ready`, which means it's not a
+ * Webiny frontend, or the Website Builder SDK is not set up in it.
+ */
+export type PreviewConnectionStatus = "connecting" | "connected" | "unreachable" | "unresponsive";
+
+export type PreviewConnectionError = Extract<
+ PreviewConnectionStatus,
+ "unreachable" | "unresponsive"
+>;
+
+// How long we wait for the frontend's `preview.ready` message. This has to be generous, because a
+// dev server that was just started compiles the page on the first request.
+const HANDSHAKE_TIMEOUT_MS = 15000;
+
+// How often an unreachable preview domain is re-checked, so that starting a dev server while the
+// editor is open reloads the preview on its own.
+const REACHABILITY_CHECK_INTERVAL_MS = 4000;
+
+/**
+ * Checks whether anything is listening on the given origin. The response is opaque (`no-cors`),
+ * which is all we need: we only care about whether the browser managed to open a connection.
+ * A refused connection rejects almost immediately, which is what makes this a useful early signal.
+ */
+const isReachable = async (origin: string) => {
+ try {
+ await fetch(origin, { method: "HEAD", mode: "no-cors", cache: "no-store" });
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+interface UsePreviewConnectionParams {
+ // The full preview URL. Only its origin is used for reachability checks.
+ url: string;
+ // Whether the frontend has completed the handshake with the editor.
+ connected: boolean;
+}
+
+interface UsePreviewConnectionResult {
+ status: PreviewConnectionStatus;
+ retry: () => void;
+}
+
+/**
+ * Tells us whether the editor is actually talking to a frontend, and if not, why not.
+ *
+ * There are two signals at play. The handshake (`preview.ready`) is the authoritative one: it only
+ * arrives from a frontend that has the Website Builder SDK set up. The reachability check is there
+ * for speed: waiting out the handshake timeout to tell someone that they have no frontend running
+ * is a poor first experience, and a refused connection gives us that answer in milliseconds.
+ */
+export const usePreviewConnection = (
+ params: UsePreviewConnectionParams
+): UsePreviewConnectionResult => {
+ const { url, connected } = params;
+ const editor = useDocumentEditor();
+ const [status, setStatus] = useState("connecting");
+
+ const origin = useMemo(() => new URL(url).origin, [url]);
+
+ const retry = useCallback(() => {
+ editor.executeCommand(Commands.RefreshPreview);
+ }, [editor]);
+
+ useEffect(() => {
+ if (connected) {
+ setStatus("connected");
+ return;
+ }
+
+ let disposed = false;
+
+ isReachable(origin).then(reachable => {
+ // When the origin is reachable, we keep waiting. Only a missing handshake can tell us
+ // that whatever answered isn't a frontend the editor can drive.
+ if (!disposed && !reachable) {
+ setStatus("unreachable");
+ }
+ });
+
+ const handshakeTimeout = setTimeout(() => {
+ if (!disposed) {
+ // `unreachable` describes the problem more accurately, so don't overwrite it.
+ setStatus(current => (current === "connecting" ? "unresponsive" : current));
+ }
+ }, HANDSHAKE_TIMEOUT_MS);
+
+ return () => {
+ disposed = true;
+ clearTimeout(handshakeTimeout);
+ };
+ }, [origin, connected]);
+
+ useEffect(() => {
+ if (status !== "unreachable") {
+ // We only poll while nothing is listening. A domain that answers but doesn't complete
+ // the handshake would otherwise reload the preview over and over.
+ return;
+ }
+
+ let disposed = false;
+
+ const interval = setInterval(async () => {
+ const reachable = await isReachable(origin);
+ if (!disposed && reachable) {
+ // The user started their frontend, so reload the preview for them.
+ retry();
+ }
+ }, REACHABILITY_CHECK_INTERVAL_MS);
+
+ return () => {
+ disposed = true;
+ clearInterval(interval);
+ };
+ }, [status, origin, retry]);
+
+ return { status, retry };
+};
diff --git a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/SampleFrontendBanner.tsx b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/SampleFrontendBanner.tsx
new file mode 100644
index 00000000000..719fd006123
--- /dev/null
+++ b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/SampleFrontendBanner.tsx
@@ -0,0 +1,39 @@
+import React, { useCallback } from "react";
+import { Alert } from "@webiny/admin-ui";
+import { FRONTEND_SETUP_DOCS_URL, SAMPLE_FRONTEND_DOMAIN } from "./sampleFrontend.js";
+import { usePreviewDomain } from "./usePreviewDomain.js";
+
+/**
+ * Shown while the preview points to Webiny's hosted sample frontend, so it's always clear that the
+ * page isn't rendering in the user's own frontend, and how to change that.
+ */
+export const SampleFrontendBanner = () => {
+ const { previewDomain, unsetPreviewDomain } = usePreviewDomain();
+
+ const openInstructions = useCallback(() => {
+ // Opened in a new tab so the user doesn't lose the page they're editing.
+ window.open(FRONTEND_SETUP_DOCS_URL, "_blank", "noopener,noreferrer");
+ }, []);
+
+ if (previewDomain !== SAMPLE_FRONTEND_DOMAIN) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ >
+ }
+ >
+ You're on Webiny's sample frontend — pages render only inside the editor.
+
+
+ );
+};
diff --git a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/sampleFrontend.ts b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/sampleFrontend.ts
new file mode 100644
index 00000000000..8af8136445f
--- /dev/null
+++ b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/sampleFrontend.ts
@@ -0,0 +1,10 @@
+/**
+ * A hosted Website Builder frontend, so that users who don't have a frontend of their own running
+ * yet can still see their pages render in the editor.
+ */
+export const SAMPLE_FRONTEND_DOMAIN = "https://wb-demo.webiny.com";
+
+/**
+ * Instructions on how to create a frontend and connect it to the editor.
+ */
+export const FRONTEND_SETUP_DOCS_URL = "https://webiny.link/wb-frontend";