Skip to content

Commit 26bb83e

Browse files
paragmoreclaude
andcommitted
feat(tag): drain a pre-load stub queue so the tag can be embedded async
Pages that load the tag with `async` can no longer rely on `window.SurfaceEmbed` existing when their inline init runs. The install snippet now ships a tiny stub that queues SurfaceEmbed constructions (capturing `data-question-id` at queue time, while `document.currentScript` still points at the constructing tag), zero-arg method calls, and SurfaceSetConsent answers onto `window.SurfaceTagQueue`; the tag drains and replays the queue once its real APIs are on `window`, then re-points kept stub references at the real embeds. Pages without the stub hit a single `Array.isArray` check — no behavior change for existing installations. `SurfaceEmbedOptions.questionId` is added so the drain can pass the captured question id through to the constructor. Manual test page: test/async-stub.html (async tag + immediate IIFE init + pre-load consent call, the smallest.ai pattern). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Kjq1CG5RwFMpeucoPJWML
1 parent 0dfbb41 commit 26bb83e

9 files changed

Lines changed: 394 additions & 3 deletions

File tree

‎src/embed/embed.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ export class SurfaceEmbed {
9898
this.log = createLogger("Surface Embed");
9999
this.store = (window as unknown as Record<string, unknown>).SurfaceTagStore as SurfaceStore;
100100
this.currentQuestionId =
101-
document.currentScript?.getAttribute("data-question-id") || null;
101+
options.questionId ||
102+
document.currentScript?.getAttribute("data-question-id") ||
103+
null;
102104

103105
SurfaceEmbed._instances.push(this);
104106

‎src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { SurfaceEmbed } from "./embed/embed";
1212
import { resolveOpenTriggersOnLoad } from "./open-triggers/open-triggers";
1313
import { initReview } from "./review/review";
1414
import { initializeSurfaceRuntimeConfig } from "./runtime-config";
15+
import { drainStubQueue } from "./stub-queue";
1516

1617
const scriptTag = document.currentScript as HTMLScriptElement;
1718
const runtimeConfig = initializeSurfaceRuntimeConfig(scriptTag);
@@ -47,3 +48,8 @@ void resolveOpenTriggersOnLoad(environmentId, runtimeConfig);
4748
// Surface CMS review bridge. Inert unless the page is loaded inside the CMS
4849
// review iframe (?surface_review= token) — adds no listeners otherwise.
4950
initReview();
51+
52+
// Async-loaded pages may have queued SurfaceEmbed / SurfaceSetConsent calls on
53+
// the install snippet's stub before this script arrived — replay them now that
54+
// the real APIs are on window.
55+
drainStubQueue();

‎src/stub-queue.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { drainStubQueue } from "./stub-queue";
3+
import { SurfaceEmbed } from "./embed/embed";
4+
import { getSurfaceConsent } from "./consent/consent";
5+
6+
vi.mock("./embed/embed", () => ({
7+
SurfaceEmbed: vi.fn(function (this: Record<string, unknown>) {
8+
this.showSurfaceForm = vi.fn();
9+
this.hideSurfaceForm = vi.fn();
10+
this.initializeEmbed = vi.fn();
11+
}),
12+
}));
13+
14+
const MockedSurfaceEmbed = vi.mocked(SurfaceEmbed);
15+
16+
const queueWindow = window as unknown as { SurfaceTagQueue?: unknown };
17+
18+
const embedEntry = (overrides: Record<string, unknown> = {}) => ({
19+
type: "embed",
20+
args: ["https://forms.withsurface.com/s/form-1", "slideover", "surface-form-button"],
21+
calls: [],
22+
...overrides,
23+
});
24+
25+
describe("drainStubQueue", () => {
26+
beforeEach(() => {
27+
MockedSurfaceEmbed.mockClear();
28+
delete queueWindow.SurfaceTagQueue;
29+
});
30+
31+
it("does nothing without a queued array", () => {
32+
drainStubQueue();
33+
queueWindow.SurfaceTagQueue = { push: () => {} };
34+
drainStubQueue();
35+
expect(MockedSurfaceEmbed).not.toHaveBeenCalled();
36+
});
37+
38+
it("constructs a real embed per queued entry, merging the captured question id", () => {
39+
queueWindow.SurfaceTagQueue = [
40+
embedEntry({ questionId: "q-123", args: ["https://x.com/s/f", "popup", "btn", { popupSize: "large" }] }),
41+
];
42+
43+
drainStubQueue();
44+
45+
expect(MockedSurfaceEmbed).toHaveBeenCalledWith("https://x.com/s/f", "popup", "btn", {
46+
popupSize: "large",
47+
questionId: "q-123",
48+
});
49+
});
50+
51+
it("replays queued method calls and re-points the stub instance at the real embed", () => {
52+
const stub: Record<string, () => void> = {};
53+
queueWindow.SurfaceTagQueue = [embedEntry({ calls: ["showSurfaceForm"], stub })];
54+
55+
drainStubQueue();
56+
57+
const real = MockedSurfaceEmbed.mock.instances[0] as unknown as Record<string, ReturnType<typeof vi.fn>>;
58+
expect(real.showSurfaceForm).toHaveBeenCalledTimes(1);
59+
60+
stub.hideSurfaceForm();
61+
expect(real.hideSurfaceForm).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it("applies a queued consent answer", () => {
65+
queueWindow.SurfaceTagQueue = [
66+
{ type: "consent", args: [{ adTracking: true, surfaceAnalytics: true }] },
67+
];
68+
69+
drainStubQueue();
70+
71+
expect(getSurfaceConsent()).toEqual({ adTracking: true, surfaceAnalytics: true });
72+
});
73+
74+
it("processes pushes made after the drain immediately", () => {
75+
queueWindow.SurfaceTagQueue = [];
76+
drainStubQueue();
77+
78+
(queueWindow.SurfaceTagQueue as { push: (e: unknown) => void }).push(embedEntry());
79+
expect(MockedSurfaceEmbed).toHaveBeenCalledTimes(1);
80+
});
81+
82+
it("skips malformed entries without breaking the rest", () => {
83+
vi.spyOn(console, "error").mockImplementation(() => {});
84+
queueWindow.SurfaceTagQueue = [null, { type: "embed" }, embedEntry()];
85+
86+
expect(() => drainStubQueue()).not.toThrow();
87+
expect(MockedSurfaceEmbed).toHaveBeenCalledTimes(2);
88+
});
89+
});

‎src/stub-queue.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { SurfaceEmbed } from "./embed/embed";
2+
import { setSurfaceConsent, type SurfaceConsent } from "./consent/consent";
3+
import type { EmbedTypeInput, SurfaceEmbedOptions } from "./types";
4+
5+
/**
6+
* Replays `SurfaceEmbed` / `SurfaceSetConsent` calls a page made before this
7+
* script finished loading. Lets the tag be embedded with `async` (it no longer
8+
* has to block the HTML parser just so `window.SurfaceEmbed` exists for later
9+
* inline scripts).
10+
*
11+
* The counterpart is a small inline stub the install snippet places BEFORE the
12+
* async script tag (source of truth for the generated snippet:
13+
* `surface_forms` `lib/shared/surfaceTagSnippet.ts` — keep the entry shape in
14+
* sync with `StubQueueEntry`):
15+
*
16+
* ```html
17+
* <script>
18+
* window.SurfaceTagQueue = window.SurfaceTagQueue || [];
19+
* window.SurfaceEmbed = window.SurfaceEmbed || function () {
20+
* var entry = { type: "embed", args: arguments, calls: [], stub: this,
21+
* questionId: document.currentScript && document.currentScript.getAttribute("data-question-id") };
22+
* var stub = this;
23+
* ["showSurfaceForm", "hideSurfaceForm", "initializeEmbed"].forEach(function (method) {
24+
* stub[method] = function () { entry.calls.push(method); };
25+
* });
26+
* window.SurfaceTagQueue.push(entry);
27+
* };
28+
* window.SurfaceSetConsent = window.SurfaceSetConsent || function (consent) {
29+
* window.SurfaceTagQueue.push({ type: "consent", args: [consent] });
30+
* };
31+
* </script>
32+
* ```
33+
*
34+
* The stub captures `data-question-id` from the constructing script tag at
35+
* queue time because `document.currentScript` is null by the time the drain
36+
* runs (input-trigger embeds route on it).
37+
*/
38+
39+
const RELAYED_METHODS = [
40+
"showSurfaceForm",
41+
"hideSurfaceForm",
42+
"initializeEmbed",
43+
] as const;
44+
45+
type RelayedMethod = (typeof RELAYED_METHODS)[number];
46+
47+
interface StubQueueEntry {
48+
type: "embed" | "consent";
49+
args: ArrayLike<unknown>;
50+
questionId?: string | null;
51+
calls?: RelayedMethod[];
52+
stub?: Record<string, unknown>;
53+
}
54+
55+
export function drainStubQueue(): void {
56+
const w = window as unknown as { SurfaceTagQueue?: unknown };
57+
if (!Array.isArray(w.SurfaceTagQueue)) return;
58+
59+
const entries = w.SurfaceTagQueue as StubQueueEntry[];
60+
// Swap the array out first: code that captured the stub function before the
61+
// tag loaded still pushes here, and those pushes must process immediately
62+
// instead of landing in a queue nobody reads again. A non-array queue also
63+
// makes a double-loaded tag skip draining twice.
64+
w.SurfaceTagQueue = { push: processEntry };
65+
entries.forEach(processEntry);
66+
}
67+
68+
function processEntry(entry: StubQueueEntry): void {
69+
try {
70+
if (entry?.type === "consent") {
71+
setSurfaceConsent((entry.args?.[0] ?? {}) as Partial<SurfaceConsent>);
72+
return;
73+
}
74+
if (entry?.type !== "embed") return;
75+
76+
const [src, embedType, targetClass, options] = Array.from(entry.args ?? []);
77+
const embed = new SurfaceEmbed(
78+
src as string,
79+
embedType as EmbedTypeInput,
80+
targetClass as string,
81+
{
82+
...((options as SurfaceEmbedOptions) ?? {}),
83+
...(entry.questionId ? { questionId: entry.questionId } : {}),
84+
}
85+
);
86+
87+
for (const method of entry.calls ?? []) {
88+
if (RELAYED_METHODS.includes(method)) embed[method]();
89+
}
90+
91+
// The page may be holding the stub instance (`const c = new SurfaceEmbed(...)`)
92+
// — re-point its methods at the real embed so later calls work.
93+
if (entry.stub && typeof entry.stub === "object") {
94+
for (const method of RELAYED_METHODS) {
95+
entry.stub[method] = () => embed[method]();
96+
}
97+
}
98+
} catch (error) {
99+
// A malformed entry must never break the host page or later entries.
100+
console.error("Surface Tag: failed to replay queued call", error);
101+
}
102+
}

‎src/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ export interface SurfaceEmbedOptions {
5959
enforceIDSelector?: boolean;
6060
widgetStyles?: Partial<WidgetStyles>;
6161
prefillData?: Record<string, string>;
62+
/**
63+
* Overrides the `data-question-id` normally read from the constructing
64+
* script tag — needed when construction is replayed from the async stub
65+
* queue, where `document.currentScript` is already null.
66+
*/
67+
questionId?: string;
6268
}
6369

6470
export interface ExternalFormProps {

‎surface_embed_v1.js‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1947,7 +1947,7 @@
19471947
this.src = new URL(src);
19481948
this.log = createLogger("Surface Embed");
19491949
this.store = window.SurfaceTagStore;
1950-
this.currentQuestionId = document.currentScript?.getAttribute("data-question-id") || null;
1950+
this.currentQuestionId = options.questionId || document.currentScript?.getAttribute("data-question-id") || null;
19511951
_SurfaceEmbed._instances.push(this);
19521952
if (this._isFormPreviewMode()) {
19531953
this.log.info({ message: "Form is in preview mode" });
@@ -2494,6 +2494,49 @@
24942494
window.addEventListener("message", onMessage);
24952495
}
24962496

2497+
// src/stub-queue.ts
2498+
var RELAYED_METHODS = [
2499+
"showSurfaceForm",
2500+
"hideSurfaceForm",
2501+
"initializeEmbed"
2502+
];
2503+
function drainStubQueue() {
2504+
const w3 = window;
2505+
if (!Array.isArray(w3.SurfaceTagQueue)) return;
2506+
const entries = w3.SurfaceTagQueue;
2507+
w3.SurfaceTagQueue = { push: processEntry };
2508+
entries.forEach(processEntry);
2509+
}
2510+
function processEntry(entry) {
2511+
try {
2512+
if (entry?.type === "consent") {
2513+
setSurfaceConsent(entry.args?.[0] ?? {});
2514+
return;
2515+
}
2516+
if (entry?.type !== "embed") return;
2517+
const [src, embedType, targetClass, options] = Array.from(entry.args ?? []);
2518+
const embed = new SurfaceEmbed(
2519+
src,
2520+
embedType,
2521+
targetClass,
2522+
{
2523+
...options ?? {},
2524+
...entry.questionId ? { questionId: entry.questionId } : {}
2525+
}
2526+
);
2527+
for (const method of entry.calls ?? []) {
2528+
if (RELAYED_METHODS.includes(method)) embed[method]();
2529+
}
2530+
if (entry.stub && typeof entry.stub === "object") {
2531+
for (const method of RELAYED_METHODS) {
2532+
entry.stub[method] = () => embed[method]();
2533+
}
2534+
}
2535+
} catch (error) {
2536+
console.error("Surface Tag: failed to replay queued call", error);
2537+
}
2538+
}
2539+
24972540
// src/index.ts
24982541
var scriptTag = document.currentScript;
24992542
var runtimeConfig2 = initializeSurfaceRuntimeConfig(scriptTag);
@@ -2515,4 +2558,5 @@
25152558
});
25162559
void resolveOpenTriggersOnLoad(environmentId2, runtimeConfig2);
25172560
initReview();
2561+
drainStubQueue();
25182562
})();

