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
5 changes: 5 additions & 0 deletions .changeset/teaser-delay-reschedule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perspective-ai/sdk": patch
---

Fix float teaser delay from dashboard embed settings being ignored. Script-tag embeds armed the welcome sequence at mount with the default 3s delay before the async `/embed/config` fetch resolved, and never rescheduled when the API config arrived with a custom `embedSettings.teaser.delay`. The teaser is now deferred until the config resolves (it can no longer fire early while the fetch is in flight) and then fires at the resolved delay measured from mount, crediting the time already waited. Delay changes on a still-pending teaser reschedule it against the same anchor; a delivered or cancelled teaser is never re-armed by a late config refresh.
19 changes: 18 additions & 1 deletion packages/sdk/src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,19 +874,36 @@ describe("browser entry", () => {
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
});

it("parses data-perspective-teaser-delay as teaser delay in ms", () => {
it("parses data-perspective-teaser-delay as teaser delay in ms", async () => {
vi.useFakeTimers();
document.body.innerHTML = `
<div data-perspective-float="test-id"
data-perspective-teaser-delay="500"></div>
`;
autoInit();

// The teaser is deferred until the config fetch resolves
await flushConfigFetch();
await flushConfigFetch();

vi.advanceTimersByTime(400);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

vi.advanceTimersByTime(100);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
});

it("does not arm the teaser before the config fetch resolves", () => {
vi.useFakeTimers();
// Never-resolving fetch: the config stays in flight for the whole test
vi.stubGlobal("fetch", vi.fn().mockReturnValue(new Promise(() => {})));
document.body.innerHTML = `
<div data-perspective-float="test-id-pending"></div>
`;
autoInit();

vi.advanceTimersByTime(10000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();
});
});
});
5 changes: 5 additions & 0 deletions packages/sdk/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,11 @@ function autoInit(): void {
...(launcherConfig && { launcher: launcherConfig }),
...(teaserConfig && { teaser: teaserConfig }),
_apiConfig: DEFAULT_THEME,
// Defer the teaser until the fetched config below arrives — its
// embedSettings.teaser.delay must not be preempted by a shorter
// delay armed at mount. fetchConfig never rejects (it resolves with
// DEFAULT_THEME on error/timeout), so the deferral always ends.
_apiConfigPending: true,
} as InternalEmbedConfig);

fetchConfig(researchId).then((config) => {
Expand Down
231 changes: 231 additions & 0 deletions packages/sdk/src/float.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,237 @@ describe("createFloatBubble", () => {

handle.unmount();
});

it("API embedSettings.teaser.delay reschedules a pending teaser armed at mount", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
});

// Async API config arrives after 1s with a longer delay from the
// dashboard — the sequence armed at mount with the 3s default must
// be rescheduled instead of firing at 3s.
vi.advanceTimersByTime(1000);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handle.update as any)({
_apiConfig: {
primaryColor: "#7c3aed",
textColor: "#ffffff",
darkPrimaryColor: "#a78bfa",
darkTextColor: "#ffffff",
embedSettings: { teaser: { delay: 30000 } },
},
});

// Would have fired at 3s under the default delay
vi.advanceTimersByTime(10000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

// Fires 30s after mount: 1s already elapsed + 29s remaining
vi.advanceTimersByTime(19000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

handle.unmount();
});

it("_apiConfigPending defers the teaser until the API config arrives, crediting the wait", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
_apiConfigPending: true,
});

// Nothing fires while the config fetch is in flight — not even the
// 3s default.
vi.advanceTimersByTime(10000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handle.update as any)({
_apiConfig: {
primaryColor: "#7c3aed",
textColor: "#ffffff",
darkPrimaryColor: "#a78bfa",
darkTextColor: "#ffffff",
embedSettings: { teaser: { delay: 30000 } },
},
});

// Fires 30s after mount: 10s already elapsed + 20s remaining
vi.advanceTimersByTime(19000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

vi.advanceTimersByTime(1000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

handle.unmount();
});

it("a config resolving past the default delay shows the deferred teaser immediately", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
_apiConfigPending: true,
});

// Config resolves at 5s with no teaser settings — the default 3s delay
// has fully elapsed, so the teaser shows right away.
vi.advanceTimersByTime(5000);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handle.update as any)({
_apiConfig: {
primaryColor: "#7c3aed",
textColor: "#ffffff",
darkPrimaryColor: "#a78bfa",
darkTextColor: "#ffffff",
},
});

vi.advanceTimersByTime(0);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

handle.unmount();
});

it("a second delay change credits elapsed time from the first arm, not the last reschedule", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
});

// First reschedule at 1s (default 3s -> 30s)
vi.advanceTimersByTime(1000);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handle.update as any)({
_apiConfig: {
primaryColor: "#7c3aed",
textColor: "#ffffff",
darkPrimaryColor: "#a78bfa",
darkTextColor: "#ffffff",
embedSettings: { teaser: { delay: 30000 } },
},
});

// Second reschedule at 2s (30s -> 10s): must fire 10s after mount,
// i.e. 8s from now — not 10s from this reschedule.
vi.advanceTimersByTime(1000);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handle.update as any)({
_apiConfig: {
primaryColor: "#7c3aed",
textColor: "#ffffff",
darkPrimaryColor: "#a78bfa",
darkTextColor: "#ffffff",
embedSettings: { teaser: { delay: 10000 } },
},
});

vi.advanceTimersByTime(7000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

vi.advanceTimersByTime(1000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

handle.unmount();
});

it("re-enabling the teaser measures the delay from the re-enable, not the original arm", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
});

