Skip to content

Commit 055c560

Browse files
authored
fix(core): resolve consent banner race condition (#34)
* fix(core): resolve consent banner race condition and missing layout slot - Add bannerPending flag to ConsentManager — if init() calls showBanner before ConsentBanner registers its callback, defers and fires on registration - Add ConsentBanner to docs theme layout-bottom slot (was missing when custom Layout overrides the one from enhanceWithConsent) Fixes #33 * test(core): add bannerPending deferred show tests - Verify callback fires immediately when registered after init() - Verify callback fires via init() when registered before init() - Verify non-EU user does not trigger deferred banner
1 parent d763387 commit 055c560

3 files changed

Lines changed: 67 additions & 5 deletions

File tree

docs/.vitepress/theme/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { h } from "vue";
22
import DefaultTheme from "vitepress/theme";
33
import type { Theme } from "vitepress";
4-
import { enhanceWithConsent } from "../../../src/vitepress/index";
4+
import { enhanceWithConsent, ConsentBanner } from "../../../src/vitepress/index";
55
import { createKVStorage } from "../../../src/index";
66
import "./style.css";
77
import BugReportWidget from "./components/BugReportWidget.vue";
@@ -17,7 +17,7 @@ export default {
1717
...consentTheme,
1818
Layout() {
1919
return h(consentTheme.Layout ?? DefaultTheme.Layout, null, {
20-
"layout-bottom": () => [h(BugReportWidget), h(ConsentDebugPanel)],
20+
"layout-bottom": () => [h(ConsentBanner), h(BugReportWidget), h(ConsentDebugPanel)],
2121
});
2222
},
2323
} satisfies Theme;

src/__tests__/consent-manager.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,57 @@ describe("ConsentManager with remote storage", () => {
161161
});
162162
});
163163

164+
describe("ConsentManager banner deferred show (bannerPending)", () => {
165+
it("fires callback immediately when registered after init() already requested banner", async () => {
166+
const manager = new ConsentManager({
167+
geoDetector: createMockGeoDetector(true),
168+
version: "1.0",
169+
});
170+
171+
// init() completes before onShowBanner is registered
172+
await manager.init();
173+
174+
const showBanner = vi.fn();
175+
manager.onShowBanner(showBanner);
176+
177+
// Callback fires immediately because init() set bannerPending=true
178+
expect(showBanner).toHaveBeenCalledTimes(1);
179+
});
180+
181+
it("fires callback via init() when registered before init()", async () => {
182+
const manager = new ConsentManager({
183+
geoDetector: createMockGeoDetector(true),
184+
version: "1.0",
185+
});
186+
187+
const showBanner = vi.fn();
188+
manager.onShowBanner(showBanner);
189+
190+
// Callback not yet fired — init() hasn't run
191+
expect(showBanner).not.toHaveBeenCalled();
192+
193+
await manager.init();
194+
195+
// init() calls showBannerCallback directly
196+
expect(showBanner).toHaveBeenCalledTimes(1);
197+
});
198+
199+
it("does not fire callback for non-EU user", async () => {
200+
const manager = new ConsentManager({
201+
geoDetector: createMockGeoDetector(false),
202+
version: "1.0",
203+
});
204+
205+
await manager.init();
206+
207+
const showBanner = vi.fn();
208+
manager.onShowBanner(showBanner);
209+
210+
// Non-EU user: banner never shown, bannerPending stays false
211+
expect(showBanner).not.toHaveBeenCalled();
212+
});
213+
});
214+
164215
describe("ConsentManager.getGeoResult()", () => {
165216
it("returns null before init", () => {
166217
const manager = new ConsentManager({ version: "1.0" });

src/core/consent-manager.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class ConsentManager {
3434
private remoteStorage: ConsentStorage | null = null;
3535
private showBannerCallback: (() => void) | null = null;
3636
private hideBannerCallback: (() => void) | null = null;
37+
private bannerPending = false;
3738

3839
constructor(config: ConsentConfig = {}) {
3940
this.config = {
@@ -49,10 +50,16 @@ export class ConsentManager {
4950
}
5051

5152
/**
52-
* Register callback to show banner
53+
* Register callback to show banner.
54+
* If init() already requested the banner before this callback was registered,
55+
* fires immediately (handles race condition with component mount timing).
5356
*/
5457
onShowBanner(callback: () => void): void {
5558
this.showBannerCallback = callback;
59+
if (this.bannerPending) {
60+
this.bannerPending = false;
61+
callback();
62+
}
5663
}
5764

5865
/**
@@ -111,8 +118,12 @@ export class ConsentManager {
111118
await initGoogleAnalytics(this.config.gaId, true, sendPageView);
112119
}
113120

114-
// Show banner
115-
this.showBannerCallback?.();
121+
// Show banner (or defer if component hasn't mounted yet)
122+
if (this.showBannerCallback) {
123+
this.showBannerCallback();
124+
} else {
125+
this.bannerPending = true;
126+
}
116127
this.config.onBannerShow?.();
117128
} else {
118129
// Non-EU user: grant consent silently

0 commit comments

Comments
 (0)