From 7c34250bd8a0cf34406f95cb84d076140b6031cb Mon Sep 17 00:00:00 2001 From: Levi Rivkin Date: Thu, 9 Jul 2026 21:43:09 +0000 Subject: [PATCH 1/2] Fix float teaser delay from API embed settings being ignored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The welcome sequence was armed at mount with whatever teaser delay was known at that moment — for script-tag embeds that's the 3s default, since the async /embed/config fetch hasn't resolved yet. When the API config later arrived via update(), maybeStartWelcomeSequence() bailed out because the sequence was already marked as started, so a dashboard teaser delay (e.g. 30000ms) never took effect and the teaser always appeared after ~3s. syncTeaserState() now reschedules the pending timers when the resolved delay changes, crediting the time already waited so the teaser fires at the configured delay relative to mount. Once the teaser has been delivered or the sequence cancelled (e.g. the float was opened), a late config refresh no longer re-arms it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U6W4LcRde56iDNCwCMj76Q --- .changeset/teaser-delay-reschedule.md | 5 ++ packages/sdk/src/float.test.ts | 100 ++++++++++++++++++++++++++ packages/sdk/src/float.ts | 39 +++++++++- 3 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 .changeset/teaser-delay-reschedule.md diff --git a/.changeset/teaser-delay-reschedule.md b/.changeset/teaser-delay-reschedule.md new file mode 100644 index 0000000..a309bde --- /dev/null +++ b/.changeset/teaser-delay-reschedule.md @@ -0,0 +1,5 @@ +--- +"@perspective-ai/sdk": patch +--- + +Fix float teaser delay from dashboard embed settings being ignored: the welcome sequence armed at mount with the default 3s delay and was never rescheduled when the API config arrived with a custom `embedSettings.teaser.delay`. A delay change while the teaser is still pending now reschedules the timers, crediting the time already waited. diff --git a/packages/sdk/src/float.test.ts b/packages/sdk/src/float.test.ts index e170e27..d87c019 100644 --- a/packages/sdk/src/float.test.ts +++ b/packages/sdk/src/float.test.ts @@ -885,6 +885,106 @@ 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("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", () => { diff --git a/packages/sdk/src/float.ts b/packages/sdk/src/float.ts index 7dde17a..5b2a410 100644 --- a/packages/sdk/src/float.ts +++ b/packages/sdk/src/float.ts @@ -319,6 +319,11 @@ 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 — so a config change + // (e.g. the async API config arriving after mount) can reschedule it. + let armedTeaserDelay: number | null = null; + let armedAtMs = 0; + let teaserDelivered = false; const persistOpenState = (open: boolean) => { setPersistedOpenState({ researchId, @@ -445,7 +450,10 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { }; const maybeStartWelcomeSequence = ( - teaserConfig = resolveTeaserConfig(currentConfig) + teaserConfig = resolveTeaserConfig(currentConfig), + // Actual timer duration; differs from teaserConfig.delay when a + // reschedule credits time already waited. + timerDelay = teaserConfig.delay ) => { if (welcomeSequenceStarted || isOpen) return; @@ -454,6 +462,8 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { if (!teaserConfig.enabled) return; welcomeSequenceStarted = true; + armedTeaserDelay = teaserConfig.delay; + armedAtMs = Date.now(); if (teaserConfig.sound) { // The chime tracks the teaser: it announces the bubble 1s ahead of it @@ -463,15 +473,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); }; @@ -487,6 +498,28 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { welcomeSequenceStarted = false; return; } + + // A delay change while the teaser is still pending (typically the API + // config arriving after mount armed the default) reschedules the timers, + // crediting the time already waited. 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; + const elapsed = Date.now() - armedAtMs; + maybeStartWelcomeSequence( + teaserConfig, + Math.max(0, teaserConfig.delay - elapsed) + ); + return; + } + maybeStartWelcomeSequence(teaserConfig); }; From a3e30e2548cb6c89ff3e8d34ba488a1aa0f6bfa7 Mon Sep 17 00:00:00 2001 From: Levi Rivkin Date: Thu, 9 Jul 2026 21:57:27 +0000 Subject: [PATCH 2/2] Defer teaser arming until the API config resolves Instead of arming the welcome sequence at mount with the default delay and rescheduling when the API config arrives, script-tag embeds now defer arming entirely while the config fetch is in flight (internal _apiConfigPending flag, cleared by update({_apiConfig})). This removes any chance of the teaser firing early on a slow fetch; fetchEmbedConfig never rejects (it resolves with the default theme on error/timeout), so the deferral always ends. The delay is measured from a persistent anchor set when the sequence first could have started (mount, or re-enable after a disable), so the eventual arm credits the time already waited, and repeated delay changes on a still-pending teaser reschedule against the same anchor rather than the latest reschedule. Addresses both Copilot review comments on the previous approach. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U6W4LcRde56iDNCwCMj76Q --- .changeset/teaser-delay-reschedule.md | 2 +- packages/sdk/src/browser.test.ts | 19 +++- packages/sdk/src/browser.ts | 5 + packages/sdk/src/float.test.ts | 131 ++++++++++++++++++++++++++ packages/sdk/src/float.ts | 50 ++++++---- packages/sdk/src/types.ts | 10 +- 6 files changed, 197 insertions(+), 20 deletions(-) diff --git a/.changeset/teaser-delay-reschedule.md b/.changeset/teaser-delay-reschedule.md index a309bde..dd94e9a 100644 --- a/.changeset/teaser-delay-reschedule.md +++ b/.changeset/teaser-delay-reschedule.md @@ -2,4 +2,4 @@ "@perspective-ai/sdk": patch --- -Fix float teaser delay from dashboard embed settings being ignored: the welcome sequence armed at mount with the default 3s delay and was never rescheduled when the API config arrived with a custom `embedSettings.teaser.delay`. A delay change while the teaser is still pending now reschedules the timers, crediting the time already waited. +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. diff --git a/packages/sdk/src/browser.test.ts b/packages/sdk/src/browser.test.ts index f8542e1..9306e0e 100644 --- a/packages/sdk/src/browser.test.ts +++ b/packages/sdk/src/browser.test.ts @@ -874,7 +874,7 @@ 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 = `
{ `; 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 = ` +
+ `; + autoInit(); + + vi.advanceTimersByTime(10000); + expect(document.querySelector(".perspective-float-teaser")).toBeFalsy(); + }); }); }); diff --git a/packages/sdk/src/browser.ts b/packages/sdk/src/browser.ts index 280c0b9..4f5e516 100644 --- a/packages/sdk/src/browser.ts +++ b/packages/sdk/src/browser.ts @@ -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) => { diff --git a/packages/sdk/src/float.test.ts b/packages/sdk/src/float.test.ts index d87c019..d36ac1b 100644 --- a/packages/sdk/src/float.test.ts +++ b/packages/sdk/src/float.test.ts @@ -920,6 +920,137 @@ describe("createFloatBubble", () => { 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(); diff --git a/packages/sdk/src/float.ts b/packages/sdk/src/float.ts index 5b2a410..f45d1db 100644 --- a/packages/sdk/src/float.ts +++ b/packages/sdk/src/float.ts @@ -319,10 +319,12 @@ 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 — so a config change - // (e.g. the async API config arriving after mount) can reschedule it. + // 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 armedAtMs = 0; + let delayAnchorMs: number | null = null; let teaserDelivered = false; const persistOpenState = (open: boolean) => { setPersistedOpenState({ @@ -450,10 +452,7 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { }; const maybeStartWelcomeSequence = ( - teaserConfig = resolveTeaserConfig(currentConfig), - // Actual timer duration; differs from teaserConfig.delay when a - // reschedule credits time already waited. - timerDelay = teaserConfig.delay + teaserConfig = resolveTeaserConfig(currentConfig) ) => { if (welcomeSequenceStarted || isOpen) return; @@ -461,9 +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; - armedAtMs = Date.now(); + 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 @@ -496,12 +507,17 @@ 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 (typically the API - // config arriving after mount armed the default) reschedules the timers, - // crediting the time already waited. Never reschedule once the teaser + // 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 && @@ -512,12 +528,6 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { ) { clearWelcomeTimers(); welcomeSequenceStarted = false; - const elapsed = Date.now() - armedAtMs; - maybeStartWelcomeSequence( - teaserConfig, - Math.max(0, teaserConfig.delay - elapsed) - ); - return; } maybeStartWelcomeSequence(teaserConfig); @@ -697,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 ( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 646f9f8..72153d0 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -348,7 +348,15 @@ export interface ThemeConfig { } /** EmbedConfig with _apiConfig from the config API (internal, subject to change) */ -export type InternalEmbedConfig = EmbedConfig & { _apiConfig?: ThemeConfig }; +export type InternalEmbedConfig = EmbedConfig & { + _apiConfig?: ThemeConfig; + /** + * Set when the embed mounts while the config API fetch is still in flight. + * Defers delay-sensitive behavior (the float teaser) until the fetched + * config arrives via update({ _apiConfig }), which clears the flag. + */ + _apiConfigPending?: boolean; +}; /** SDK global configuration */ export interface SDKConfig {