Three components each read the consent platform independently, they disagree during the hydration window, and revoking consent stops new vendors loading while leaving the ones already running untouched.
Triage: Count the Readers
- Grep the codebase for the consent platform’s global —
__cmp,OnetrustActiveGroups,Cookiebot, whichever applies. - Count the files that match. Every match beyond one is an independent reader with its own opinion about timing.
- Grep for the vendor URLs. Every file that names one is a place a vendor can be loaded from.
- Search for a teardown or opt-out call. Its absence means revocation currently stops nothing.
Root Cause: Reactive State Without a Single Owner
Svelte stores make it easy to subscribe from anywhere, which is the feature and the trap. Three components can each call the platform’s addEventListener and each maintain their own state, and nothing about the framework discourages it — the code reads naturally and works in testing, where the platform resolves before anything renders.
Under real conditions the subscriptions attach at different points in the hydration sequence, so during the interval between the first and the last they hold different values. A vendor loaded by the earliest subscriber can therefore run under a state the later ones would have refused, and because the states converge a moment later there is no artefact afterwards that shows it happened.
The revocation half is worse, because reactivity does not help at all. A store update stops a {#if} block rendering and stops a subscriber injecting; it does nothing about a script that already executed and is running in a closure it created. Stopping that requires calling the vendor’s own teardown, which needs somewhere to live — and a codebase with three readers has no obvious place to put it.
Resolution: One Store, One Registry, One Subscription
// src/lib/consent.js — the only module that touches the platform.
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
// Three states, not two. 'pending' is not 'denied'.
export const consent = writable({ analytics: 'pending', marketing: 'pending' });
export const allows = (category) => derived(consent, ($c) => $c[category] === 'granted');
export function initConsent() {
if (!browser) return;
window.__cmp?.addEventListener?.('change', (s) =>
consent.set({
analytics: s.statistics ? 'granted' : 'denied',
marketing: s.marketing ? 'granted' : 'denied',
}));
}
// src/lib/vendors.js — one record, several consumers.
export const VENDORS = {
analytics: {
src: 'https://cdn.vendor.example/sdk/3.4.1/client.js',
integrity: 'sha384-…',
category: 'analytics',
origin: 'https://cdn.vendor.example',
teardown: () => window.acme?.optOut?.(),
},
};
<!-- src/routes/+layout.svelte — the single subscription. -->
<script>
import { onMount } from 'svelte';
import { consent, initConsent } from '$lib/consent.js';
import { loadScript } from '$lib/load-script.js';
import { VENDORS } from '$lib/vendors.js';
onMount(() => {
initConsent();
return consent.subscribe(($c) => {
for (const v of Object.values(VENDORS)) {
if ($c[v.category] === 'granted') {
loadScript(v.src, { integrity: v.integrity }).catch(reportFailure);
} else {
// The branch most implementations omit entirely.
v.teardown?.();
}
}
});
});
</script>
<slot />
The else branch is the part worth dwelling on. Without it, revocation is a promise your code makes and does not keep: the store says denied, no new script loads, and the vendor that loaded three minutes ago continues collecting for the rest of the session. In a single-page application that session can be very long.
Verification: Revoke and Watch the Network
Accept, let the vendor load, then revoke without reloading. Filter the Network panel to the vendor origin and exercise the page — there should be no further requests. Then check the Application panel: the vendor’s storage entries under your own domain should be gone.
Why the Registry Earns Its Keep at Two Vendors
A single-vendor site can hard-code the source URL in the layout and lose nothing. The registry pays for itself at two, and the reason is not the loading loop — it is everything else that wants the same list.
The consent gate needs to know which category permits each vendor. The content security policy needs the origins. The integrity pinning needs the digests. The inventory needs the owner and the justification. And revocation needs the teardown call. Those are five consumers of one list, and maintaining five copies is how they diverge.
Generating the policy allowlist from the registry at build time is the clearest demonstration. It means adding a vendor cannot produce the failure where a tag is consented but blocked, or allowlisted but never gated — the two lists are the same list, and the class of bug disappears rather than being tested for.
Testing a Property That Has No Visible Symptom
A consent gate is unusual among features in that its correct behaviour produces nothing to look at. The page works whether or not the gate holds, the vendor collects data whether or not it should, and nobody using the site can tell the difference — which is why gates decay quietly and why the tests matter more than usual.
Three assertions cover most of it and all three are cheap. Render a route through the server pipeline and assert no vendor hostname appears in the HTML string. Simulate a decline and assert loadScript was never called. Simulate a revocation after a grant and assert the teardown function was called for every vendor in the denied category.
None of them needs a browser and all three run in milliseconds, which means they belong in the ordinary test suite rather than in a separate compliance exercise. That placement is the point: a property checked on every commit stays true, and a property checked during an annual review is true for about a week after each review.
Common Pitfalls
- Several components subscribing independently. They disagree during hydration and there is no single place to log or to tear down.
- Omitting the teardown branch. Revocation stops future loads and nothing else.
- Using a boolean store. It cannot express pending, so the initial state is indistinguishable from a refusal.
- Declaring the deduplication map per component. Navigation then re-injects the vendor.
Frequently Asked Questions
Should the store be persisted?
The decision should be, and the store should read it on initialisation. Persisting to a cookie rather than to localStorage is worth the small extra effort because a cookie can be read by the server, which lets a returning visitor’s first paint already reflect their choice.
Keep the persisted record minimal and versioned: a category map and a policy version, nothing identifying. And treat the platform as authoritative over the persisted copy — the copy exists to render the likely state early, not to replace the thing that can observe a change made in another tab.
What if a vendor has no teardown API?
Then a reload is the only honest way to stop it, and the interface should say so rather than pretending otherwise. Tear down everything you can in place, then tell the visitor plainly that completing the change requires a refresh, with a button that performs it.
Record which vendors are in this position in the registry, because it is a procurement fact as much as a technical one. A vendor that cannot be stopped without discarding the page is a constraint on your consent implementation, and it is a much easier conversation to have with a supplier when it is written down alongside everything else you know about them.
Does this work with Svelte 5 runes?
The shape is unchanged; only the primitives differ. A rune-based state object replaces the writable store, and a derived rune replaces the derived store, but the architecture — one module owning the platform, one registry, one subscription in the root layout, an explicit teardown branch — is the part that matters and it carries over directly.
Resist the temptation to spread reactive state across components because runes make it convenient. The argument for a single owner is about having one place to log, one place to tear down and one place to reason about timing, none of which changes with the reactivity implementation.
Where should the store live — a module, or context?
A module. Consent is application-wide rather than subtree-scoped, so the context API adds a provider component and a lookup for no benefit, and it makes the store unreachable from plain modules such as your loader and your analytics wrapper.
The one thing to be careful about is that a module-scope store is shared across requests on the server, exactly as any other module-level state is. Because this store is only ever populated in the browser — the platform cannot resolve on the server — that hazard does not arise here, but it is worth knowing why rather than discovering it with a different piece of state later.