Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/sdk/reference/adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -335,4 +335,3 @@ preview.commitPreview();
Building a visual editor canvas with the iframe preview adapter and hit-testing.
</Card>
</CardGroup>

48 changes: 48 additions & 0 deletions packages/sdk/src/adapters/iframe.sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
25 changes: 18 additions & 7 deletions packages/sdk/src/adapters/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -778,6 +788,7 @@ class IframePreviewAdapter implements PreviewAdapter {
});

const detach = (): void => {
this.iframe.removeEventListener("load", syncOverrides);
rawUnsubscribe();
if (this._syncDetach === detach) this._syncDetach = null;
};
Expand Down
Loading