Merge pull request #2500 from heygen-com/task2-iframe-load-resync

fix(sdk): attachSync re-syncs the override snapshot on iframe load
This commit is contained in:
Vance Ingalls
2026-07-15 18:11:13 -07:00
committed by GitHub
3 changed files with 67 additions and 9 deletions
+1 -2
View File
@@ -121,7 +121,7 @@ interface PreviewAdapter {
</ParamField>
<ParamField path="attachSync" type="(comp: Composition) => () => void">
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 `<script>` tag's content doesn't re-execute it, and re-running GSAP setup from scratch would conflict with running timeline state. Every other patch kind (style, text, attribute, timing, hold, element add/remove, stylesheet, variable value, variable declaration) mirrors as-is.
@@ -335,4 +335,3 @@ preview.commitPreview();
Building a visual editor canvas with the iframe preview adapter and hit-testing.
</Card>
</CardGroup>
@@ -252,3 +252,51 @@ window.__timelines = { t: tl };</script>
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");
});
});
+18 -7
View File
@@ -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;
};