diff --git a/app/javascript/alchemy_admin/components/preview_window.js b/app/javascript/alchemy_admin/components/preview_window.js
index dceeb03aec..3a340c195a 100644
--- a/app/javascript/alchemy_admin/components/preview_window.js
+++ b/app/javascript/alchemy_admin/components/preview_window.js
@@ -1,23 +1,39 @@
+import { growl } from "alchemy_admin/growler"
+import { translate } from "alchemy_admin/i18n"
+
class PreviewWindow extends HTMLIFrameElement {
#afterLoad
#reloadIcon
+ #loadTimeout
+ #previewReadyHandler
constructor() {
super()
this.addEventListener("load", this)
+ this.#previewReadyHandler = this.#handlePreviewReadyMessage.bind(this)
}
handleEvent(evt) {
if (evt.type === "load") {
+ this.#clearLoadTimeout()
this.#stopSpinner()
this.#afterLoad?.call(this, evt)
}
}
+ #handlePreviewReadyMessage(event) {
+ if (event.data.message === "Alchemy.previewReady") {
+ this.#clearLoadTimeout()
+ this.#stopSpinner()
+ this.#afterLoad?.call(this, event)
+ }
+ }
+
connectedCallback() {
let url = this.url
this.#attachEvents()
+ window.addEventListener("message", this.#previewReadyHandler)
if (window.localStorage.getItem("alchemy-preview-url")) {
url = window.localStorage.getItem("alchemy-preview-url")
@@ -29,6 +45,7 @@ class PreviewWindow extends HTMLIFrameElement {
disconnectedCallback() {
key.unbind("alt+r")
+ window.removeEventListener("message", this.#previewReadyHandler)
}
postMessage(data) {
@@ -48,6 +65,13 @@ class PreviewWindow extends HTMLIFrameElement {
this.src = this.url
}
+ // Set 5s timeout as fallback - if iframe doesn't load, stop spinner anyway
+ this.#clearLoadTimeout()
+ this.#loadTimeout = setTimeout(() => {
+ this.#stopSpinner()
+ growl(translate("Preview failed to load"), "warning")
+ }, 5000)
+
return new Promise((resolve) => {
this.#afterLoad = resolve
})
@@ -85,7 +109,10 @@ class PreviewWindow extends HTMLIFrameElement {
}
#startSpinner() {
- this.#reloadIcon = this.reloadButton.innerHTML
+ // Only save the reload icon if we're not already showing a spinner
+ if (!this.reloadButton.innerHTML.includes("alchemy-spinner")) {
+ this.#reloadIcon = this.reloadButton.innerHTML
+ }
this.reloadButton.innerHTML = ``
}
@@ -93,6 +120,13 @@ class PreviewWindow extends HTMLIFrameElement {
this.reloadButton.innerHTML = this.#reloadIcon
}
+ #clearLoadTimeout() {
+ if (this.#loadTimeout) {
+ clearTimeout(this.#loadTimeout)
+ this.#loadTimeout = null
+ }
+ }
+
get url() {
return this.getAttribute("url")
}
diff --git a/app/javascript/preview.js b/app/javascript/preview.js
index c4277c2b14..9f27dbb03c 100644
--- a/app/javascript/preview.js
+++ b/app/javascript/preview.js
@@ -115,3 +115,11 @@ Object.assign(Alchemy, {
})
Alchemy.ElementSelector.init()
+
+// Notify parent window that preview is ready
+window.parent.postMessage(
+ {
+ message: "Alchemy.previewReady"
+ },
+ window.location.origin
+)
diff --git a/app/views/alchemy/admin/translations/_en.js b/app/views/alchemy/admin/translations/_en.js
index fb4ce0914d..bd564ed5f9 100644
--- a/app/views/alchemy/admin/translations/_en.js
+++ b/app/views/alchemy/admin/translations/_en.js
@@ -13,6 +13,7 @@ Alchemy.translations = {
page_found: "Page found",
pages_found: "Pages found",
"Please confirm": "Please confirm",
+ "Preview failed to load": "Preview failed to load. Please try again.",
url_validation_failed: "The url has no valid format.",
warning: "Warning!",
"File is too large": "File is too large",
diff --git a/spec/javascript/alchemy_admin/components/preview_window.spec.js b/spec/javascript/alchemy_admin/components/preview_window.spec.js
new file mode 100644
index 0000000000..0496fc3728
--- /dev/null
+++ b/spec/javascript/alchemy_admin/components/preview_window.spec.js
@@ -0,0 +1,198 @@
+import "alchemy_admin/components/preview_window"
+import * as growler from "alchemy_admin/growler"
+import { setupLanguage } from "./component.helper"
+
+describe("alchemy-preview-window", () => {
+ /**
+ * @type {HTMLIFrameElement | undefined}
+ */
+ let previewWindow = undefined
+ let reloadButton = undefined
+
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+ `
+ previewWindow = document.querySelector('[is="alchemy-preview-window"]')
+ reloadButton = document.getElementById("reload_preview_button")
+ })
+
+ afterEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ describe("refresh with timeout", () => {
+ let growlSpy
+
+ beforeEach(() => {
+ setupLanguage()
+ Alchemy.translations["Preview failed to load"] =
+ "Preview failed to load. Please try again."
+ vi.useFakeTimers()
+ growlSpy = vi.spyOn(growler, "growl")
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it("starts spinner when refresh is called", () => {
+ const originalContent = reloadButton.innerHTML
+
+ previewWindow.refresh()
+
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+ expect(reloadButton.innerHTML).not.toBe(originalContent)
+ })
+
+ it("stops spinner when iframe loads successfully", async () => {
+ const originalContent = reloadButton.innerHTML
+
+ previewWindow.refresh()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Simulate iframe load event
+ previewWindow.dispatchEvent(new Event("load"))
+
+ expect(reloadButton.innerHTML).toBe(originalContent)
+ expect(reloadButton.innerHTML).not.toContain("alchemy-spinner")
+ })
+
+ it("stops spinner after 5s timeout if iframe doesn't load", () => {
+ const originalContent = reloadButton.innerHTML
+
+ previewWindow.refresh()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Fast-forward time by 5 seconds
+ vi.advanceTimersByTime(5000)
+
+ expect(reloadButton.innerHTML).toBe(originalContent)
+ expect(reloadButton.innerHTML).not.toContain("alchemy-spinner")
+ expect(growlSpy).toHaveBeenCalledWith(
+ "Preview failed to load. Please try again.",
+ "warning"
+ )
+ })
+
+ it("clears timeout when iframe loads before timeout expires", () => {
+ const originalContent = reloadButton.innerHTML
+
+ previewWindow.refresh()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Fast-forward time by 2 seconds (less than timeout)
+ vi.advanceTimersByTime(2000)
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Simulate iframe load event
+ previewWindow.dispatchEvent(new Event("load"))
+ expect(reloadButton.innerHTML).toBe(originalContent)
+
+ // Fast-forward remaining time - spinner should stay stopped
+ vi.advanceTimersByTime(3000)
+ expect(reloadButton.innerHTML).toBe(originalContent)
+ expect(reloadButton.innerHTML).not.toContain("alchemy-spinner")
+ // Growl should NOT be called since iframe loaded successfully
+ expect(growlSpy).not.toHaveBeenCalled()
+ })
+
+ it("clears previous timeout when refresh is called again", () => {
+ previewWindow.refresh()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Fast-forward 2 seconds
+ vi.advanceTimersByTime(2000)
+
+ // Call refresh again before timeout
+ previewWindow.refresh()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Fast-forward 4 seconds (total 6s from first refresh, but only 4s from second)
+ vi.advanceTimersByTime(4000)
+
+ // Spinner should still be showing because new 5s timeout hasn't expired
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Fast-forward 1 more second to complete new timeout
+ vi.advanceTimersByTime(1000)
+ expect(reloadButton.innerHTML).not.toContain("alchemy-spinner")
+ })
+
+ it("allows reload button to be clicked again after timeout", () => {
+ const originalContent = reloadButton.innerHTML
+
+ // First refresh
+ previewWindow.refresh()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Wait for timeout
+ vi.advanceTimersByTime(5000)
+ expect(reloadButton.innerHTML).toBe(originalContent)
+
+ // Click reload button again
+ reloadButton.click()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Verify it works again
+ vi.advanceTimersByTime(5000)
+ expect(reloadButton.innerHTML).toBe(originalContent)
+ })
+
+ it("stops spinner when receiving previewReady message from iframe", () => {
+ const originalContent = reloadButton.innerHTML
+
+ previewWindow.refresh()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Fast-forward 2 seconds (less than timeout)
+ vi.advanceTimersByTime(2000)
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Simulate postMessage from iframe
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { message: "Alchemy.previewReady" }
+ })
+ )
+
+ expect(reloadButton.innerHTML).toBe(originalContent)
+ expect(reloadButton.innerHTML).not.toContain("alchemy-spinner")
+
+ // Fast-forward remaining time - spinner should stay stopped and no growl
+ vi.advanceTimersByTime(3000)
+ expect(reloadButton.innerHTML).toBe(originalContent)
+ expect(growlSpy).not.toHaveBeenCalled()
+ })
+
+ it("prefers postMessage over timeout when both occur", () => {
+ const originalContent = reloadButton.innerHTML
+
+ previewWindow.refresh()
+ expect(reloadButton.innerHTML).toContain("alchemy-spinner")
+
+ // Simulate postMessage from iframe before timeout
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { message: "Alchemy.previewReady" }
+ })
+ )
+
+ expect(reloadButton.innerHTML).toBe(originalContent)
+
+ // Fast-forward past timeout - nothing should happen, already stopped
+ vi.advanceTimersByTime(5000)
+ expect(reloadButton.innerHTML).toBe(originalContent)
+ expect(growlSpy).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/spec/javascript/alchemy_admin/setup.js b/spec/javascript/alchemy_admin/setup.js
index cf6d53ef61..3ef698fd9a 100644
--- a/spec/javascript/alchemy_admin/setup.js
+++ b/spec/javascript/alchemy_admin/setup.js
@@ -100,6 +100,10 @@ Object.defineProperty(window, "matchMedia", {
}))
})
+// Mock keymaster (key) for keyboard shortcuts
+globalThis.key = vi.fn()
+globalThis.key.unbind = vi.fn()
+
// Set up global Alchemy object that many tests expect
globalThis.Alchemy = {
translations: {},