// Disable at 1s, re-enable at 2s with a 5s delay
vi.advanceTimersByTime(1000);
handle.update({ teaser: { enabled: false } });
vi.advanceTimersByTime(1000);
handle.update({ teaser: { enabled: true, delay: 5000 } });

// 5s measured from the re-enable: nothing at +4s, teaser at +5s
vi.advanceTimersByTime(4000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

vi.advanceTimersByTime(1000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

handle.unmount();
});

it("an _apiConfig refresh with an unchanged delay does not reset a visible teaser", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
teaser: { delay: 1000 },
});

vi.advanceTimersByTime(2000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handle.update as any)({
_apiConfig: {
primaryColor: "#7c3aed",
textColor: "#ffffff",
darkPrimaryColor: "#a78bfa",
darkTextColor: "#ffffff",
embedSettings: { teaser: { delay: 1000 } },
},
});

expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();
expect(
document.querySelectorAll(".perspective-float-teaser")
).toHaveLength(1);

handle.unmount();
});

it("a delay change after the teaser has shown does not re-arm it", () => {
vi.useFakeTimers();

const handle = createFloatBubble({
researchId: "test-research-id",
welcomeMessage: "Hello!",
teaser: { delay: 1000 },
});

vi.advanceTimersByTime(2000);
expect(document.querySelector(".perspective-float-teaser")).toBeTruthy();

// Open (dismisses the teaser), then close — a late config refresh with
// a different delay must not bring the teaser back.
handle.open();
handle.close();
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handle.update as any)({
_apiConfig: {
primaryColor: "#7c3aed",
textColor: "#ffffff",
darkPrimaryColor: "#a78bfa",
darkTextColor: "#ffffff",
embedSettings: { teaser: { delay: 5000 } },
},
});

vi.advanceTimersByTime(10000);
expect(document.querySelector(".perspective-float-teaser")).toBeFalsy();

handle.unmount();
});
});

describe("launcher config", () => {
Expand Down
53 changes: 51 additions & 2 deletions packages/sdk/src/float.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,13 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
let audioCtx: AudioContext | null = null;
let welcomeSequenceStarted = false;
let welcomeTimers: number[] = [];
// Delay the pending sequence was armed with, and when the current waiting
// period began (mount, or a re-enable after a disable). The anchor survives
// deferred arms and reschedules: the teaser always fires at the resolved
// delay measured from the anchor, crediting time already waited.
let armedTeaserDelay: number | null = null;
let delayAnchorMs: number | null = null;
let teaserDelivered = false;
const persistOpenState = (open: boolean) => {
setPersistedOpenState({
researchId,
Expand Down Expand Up @@ -453,7 +460,21 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
// (e.g. async API config arriving) can still kick off the sequence.
if (!teaserConfig.enabled) return;

// Anchor before any deferral so the eventual arm credits time already
// waited; ??= keeps the original anchor across reschedules.
delayAnchorMs ??= Date.now();

// While the config API fetch is in flight, don't arm with a delay it may
// override — a slow fetch must not set off the teaser early. update()
// clears the flag when the fetched config arrives and re-enters here.
if (currentConfig._apiConfigPending) return;

welcomeSequenceStarted = true;
armedTeaserDelay = teaserConfig.delay;
const timerDelay = Math.max(
0,
teaserConfig.delay - (Date.now() - delayAnchorMs)
);

if (teaserConfig.sound) {
// The chime tracks the teaser: it announces the bubble 1s ahead of it
Expand All @@ -463,15 +484,16 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
if (isOpen) return;
playChime();
},
Math.max(0, teaserConfig.delay - SOUND_LEAD_MS)
Math.max(0, timerDelay - SOUND_LEAD_MS)
);
welcomeTimers.push(soundTimer);
}

const teaserTimer = window.setTimeout(() => {
teaserDelivered = true;
if (isOpen) return;
renderTeaser(resolveWelcomeMessage(currentConfig));
}, teaserConfig.delay);
}, timerDelay);

welcomeTimers.push(teaserTimer);
};
Expand All @@ -485,8 +507,29 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
clearWelcomeTimers();
removeTeaser();
welcomeSequenceStarted = false;
// Forget the arm tracking so a later re-enable starts a fresh sequence
// measured from the re-enable, not from the original anchor.
armedTeaserDelay = null;
delayAnchorMs = null;
teaserDelivered = false;
return;
}

// A delay change while the teaser is still pending (e.g. the API config
// updating a customer-configured delay) reschedules the timers; the arm
// credits time waited since the anchor. Never reschedule once the teaser
// has been delivered or the sequence was cancelled (e.g. by opening).
if (
welcomeSequenceStarted &&
!teaserDelivered &&
welcomeTimers.length > 0 &&
armedTeaserDelay !== null &&
teaserConfig.delay !== armedTeaserDelay
) {
clearWelcomeTimers();
welcomeSequenceStarted = false;
}

maybeStartWelcomeSequence(teaserConfig);
};

Expand Down Expand Up @@ -664,6 +707,12 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle {
const prevApiTeaser = currentConfig._apiConfig?.embedSettings?.teaser;
currentConfig = { ...currentConfig, ...options };

// The fetched config arriving ends the deferral of delay-sensitive
// behavior (see _apiConfigPending).
if (options._apiConfig) {
currentConfig = { ...currentConfig, _apiConfigPending: false };
}

// An _apiConfig refresh that omits teaser settings keeps the previous
// ones — a later payload must not silently undo an earlier API disable.
if (
Expand Down
Loading
Loading