From 4682da14f19061aeac25b723e1cbb6a98f5d86ad Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 15 Jul 2026 14:50:41 -0700 Subject: [PATCH] fix(sdk): attachSync re-syncs the override snapshot on iframe load --- docs/sdk/reference/adapters.mdx | 3 +- packages/sdk/src/adapters/iframe.sync.test.ts | 48 +++++++++++++++++++ packages/sdk/src/adapters/iframe.ts | 25 +++++++--- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/docs/sdk/reference/adapters.mdx b/docs/sdk/reference/adapters.mdx index cc28f116b6..9ec03b9853 100644 --- a/docs/sdk/reference/adapters.mdx +++ b/docs/sdk/reference/adapters.mdx @@ -121,7 +121,7 @@ interface PreviewAdapter { - Mirrors a composition's edits onto the adapter's own live document: an immediate full sync of the composition's current overrides, then a subscription that replays every future `patch` event — including undo/redo, since both fire through the same event with forward or inverse patches. Calling `attachSync` again while already attached detaches the previous subscription first. Returns an unsubscribe function. + Mirrors a composition's edits onto the adapter's own live document: an immediate full sync of the composition's current overrides, then a subscription that replays every future `patch` event — including undo/redo, since both fire through the same event with forward or inverse patches. Calling `attachSync` again while already attached detaches the previous subscription first. The full-override sync also re-runs on every iframe `load`, so a `srcdoc` navigation that races the attach (or drops patches committed during the load window) converges once the new document arrives. Returns an unsubscribe function. Script-tag patches (`/script/gsap` and any future `/script/*` path) are never mirrored — rewriting a live ` expect(liveTitle.getAttribute("data-end")).toBe("3"); }); }); + +describe("IframePreviewAdapter.attachSync — iframe load re-sync", () => { + it("re-applies the override snapshot when the iframe fires `load`", async () => { + const iframe = mountIframe(BASE_HTML); + const comp = await openComposition(BASE_HTML); + const adapter = createIframePreviewAdapter(iframe); + adapter.attachSync(comp); + comp.setStyle("hf-title", { color: "#f00" }); + + // Simulate a navigation: replace the document with a fresh base (the + // mirrored edit is gone), then fire the load event a real srcdoc + // assignment would produce. + const midDoc = iframe.contentDocument; + if (!midDoc) throw new Error("iframe has no document"); + midDoc.open(); + midDoc.write(BASE_HTML); + midDoc.close(); + iframe.dispatchEvent(new Event("load")); + + const liveTitle = iframe.contentDocument?.querySelector( + '[data-hf-id="hf-title"]', + ) as HTMLElement; + expect(liveTitle.style.getPropertyValue("color")).toBe("#f00"); + }); + + it("stops re-syncing on load after detach", async () => { + const iframe = mountIframe(BASE_HTML); + const comp = await openComposition(BASE_HTML); + const adapter = createIframePreviewAdapter(iframe); + const detach = adapter.attachSync(comp); + comp.setStyle("hf-title", { color: "#f00" }); + detach(); + + const midDoc = iframe.contentDocument; + if (!midDoc) throw new Error("iframe has no document"); + midDoc.open(); + midDoc.write(BASE_HTML); + midDoc.close(); + iframe.dispatchEvent(new Event("load")); + + const liveTitle = iframe.contentDocument?.querySelector( + '[data-hf-id="hf-title"]', + ) as HTMLElement; + // BASE_HTML's authored inline color remains because detach must not + // re-apply comp's #f00 override on the load event. + expect(liveTitle.style.getPropertyValue("color")).toBe("#fff"); + }); +}); diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts index e83736b7ca..1cdb0b6c3e 100644 --- a/packages/sdk/src/adapters/iframe.ts +++ b/packages/sdk/src/adapters/iframe.ts @@ -752,17 +752,27 @@ class IframePreviewAdapter implements PreviewAdapter { attachSync(comp: Composition): () => void { this._syncDetach?.(); - const doc = this.iframe.contentDocument; - if (doc) { + const syncOverrides = (): void => { + const doc = this.iframe.contentDocument; + if (!doc) return; try { applyOverrideSet({ document: doc, wrapped: false, stamped: "" }, comp.getOverrides()); } catch (err) { - // Don't let a bad initial snapshot prevent the ongoing subscription - // below from attaching — future patches should still mirror even if - // this composition's current overrides couldn't be applied. - console.warn("[hyperframes] attachSync: initial override sync failed:", err); + // Don't let a bad snapshot prevent the ongoing subscription below + // from attaching — future patches should still mirror even if this + // composition's current overrides couldn't be applied. + console.warn("[hyperframes] attachSync: override sync failed:", err); } - } + }; + + // Immediate snapshot for the current document… + syncOverrides(); + // …and again on every iframe `load`: assigning srcdoc/src is an ASYNC + // navigation, so an attach in the same tick snapshots the OUTGOING + // document, and patches committed during the load window mirror into it + // and die with it. Re-syncing on load converges the new document with + // the composition state regardless of attach/navigation ordering. + this.iframe.addEventListener("load", syncOverrides); const rawUnsubscribe = comp.on("patch", ({ patches }) => { const liveDoc = this.iframe.contentDocument; @@ -778,6 +788,7 @@ class IframePreviewAdapter implements PreviewAdapter { }); const detach = (): void => { + this.iframe.removeEventListener("load", syncOverrides); rawUnsubscribe(); if (this._syncDetach === detach) this._syncDetach = null; };