‎surface_tag.js‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1947,7 +1947,7 @@
19471947
this.src = new URL(src);
19481948
this.log = createLogger("Surface Embed");
19491949
this.store = window.SurfaceTagStore;
1950-
this.currentQuestionId = document.currentScript?.getAttribute("data-question-id") || null;
1950+
this.currentQuestionId = options.questionId || document.currentScript?.getAttribute("data-question-id") || null;
19511951
_SurfaceEmbed._instances.push(this);
19521952
if (this._isFormPreviewMode()) {
19531953
this.log.info({ message: "Form is in preview mode" });
@@ -2494,6 +2494,49 @@
24942494
window.addEventListener("message", onMessage);
24952495
}
24962496

2497+
// src/stub-queue.ts
2498+
var RELAYED_METHODS = [
2499+
"showSurfaceForm",
2500+
"hideSurfaceForm",
2501+
"initializeEmbed"
2502+
];
2503+
function drainStubQueue() {
2504+
const w3 = window;
2505+
if (!Array.isArray(w3.SurfaceTagQueue)) return;
2506+
const entries = w3.SurfaceTagQueue;
2507+
w3.SurfaceTagQueue = { push: processEntry };
2508+
entries.forEach(processEntry);
2509+
}
2510+
function processEntry(entry) {
2511+
try {
2512+
if (entry?.type === "consent") {
2513+
setSurfaceConsent(entry.args?.[0] ?? {});
2514+
return;
2515+
}
2516+
if (entry?.type !== "embed") return;
2517+
const [src, embedType, targetClass, options] = Array.from(entry.args ?? []);
2518+
const embed = new SurfaceEmbed(
2519+
src,
2520+
embedType,
2521+
targetClass,
2522+
{
2523+
...options ?? {},
2524+
...entry.questionId ? { questionId: entry.questionId } : {}
2525+
}
2526+
);
2527+
for (const method of entry.calls ?? []) {
2528+
if (RELAYED_METHODS.includes(method)) embed[method]();
2529+
}
2530+
if (entry.stub && typeof entry.stub === "object") {
2531+
for (const method of RELAYED_METHODS) {
2532+
entry.stub[method] = () => embed[method]();
2533+
}
2534+
}
2535+
} catch (error) {
2536+
console.error("Surface Tag: failed to replay queued call", error);
2537+
}
2538+
}
2539+
24972540
// src/index.ts
24982541
var scriptTag = document.currentScript;
24992542
var runtimeConfig2 = initializeSurfaceRuntimeConfig(scriptTag);
@@ -2515,4 +2558,5 @@
25152558
});
25162559
void resolveOpenTriggersOnLoad(environmentId2, runtimeConfig2);
25172560
initReview();
2561+
drainStubQueue();
25182562
})();

0 commit comments

Comments
 (0)