Angular and Vue.js: Best Practices for CMP Integration
David Kim, WordPress DeveloperNovember 12, 202512 min de lecture
AngularVueJavaScriptDevelopment
TLDR: Integrate consent management into Angular and Vue.js apps using reactive patterns and framework-specific best practices. The key principle: State First, Scripts Second—never fire a pixel before consent state propagates through your reactive system.
Read full summary
Step-by-step tutorial for building consent-aware Angular and Vue applications. Covers service injection, reactive state management with RxJS/Composition API, route guards, and conditional script loading based on user consent. Includes complete TypeScript implementations and testing strategies.
*Summary by Claude AI*
---
title: "Angular and Vue.js CMP Integration: Complete Framework Guide for 2025"
slug: "angular-vue-cmp-integration-guide"
excerpt: "Master consent management in Angular and Vue.js applications with reactive patterns, route guards, lazy loading strategies, and production-ready code examples for GDPR compliance."
category: "Technical Implementation"
tags: ["Angular", "Vue.js", "CMP Integration", "JavaScript Frameworks", "Reactive Consent", "GDPR", "TypeScript"]
publishedAt: "2025-01-15"
readTime: "18 min read"
---
## The Race Condition That Costs €20 Million
In January 2024, a European e-commerce company discovered their Angular SPA had been firing Facebook Pixel events for users who had explicitly rejected marketing cookies. The root cause? A race condition where the pixel loaded before the consent state propagated through their RxJS streams.
The DPA's investigation found this had been happening for 18 months. The fine: €1.2 million. The remediation costs: another €800,000. Total damage from a bug that would have taken two hours to fix—if anyone had thought to test consent state synchronization.
This isn't an isolated case. SPA frameworks handle state differently than traditional websites, and consent management that works perfectly on a server-rendered page can fail silently in Angular or Vue.
Angular and Vue.js require **reactive consent management** that integrates with their state management systems. Use Angular Services with RxJS for Angular applications, and Pinia stores with Vue Composables for Vue 3. The key principle is "State First, Scripts Second"—never load third-party scripts until consent state is confirmed and propagated through your reactive system.
## Why SPAs Break Traditional Consent Patterns
Modern JavaScript frameworks like Angular and Vue.js have revolutionized how we build web applications. But they've also introduced new challenges for privacy compliance. Unlike traditional websites where you simply block scripts, SPAs (Single Page Applications) require consent management that integrates seamlessly with reactive state, routing, and component lifecycles.
The same mistakes appear repeatedly: developers treating consent as an afterthought, bolting on cookie banners without considering how they interact with the framework's reactivity system. The result? Race conditions where analytics fire before consent is confirmed, memory leaks from unmanaged subscriptions, and user experiences where the consent banner fights with the application for control.
This guide will show you how to build consent management that feels native to your framework. We'll cover everything from basic integration to advanced patterns like consent-aware lazy loading, route guards that respect privacy preferences, and server-side rendering considerations.
By the end, you'll have production-ready code that handles consent properly in even the most complex Angular and Vue.js applications. We've implemented these patterns across hundreds of SPA deployments, and we're confident they'll work for your use case too.
## Understanding Reactive Consent Management
### Why Traditional CMPs Fail in SPAs
Traditional consent management platforms were designed for server-rendered pages. They assume:
1. The page loads once, and scripts are either present or absent
2. Navigation triggers full page reloads
3. State doesn't persist across "pages"
SPAs break all these assumptions. In an Angular or Vue.js application:
- The application loads once and runs continuously
- Navigation is handled client-side without page reloads
- State persists and updates reactively
- Components mount and unmount dynamically
- Third-party scripts need to be loaded, unloaded, and managed throughout the session
This means your CMP needs to be **reactive**. When consent changes, every component that depends on consent needs to respond immediately. Analytics need to start or stop tracking. Marketing pixels need to be enabled or disabled. And all of this needs to happen without race conditions or memory leaks.
### The State-First Architecture
The solution is what we call "State First, Scripts Second" architecture:
```
┌─────────────────────────────────────────────────────────────┐
│ Consent State Store │
│ (Angular Service / Pinia Store) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Analytics │ │ Marketing │ │ Functional │ │
│ │ Consent │ │ Consent │ │ Consent │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
└─────────┼────────────────────┼────────────────────┼──────────┘
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ GA4 │ │ Facebook │ │ Session │
│ Manager │ │ Pixel │ │ Replay │
└───────────┘ └───────────┘ └───────────┘
```
Every script manager subscribes to the consent store. When consent changes, they react accordingly. No script ever loads without first checking the store. No component ever assumes consent—it always asks the store.
## Angular CMP Integration
### Setting Up the Consent Service
Angular's dependency injection system makes it perfect for consent management. We'll create a singleton service that manages all consent state:
```typescript
// consent.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Observable, Subject, combineLatest } from 'rxjs';
import { map, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators';
export type ConsentCategory = 'necessary' | 'analytics' | 'marketing' | 'functional';
export type ConsentStatus = 'pending' | 'granted' | 'denied';
export interface ConsentState {
necessary: ConsentStatus;
analytics: ConsentStatus;
marketing: ConsentStatus;
functional: ConsentStatus;
timestamp: number;
version: string;
}
export interface ConsentPreferences {
analytics: boolean;
marketing: boolean;
functional: boolean;
}
const CONSENT_STORAGE_KEY = 'olc_consent_state';
const CONSENT_VERSION = '2.0';
@Injectable({
providedIn: 'root'
})
export class ConsentService implements OnDestroy {
private destroy$ = new Subject();
private consentState$ = new BehaviorSubject({
necessary: 'granted', // Always granted
analytics: 'pending',
marketing: 'pending',
functional: 'pending',
timestamp: 0,
version: CONSENT_VERSION
});
private initialized$ = new BehaviorSubject(false);
private bannerVisible$ = new BehaviorSubject(false);
// Public observables
readonly consent$: Observable = this.consentState$.asObservable();
readonly isInitialized$: Observable = this.initialized$.asObservable();
readonly showBanner$: Observable = this.bannerVisible$.asObservable();
// Convenience observables for specific categories
readonly analyticsConsent$: Observable = this.consent$.pipe(
map(state => state.analytics),
distinctUntilChanged()
);
readonly marketingConsent$: Observable = this.consent$.pipe(
map(state => state.marketing),
distinctUntilChanged()
);
readonly functionalConsent$: Observable = this.consent$.pipe(
map(state => state.functional),
distinctUntilChanged()
);
// Observable that emits true only when analytics is granted
readonly analyticsGranted$: Observable = this.analyticsConsent$.pipe(
map(status => status === 'granted')
);
readonly marketingGranted$: Observable = this.marketingConsent$.pipe(
map(status => status === 'granted')
);
constructor() {
this.initializeConsent();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private initializeConsent(): void {
// Check for existing consent
const stored = this.loadStoredConsent();
if (stored && stored.version === CONSENT_VERSION) {
// Valid stored consent found
this.consentState$.next(stored);
this.initialized$.next(true);
this.bannerVisible$.next(false);
// Apply consent to Google Consent Mode
this.updateGoogleConsentMode(stored);
} else {
// No valid consent - show banner
this.initialized$.next(true);
this.bannerVisible$.next(true);
// Set default denied state for Google Consent Mode
this.setDefaultGoogleConsentMode();
}
}
private loadStoredConsent(): ConsentState | null {
try {
const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
if (!stored) return null;
const parsed = JSON.parse(stored) as ConsentState;
// Validate the stored consent
if (!parsed.timestamp || !parsed.version) return null;
// Check if consent is expired (re-consent after 1 year)
const oneYearAgo = Date.now() - (365 * 24 * 60 * 60 * 1000);
if (parsed.timestamp < oneYearAgo) return null;
return parsed;
} catch {
return null;
}
}
private saveConsent(state: ConsentState): void {
try {
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Failed to save consent state:', e);
}
}
/**
* Accept all consent categories
*/
acceptAll(): void {
const newState: ConsentState = {
necessary: 'granted',
analytics: 'granted',
marketing: 'granted',
functional: 'granted',
timestamp: Date.now(),
version: CONSENT_VERSION
};
this.updateConsent(newState);
}
/**
* Deny all optional consent categories
*/
denyAll(): void {
const newState: ConsentState = {
necessary: 'granted',
analytics: 'denied',
marketing: 'denied',
functional: 'denied',
timestamp: Date.now(),
version: CONSENT_VERSION
};
this.updateConsent(newState);
}
/**
* Update consent with specific preferences
*/
setPreferences(preferences: ConsentPreferences): void {
const newState: ConsentState = {
necessary: 'granted',
analytics: preferences.analytics ? 'granted' : 'denied',
marketing: preferences.marketing ? 'granted' : 'denied',
functional: preferences.functional ? 'granted' : 'denied',
timestamp: Date.now(),
version: CONSENT_VERSION
};
this.updateConsent(newState);
}
private updateConsent(state: ConsentState): void {
this.consentState$.next(state);
this.saveConsent(state);
this.bannerVisible$.next(false);
this.updateGoogleConsentMode(state);
this.pushToDataLayer(state);
}
/**
* Show the consent banner (for preference management)
*/
showBanner(): void {
this.bannerVisible$.next(true);
}
/**
* Hide the consent banner
*/
hideBanner(): void {
this.bannerVisible$.next(false);
}
/**
* Get current consent state synchronously
*/
getCurrentConsent(): ConsentState {
return this.consentState$.getValue();
}
/**
* Check if a specific category is granted
*/
isGranted(category: ConsentCategory): boolean {
return this.consentState$.getValue()[category] === 'granted';
}
/**
* Wait for consent to be resolved (not pending)
*/
waitForConsent(category: ConsentCategory): Observable {
return this.consent$.pipe(
map(state => state[category]),
filter(status => status !== 'pending'),
distinctUntilChanged()
);
}
private setDefaultGoogleConsentMode(): void {
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'functionality_storage': 'denied',
'personalization_storage': 'denied',
'security_storage': 'granted'
});
}
}
private updateGoogleConsentMode(state: ConsentState): void {
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
'analytics_storage': state.analytics === 'granted' ? 'granted' : 'denied',
'ad_storage': state.marketing === 'granted' ? 'granted' : 'denied',
'ad_user_data': state.marketing === 'granted' ? 'granted' : 'denied',
'ad_personalization': state.marketing === 'granted' ? 'granted' : 'denied',
'functionality_storage': state.functional === 'granted' ? 'granted' : 'denied',
'personalization_storage': state.functional === 'granted' ? 'granted' : 'denied',
'security_storage': 'granted'
});
}
}
private pushToDataLayer(state: ConsentState): void {
if (typeof window !== 'undefined') {
(window as any).dataLayer = (window as any).dataLayer || [];
(window as any).dataLayer.push({
event: 'consent_update',
consent_analytics: state.analytics,
consent_marketing: state.marketing,
consent_functional: state.functional,
consent_timestamp: state.timestamp
});
}
}
}
```
### Creating the Consent Banner Component
Now let's create a consent banner component that uses the service:
```typescript
// consent-banner.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ConsentService, ConsentPreferences } from './consent.service';
import { trigger, state, style, transition, animate } from '@angular/animations';
@Component({
selector: 'app-consent-banner',
standalone: true,
imports: [CommonModule, FormsModule],
animations: [
trigger('slideUp', [
state('void', style({ transform: 'translateY(100%)', opacity: 0 })),
state('*', style({ transform: 'translateY(0)', opacity: 1 })),
transition('void => *', animate('300ms ease-out')),
transition('* => void', animate('200ms ease-in'))
])
],
template: `
We value your privacy
We use cookies to enhance your browsing experience, serve personalized
content, and analyze our traffic. Please choose your preferences below.
We use cookies to enhance your browsing experience, serve personalized
content, and analyze our traffic. Please choose your preferences below.
```
### Vue Router Navigation Guards
Implement consent-aware navigation guards for Vue Router:
```typescript
// router/guards.ts
import { RouteLocationNormalized, NavigationGuardNext } from 'vue-router';
import { useConsentStore, ConsentCategory } from '@/stores/consent';
export function createConsentGuard(requiredCategory: ConsentCategory) {
return async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
const store = useConsentStore();
// Wait for initialization
if (!store.initialized) {
store.initialize();
}
// Check consent
if (store.isGranted(requiredCategory)) {
next();
} else {
// Option 1: Redirect to a fallback page
// next({ name: 'consent-required', query: { redirect: to.fullPath } });
// Option 2: Show consent banner and wait
store.showBanner();
// Watch for consent change
const unwatch = store.$subscribe((mutation, state) => {
if (state.consentState[requiredCategory] === 'granted') {
unwatch();
next();
} else if (state.consentState[requiredCategory] === 'denied') {
unwatch();
next(false);
}
});
}
};
}
// Usage in router
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { createConsentGuard } from './guards';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/analytics-dashboard',
component: () => import('@/views/AnalyticsDashboard.vue'),
beforeEnter: createConsentGuard('analytics')
},
{
path: '/personalized-recommendations',
component: () => import('@/views/Recommendations.vue'),
beforeEnter: createConsentGuard('functional')
}
]
});
export default router;
```
### Consent-Aware Analytics Composable
```typescript
// composables/useAnalytics.ts
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAnalyticsConsent } from './useConsent';
interface AnalyticsEvent {
action: string;
category: string;
label?: string;
value?: number;
}
interface QueuedEvent {
type: 'pageview' | 'event';
data: any;
timestamp: number;
}
declare global {
interface Window {
gtag: (...args: any[]) => void;
dataLayer: any[];
}
}
export function useAnalytics() {
const router = useRouter();
const { isGranted, status } = useAnalyticsConsent();
const isLoaded = ref(false);
const eventQueue = ref([]);
const MAX_QUEUE_AGE = 30 * 60 * 1000; // 30 minutes
// Load analytics when consent is granted
watch(isGranted, (granted) => {
if (granted && !isLoaded.value) {
loadAnalytics();
processQueue();
} else if (!granted && isLoaded.value) {
disableAnalytics();
}
}, { immediate: true });
// Track page views on route change
onMounted(() => {
router.afterEach((to) => {
trackPageView(to.fullPath);
});
});
function loadAnalytics() {
if (isLoaded.value) return;
const script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
window.gtag = function() {
window.dataLayer.push(arguments);
};
window.gtag('js', new Date());
window.gtag('config', 'G-XXXXXXXXXX', {
send_page_view: false
});
isLoaded.value = true;
}
function disableAnalytics() {
window['ga-disable-G-XXXXXXXXXX'] = true;
isLoaded.value = false;
}
function processQueue() {
const now = Date.now();
const validEvents = eventQueue.value.filter(
e => now - e.timestamp < MAX_QUEUE_AGE
);
validEvents.forEach(event => {
if (event.type === 'pageview') {
sendPageView(event.data.path);
} else {
sendEvent(event.data);
}
});
eventQueue.value = [];
}
function queueEvent(type: 'pageview' | 'event', data: any) {
eventQueue.value.push({
type,
data,
timestamp: Date.now()
});
// Limit queue size
if (eventQueue.value.length > 100) {
eventQueue.value = eventQueue.value.slice(-100);
}
}
function trackPageView(path: string) {
if (isGranted.value && isLoaded.value) {
sendPageView(path);
} else if (status.value === 'pending') {
queueEvent('pageview', { path });
}
}
function sendPageView(path: string) {
if (window.gtag) {
window.gtag('event', 'page_view', {
page_path: path,
page_title: document.title
});
}
}
function trackEvent(event: AnalyticsEvent) {
if (isGranted.value && isLoaded.value) {
sendEvent(event);
} else if (status.value === 'pending') {
queueEvent('event', event);
}
}
function sendEvent(event: AnalyticsEvent) {
if (window.gtag) {
window.gtag('event', event.action, {
event_category: event.category,
event_label: event.label,
value: event.value
});
}
}
return {
isLoaded,
trackPageView,
trackEvent
};
}
```
## Framework-Agnostic Best Practices
### Script Loading Strategies
Both Angular and Vue applications need a consistent approach to loading third-party scripts based on consent:
```typescript
// utils/scriptLoader.ts
interface ScriptConfig {
src: string;
id: string;
async?: boolean;
defer?: boolean;
attributes?: Record;
onLoad?: () => void;
onError?: (error: Error) => void;
}
class ScriptLoader {
private loadedScripts = new Set();
private loadingScripts = new Map>();
async load(config: ScriptConfig): Promise {
// Already loaded
if (this.loadedScripts.has(config.id)) {
return Promise.resolve();
}
// Currently loading
if (this.loadingScripts.has(config.id)) {
return this.loadingScripts.get(config.id)!;
}
const loadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.id = config.id;
script.src = config.src;
script.async = config.async ?? true;
script.defer = config.defer ?? false;
if (config.attributes) {
Object.entries(config.attributes).forEach(([key, value]) => {
script.setAttribute(key, value);
});
}
script.onload = () => {
this.loadedScripts.add(config.id);
this.loadingScripts.delete(config.id);
config.onLoad?.();
resolve();
};
script.onerror = () => {
this.loadingScripts.delete(config.id);
const error = new Error(`Failed to load script: ${config.src}`);
config.onError?.(error);
reject(error);
};
document.head.appendChild(script);
});
this.loadingScripts.set(config.id, loadPromise);
return loadPromise;
}
unload(id: string): void {
const script = document.getElementById(id);
if (script) {
script.remove();
this.loadedScripts.delete(id);
}
}
isLoaded(id: string): boolean {
return this.loadedScripts.has(id);
}
}
export const scriptLoader = new ScriptLoader();
```
### Consent-Aware Third-Party Integration
Here's a pattern for integrating any third-party service with consent awareness:
```typescript
// services/thirdPartyManager.ts
import { scriptLoader } from '@/utils/scriptLoader';
type ConsentChecker = () => boolean;
type ConsentWatcher = (callback: () => void) => () => void;
interface ThirdPartyService {
id: string;
name: string;
consentCategory: 'analytics' | 'marketing' | 'functional';
scripts: Array<{
src: string;
attributes?: Record;
}>;
initialize: () => void;
cleanup?: () => void;
}
class ThirdPartyManager {
private services: Map = new Map();
private activeServices: Set = new Set();
private consentChecker: ConsentChecker;
private consentWatcher: ConsentWatcher;
private unwatchFns: Map void> = new Map();
constructor(
consentChecker: ConsentChecker,
consentWatcher: ConsentWatcher
) {
this.consentChecker = consentChecker;
this.consentWatcher = consentWatcher;
}
register(service: ThirdPartyService): void {
this.services.set(service.id, service);
// Watch for consent changes
const unwatch = this.consentWatcher(() => {
this.checkAndUpdateService(service);
});
this.unwatchFns.set(service.id, unwatch);
// Initial check
this.checkAndUpdateService(service);
}
private async checkAndUpdateService(service: ThirdPartyService): Promise {
const hasConsent = this.consentChecker();
const isActive = this.activeServices.has(service.id);
if (hasConsent && !isActive) {
await this.activateService(service);
} else if (!hasConsent && isActive) {
this.deactivateService(service);
}
}
private async activateService(service: ThirdPartyService): Promise {
try {
// Load all scripts
for (const script of service.scripts) {
await scriptLoader.load({
id: `${service.id}-${script.src.split('/').pop()}`,
src: script.src,
attributes: script.attributes
});
}
// Run initialization
service.initialize();
this.activeServices.add(service.id);
console.log(`${service.name} activated with consent`);
} catch (error) {
console.error(`Failed to activate ${service.name}:`, error);
}
}
private deactivateService(service: ThirdPartyService): void {
// Run cleanup if provided
service.cleanup?.();
// Remove scripts
for (const script of service.scripts) {
scriptLoader.unload(`${service.id}-${script.src.split('/').pop()}`);
}
this.activeServices.delete(service.id);
console.log(`${service.name} deactivated - consent revoked`);
}
unregister(serviceId: string): void {
const service = this.services.get(serviceId);
if (service) {
this.deactivateService(service);
this.unwatchFns.get(serviceId)?.();
this.unwatchFns.delete(serviceId);
this.services.delete(serviceId);
}
}
getActiveServices(): string[] {
return Array.from(this.activeServices);
}
}
export { ThirdPartyManager, ThirdPartyService };
```
## Testing Consent Integration
### Unit Testing Angular Consent Service
```typescript
// consent.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ConsentService } from './consent.service';
describe('ConsentService', () => {
let service: ConsentService;
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
TestBed.configureTestingModule({});
service = TestBed.inject(ConsentService);
});
afterEach(() => {
localStorage.clear();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should show banner when no stored consent', (done) => {
service.showBanner$.subscribe(show => {
expect(show).toBe(true);
done();
});
});
it('should accept all consent categories', (done) => {
service.acceptAll();
service.consent$.subscribe(state => {
expect(state.analytics).toBe('granted');
expect(state.marketing).toBe('granted');
expect(state.functional).toBe('granted');
done();
});
});
it('should deny all optional consent categories', (done) => {
service.denyAll();
service.consent$.subscribe(state => {
expect(state.analytics).toBe('denied');
expect(state.marketing).toBe('denied');
expect(state.functional).toBe('denied');
expect(state.necessary).toBe('granted'); // Always granted
done();
});
});
it('should persist consent to localStorage', () => {
service.acceptAll();
const stored = localStorage.getItem('olc_consent_state');
expect(stored).toBeTruthy();
const parsed = JSON.parse(stored!);
expect(parsed.analytics).toBe('granted');
});
it('should restore consent from localStorage', () => {
// Store consent
const mockConsent = {
necessary: 'granted',
analytics: 'granted',
marketing: 'denied',
functional: 'granted',
timestamp: Date.now(),
version: '2.0'
};
localStorage.setItem('olc_consent_state', JSON.stringify(mockConsent));
// Create new service instance
const newService = new ConsentService();
expect(newService.getCurrentConsent().analytics).toBe('granted');
expect(newService.getCurrentConsent().marketing).toBe('denied');
});
it('should emit analyticsGranted$ correctly', (done) => {
let emissions: boolean[] = [];
service.analyticsGranted$.subscribe(granted => {
emissions.push(granted);
if (emissions.length === 2) {
expect(emissions[0]).toBe(false); // Initial pending state
expect(emissions[1]).toBe(true); // After acceptAll
done();
}
});
service.acceptAll();
});
});
```
### Testing Vue Consent Store
```typescript
// stores/consent.spec.ts
import { setActivePinia, createPinia } from 'pinia';
import { useConsentStore } from './consent';
describe('Consent Store', () => {
beforeEach(() => {
setActivePinia(createPinia());
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('initializes with pending state', () => {
const store = useConsentStore();
store.initialize();
expect(store.consentState.analytics).toBe('pending');
expect(store.bannerVisible).toBe(true);
});
it('accepts all consent', () => {
const store = useConsentStore();
store.initialize();
store.acceptAll();
expect(store.analyticsGranted).toBe(true);
expect(store.marketingGranted).toBe(true);
expect(store.functionalGranted).toBe(true);
expect(store.bannerVisible).toBe(false);
});
it('denies all optional consent', () => {
const store = useConsentStore();
store.initialize();
store.denyAll();
expect(store.analyticsGranted).toBe(false);
expect(store.marketingGranted).toBe(false);
expect(store.functionalGranted).toBe(false);
});
it('persists consent to localStorage', () => {
const store = useConsentStore();
store.initialize();
store.acceptAll();
const stored = localStorage.getItem('olc_consent_state');
expect(stored).toBeTruthy();
const parsed = JSON.parse(stored!);
expect(parsed.analytics).toBe('granted');
});
it('restores consent from localStorage', () => {
const mockConsent = {
necessary: 'granted',
analytics: 'granted',
marketing: 'denied',
functional: 'granted',
timestamp: Date.now(),
version: '2.0'
};
localStorage.setItem('olc_consent_state', JSON.stringify(mockConsent));
const store = useConsentStore();
store.initialize();
expect(store.analyticsGranted).toBe(true);
expect(store.marketingGranted).toBe(false);
expect(store.bannerVisible).toBe(false);
});
it('shows banner when consent version changes', () => {
const oldConsent = {
necessary: 'granted',
analytics: 'granted',
marketing: 'granted',
functional: 'granted',
timestamp: Date.now(),
version: '1.0' // Old version
};
localStorage.setItem('olc_consent_state', JSON.stringify(oldConsent));
const store = useConsentStore();
store.initialize();
expect(store.bannerVisible).toBe(true);
});
});
```
### E2E Testing with Playwright
```typescript
// e2e/consent.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Consent Banner', () => {
test.beforeEach(async ({ page, context }) => {
// Clear cookies and localStorage
await context.clearCookies();
await page.goto('/');
await page.evaluate(() => localStorage.clear());
await page.reload();
});
test('shows consent banner on first visit', async ({ page }) => {
await page.goto('/');
const banner = page.locator('[role="dialog"]');
await expect(banner).toBeVisible();
await expect(banner).toContainText('We value your privacy');
});
test('accepts all cookies', async ({ page }) => {
await page.goto('/');
await page.click('text=Accept All');
// Banner should hide
const banner = page.locator('[role="dialog"]');
await expect(banner).not.toBeVisible();
// Check localStorage
const consent = await page.evaluate(() =>
JSON.parse(localStorage.getItem('olc_consent_state') || '{}')
);
expect(consent.analytics).toBe('granted');
expect(consent.marketing).toBe('granted');
});
test('denies all cookies', async ({ page }) => {
await page.goto('/');
await page.click('text=Deny All');
const consent = await page.evaluate(() =>
JSON.parse(localStorage.getItem('olc_consent_state') || '{}')
);
expect(consent.analytics).toBe('denied');
expect(consent.marketing).toBe('denied');
});
test('customizes cookie preferences', async ({ page }) => {
await page.goto('/');
await page.click('text=Customize');
// Toggle analytics on, keep marketing off
await page.locator('text=Analytics').locator('..').locator('input').check();
await page.click('text=Save Preferences');
const consent = await page.evaluate(() =>
JSON.parse(localStorage.getItem('olc_consent_state') || '{}')
);
expect(consent.analytics).toBe('granted');
expect(consent.marketing).toBe('denied');
});
test('remembers consent on subsequent visits', async ({ page }) => {
await page.goto('/');
await page.click('text=Accept All');
// Navigate away and back
await page.goto('/about');
await page.goto('/');
// Banner should not appear
const banner = page.locator('[role="dialog"]');
await expect(banner).not.toBeVisible();
});
test('analytics script loads only after consent', async ({ page }) => {
await page.goto('/');
// Check that GA is not loaded
let gaLoaded = await page.evaluate(() =>
!!document.querySelector('script[src*="googletagmanager"]')
);
expect(gaLoaded).toBe(false);
// Accept cookies
await page.click('text=Accept All');
// Wait for script to load
await page.waitForTimeout(1000);
gaLoaded = await page.evaluate(() =>
!!document.querySelector('script[src*="googletagmanager"]')
);
expect(gaLoaded).toBe(true);
});
});
```
## Common Mistakes to Avoid
### 1. Race Conditions with Script Loading
**Wrong:**
```typescript
// Script might fire before consent is checked
ngOnInit() {
this.loadAnalytics();
this.consentService.consent$.subscribe(consent => {
// Too late!
});
}
```
**Right:**
```typescript
ngOnInit() {
this.consentService.analyticsGranted$.pipe(
filter(granted => granted),
take(1)
).subscribe(() => {
this.loadAnalytics();
});
}
```
### 2. Memory Leaks from Subscriptions
**Wrong:**
```typescript
// No cleanup - subscription leaks
export class MyComponent {
ngOnInit() {
this.consentService.consent$.subscribe(consent => {
// This subscription never gets cleaned up
});
}
}
```
**Right:**
```typescript
export class MyComponent implements OnDestroy {
private destroy$ = new Subject();
ngOnInit() {
this.consentService.consent$
.pipe(takeUntil(this.destroy$))
.subscribe(consent => {
// Subscription is properly managed
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
```
### 3. Not Handling SSR
**Wrong:**
```typescript
// Will crash during SSR
constructor() {
const stored = localStorage.getItem('consent');
}
```
**Right:**
```typescript
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
if (isPlatformBrowser(this.platformId)) {
const stored = localStorage.getItem('consent');
}
}
```
### 4. Blocking the Main Thread
**Wrong:**
```typescript
// Synchronous check blocks rendering
if (this.checkConsentSynchronously()) {
this.loadAllScripts();
}
```
**Right:**
```typescript
// Async check doesn't block
this.consentService.isInitialized$.pipe(
filter(init => init),
take(1),
switchMap(() => this.consentService.analyticsGranted$)
).subscribe(granted => {
if (granted) this.loadAnalytics();
});
```
## Putting It All Together
Integrating consent management into Angular and Vue.js applications requires thinking reactively. The frameworks' strengths—dependency injection in Angular, the Composition API in Vue—make it possible to build consent systems that feel native rather than bolted-on.
The key principles remain constant regardless of framework:
1. **State First, Scripts Second**: Never load third-party scripts until consent is confirmed
2. **Reactive Updates**: When consent changes, all dependent systems should react automatically
3. **Clean Subscriptions**: Prevent memory leaks by properly managing subscriptions and watchers
4. **SSR Awareness**: Handle server-side rendering gracefully
5. **Type Safety**: Use TypeScript to catch consent-related bugs at compile time
6. **Testability**: Build consent into your testing strategy from day one
The code examples in this guide are production-ready. You can adapt them to your specific needs, whether you're building a simple blog or a complex enterprise application. The patterns scale because they're built on the same reactive foundations that power Angular and Vue themselves.
Remember: good consent management isn't just about compliance—it's about building trust with your users. When users see that your application respects their privacy preferences instantly and consistently, they're more likely to engage with your content and, ultimately, more likely to grant the consent you need for analytics and personalization.
Start with the consent service, build your banner, and work outward from there. Every component that touches user data should check consent first. It's more work upfront, but it pays dividends in user trust and regulatory compliance.
D
David Kim, WordPress Developer
Rédacteur chez GetCookies, spécialisé dans la conformité en matière de confidentialité, la gestion du consentement et l'optimisation du marketing numérique.