fix(ads): banner truly continuous across navigation + re-measure on resize
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
The previous engine kept the scheduler running but the view re-`show()`-ed the
current message on every (re)mount, replaying the fade on each navigation — which
looked like the cycle restarting (especially for a single message). Now:
- A mounted AdBanner reads the engine's live message (bannerCurrent) and renders
it immediately, with no fade; attach no longer re-shows. Only a real advance
fades. Verified: opacity stays 1.0 across a navigation, message preserved.
- The fade is manual opacity on a .fadewrap layer (not transition:fade), kept
independent of the scroll (inner track transform), so a long message still
fades at both ends and a {#key} remount cannot force an intro fade.
- A viewport size change (portrait↔landscape) re-measures the current message
(remeasureBanner on resize/orientationchange, debounced) so the scroll
re-evaluates for the new width — the owner accepts the restart on resize.
Rotator gains restart(); engine gains bannerCurrent()/remeasureBanner().
Engine continuity + remeasure unit-tested.
This commit is contained in:
+51
-19
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
|
||||
import { attachBannerHost, configureBanner, detachBannerHost } from './bannerEngine';
|
||||
import { attachBannerHost, bannerCurrent, configureBanner, detachBannerHost, remeasureBanner } from './bannerEngine';
|
||||
import type { BannerTimings } from './model';
|
||||
|
||||
describe('linkify', () => {
|
||||
@@ -181,42 +181,74 @@ describe('bannerEngine', () => {
|
||||
|
||||
const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;
|
||||
|
||||
it('continues the cycle across a host detach/attach (does not restart)', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
|
||||
const a = engineHost();
|
||||
configureBanner(camps, cfg);
|
||||
attachBannerHost(a.host);
|
||||
expect(a.shown).toEqual(['x0']); // resynced to the live message
|
||||
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||
|
||||
// Swap the host mid-cycle (as navigation remounts the view): the new host resyncs to the
|
||||
// same live message rather than restarting at the first.
|
||||
it('keeps the live message across a host detach/attach, and keeps advancing (continuity)', () => {
|
||||
vi.useFakeTimers();
|
||||
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
|
||||
configureBanner(camps, cfg);
|
||||
const a = engineHost();
|
||||
attachBannerHost(a.host);
|
||||
// Advance mid-sequence so the live message is no longer the first.
|
||||
vi.advanceTimersByTime(period(cfg));
|
||||
expect(bannerCurrent()).toBe('x1');
|
||||
|
||||
// Remount the view (as navigation does): the live message is preserved (not reset to 'x0'),
|
||||
// and a fresh view receives no replayed show() on attach (it reads bannerCurrent() itself).
|
||||
detachBannerHost(a.host);
|
||||
const b = engineHost();
|
||||
attachBannerHost(b.host);
|
||||
expect(b.shown).toEqual(['x0']);
|
||||
expect(bannerCurrent()).toBe('x1');
|
||||
expect(b.shown).toEqual([]);
|
||||
|
||||
// The sequence advances to the next message on the new host (it continued, not restarted).
|
||||
// The engine keeps advancing and drives the new host — the cycle continued, not restarted.
|
||||
vi.advanceTimersByTime(period(cfg));
|
||||
expect(b.shown).toContain('x1');
|
||||
expect(b.shown).toContain('x0');
|
||||
expect(bannerCurrent()).toBe('x0');
|
||||
detachBannerHost(b.host);
|
||||
});
|
||||
|
||||
it('does not restart on a no-op reconfigure with unchanged data', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||
const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
|
||||
const a = engineHost();
|
||||
configureBanner(camps, cfg);
|
||||
const a = engineHost();
|
||||
attachBannerHost(a.host);
|
||||
vi.advanceTimersByTime(period(cfg));
|
||||
expect(a.shown).toEqual(['y0', 'y1']);
|
||||
expect(bannerCurrent()).toBe('y1');
|
||||
|
||||
// Re-configuring with identical data keeps the running cycle (no fresh 'y0' burst).
|
||||
// Re-configuring with identical data keeps the running cycle (the live message is unchanged).
|
||||
configureBanner(camps, cfg);
|
||||
vi.advanceTimersByTime(5);
|
||||
expect(a.shown).toEqual(['y0', 'y1']);
|
||||
expect(bannerCurrent()).toBe('y1');
|
||||
detachBannerHost(a.host);
|
||||
});
|
||||
|
||||
it('re-presents the current message on remeasure, engaging the scroll on a size change', () => {
|
||||
vi.useFakeTimers();
|
||||
const big: BannerTimings = { ...cfg, holdMs: 5000 };
|
||||
const shown: string[] = [];
|
||||
let scrolled = 0;
|
||||
let overflow = 0;
|
||||
const host: BannerHost = {
|
||||
show: (md) => shown.push(md),
|
||||
resetScroll() {},
|
||||
hide() {},
|
||||
overflowPx: () => overflow,
|
||||
scrollTo: () => scrolled++,
|
||||
};
|
||||
configureBanner([{ weight: 1, messages: ['z0'] }], big);
|
||||
attachBannerHost(host);
|
||||
vi.advanceTimersByTime(big.fadeInMs + 5); // initial measure: fits, no scroll
|
||||
expect(scrolled).toBe(0);
|
||||
|
||||
// The viewport shrank so the message now overflows; remeasure re-presents it and the scroll
|
||||
// engages (the owner accepts the cycle restarting on a size change).
|
||||
overflow = 300;
|
||||
remeasureBanner();
|
||||
expect(shown).toContain('z0');
|
||||
vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 5);
|
||||
expect(scrolled).toBeGreaterThan(0);
|
||||
detachBannerHost(host);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,9 @@ export interface BannerHost {
|
||||
export interface Rotator {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
/** Re-present the current message (re-measure overflow + restart its scroll), e.g. after the
|
||||
* viewport size changed. A no-op before start(). */
|
||||
restart(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +94,7 @@ export function createBannerRotator(
|
||||
const sched = createScheduler(campaigns);
|
||||
let running = false;
|
||||
let cycleStart = 0;
|
||||
let lastShown = ''; // the message currently presented, for restart() (re-measure)
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
const at = (ms: number, fn: () => void) => {
|
||||
@@ -104,6 +108,7 @@ export function createBannerRotator(
|
||||
function present(md: string) {
|
||||
if (!running) return;
|
||||
clear();
|
||||
lastShown = md;
|
||||
host.show(md);
|
||||
at(config.fadeInMs, measure);
|
||||
}
|
||||
@@ -151,6 +156,9 @@ export function createBannerRotator(
|
||||
running = false;
|
||||
clear();
|
||||
},
|
||||
restart() {
|
||||
if (running && lastShown) present(lastShown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,31 @@ export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimi
|
||||
}
|
||||
|
||||
/**
|
||||
* attachBannerHost connects a freshly-mounted view as the DOM host and resyncs it to the live
|
||||
* message, so the banner resumes mid-cycle instead of restarting.
|
||||
* bannerCurrent returns the message the engine is currently displaying (empty before the first
|
||||
* one). A freshly-mounted view reads it to render the live message immediately, without a fade —
|
||||
* so navigating between screens does not visibly restart the cycle.
|
||||
*/
|
||||
export function bannerCurrent(): string {
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
|
||||
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
|
||||
* replay the fade-in on every navigation, which looks like the cycle restarting. The engine's own
|
||||
* timer keeps driving show/hide for real message changes through this host.
|
||||
*/
|
||||
export function attachBannerHost(host: BannerHost): void {
|
||||
mounted = host;
|
||||
if (current) host.show(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* remeasureBanner re-presents the current message (re-measuring overflow and restarting its
|
||||
* scroll), for when the viewport size changed (e.g. a portrait↔landscape rotation) and a message
|
||||
* that fit may now overflow, or vice versa.
|
||||
*/
|
||||
export function remeasureBanner(): void {
|
||||
rotator?.restart();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user