fix(store): survive iframe/tag load races for direct-iframe embeds - #70
Conversation
Direct-iframe embeds lost the entire store payload (attribution, lead identity, journey id, partial fill) whenever the form iframe booted before the tag was listening: the tag deferred its message listener to DOMContentLoaded and never pushed proactively, so the form's one-shot SEND_DATA went unanswered. - attach the message listener immediately; nothing in the handler needs the DOM and the deferral silently dropped early SEND_DATA - push STORE_UPDATE once on store init (plus LEAD_DATA_UPDATE when lead data is cached) so iframes that asked before the tag loaded still get their payload; identify remains SEND_DATA-driven - add test/direct-iframe.html to reproduce the race with ?tagDelay=<ms> and ?noTag=1 (customer-embedded iframe, no tag involvement) Pairs with the forms-side SEND_DATA retry change; each side is safe against old deployed versions of the other. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
@greptile please check, embedding Surface Tag in head with no defer or async - will not cause any issue? |
|
No issues from a correctness standpoint. Here's why it's safe: Message listener —
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", pushInitialData);
} else {
pushInitialData();
}When loaded in The one thing to be aware of is render-blocking behavior — a plain |
The browser caches ../surface_tag.js across rebuilds, so delay scenarios could run a stale bundle. Append a timestamp query so every injection fetches the current build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AminHakem
left a comment
There was a problem hiding this comment.
Two blocking race-handling gaps remain. Inline comments include the reproduction evidence and suggested direction.
…covery The init push only replayed synchronous state: on a first visit there is no cached lead and the journey id is still resolving, so a one-shot form whose SEND_DATA was missed received STORE_UPDATE with null lead/journey data and nothing ever followed. - a Surface iframe already in the DOM now implies a missed request: the push runs identifyLead and follows with LEAD_DATA_UPDATE, same as an answered SEND_DATA; pages without a Surface iframe stay quiet - the journey-id setter re-pushes STORE_UPDATE when the id resolves so already-notified iframes get the pageview stitched - the immediate-push branch defers one task so index.ts exposes the store first; direct-iframe.html installs a setter trap and hooks notifyIframe on assignment, making the init push observable in the event monitor Verified with cleared storage + ?tagDelay=8000 against the deployed one-shot form: STORE_UPDATE (null journey) -> STORE_UPDATE (resolved journey) -> LEAD_DATA_UPDATE (lead + journey), all logged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same staleness the init path had: the route-change handler pushes STORE_UPDATE immediately, but a journey created or refreshed by that route change resolves after the push. Re-push from the setter when the id actually changes, mirroring the constructor path. Found by multi-agent review of the branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Follow-up from a multi-agent adversarial review over both branches: one additional fix landed in 40e05e3 — the SPA route-change handler pushed STORE_UPDATE before a journey created/refreshed by that navigation resolved, so iframes kept a stale journey id until the next push. The journey setter now re-pushes when the id actually changes, mirroring the init path added in 18dd6a3. The review also confirmed the two issues Amin raised (both fixed in 18dd6a3) and refuted several suspected regressions, including "pushInitialData should be gated off on Surface-owned origins" and "the LEAD_DATA_UPDATE cache double-read can send a null payload". |
Vitest+jsdom unit tests locking in the postMessage-race fixes on this branch: immediate message-listener attach, iframe-gated boot push (no push/identify without a Surface iframe), cached-lead path, journey-id re-push on init and route change, and the sender/domain protocol shape. CI runs typecheck, the test suite, and a bundle-freshness check (rebuild + git diff) on every PR to main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
When a Surface form is embedded as a direct iframe (customer pastes the iframe, tag loads separately), the handshake was one-shot and answer-only:
messagelistener until DOMContentLoaded and never pushed data proactively, so a fast-booting iframe'sSEND_DATAwas silently dropped.Changes
src/store/message-listener.ts— attach thewindowmessage listener immediately. Nothing in the handler needs the DOM (iframes are queried at message time); the DOMContentLoaded deferral was pure message loss.src/store/store.ts— pushSTORE_UPDATEonce on store init (at DOM-ready when loaded in<head>, immediately otherwise), plusLEAD_DATA_UPDATEonly when cached lead data exists. Identify staysSEND_DATA-driven, so the tag still never creates a lead on pages where no form asked.test/direct-iframe.html(new) — reproduces the race: hard-coded iframe with a delayed, cache-busted tag loader (?tagDelay=<ms>,?noTag=1), SPA route-change button for the clobber scenario. The existing test pages create the iframe via the tag, so they can't reproduce this.Verification (browser, real deployed forms)
SEND_DATAat ~3s unanswered when the tag was injected at 4.5s.pushState→ route-changeSTORE_UPDATE.pnpm run typecheck+pnpm run buildclean; bundle artifacts rebuilt.Pairs with trysurface/surface_forms#4959 (forms-side: 500ms
SEND_DATApolling + wider no-tag fallback + prefill guard). Each side is independently safe against old deployed versions of the other; deploy the forms side first (instant), tag second (CDN-cached).🤖 Generated with Claude Code