Skip to content

Commit 279688a

Browse files
fix(core): stage preference toggles in a draft until save (#162)
1 parent 2a54dce commit 279688a

27 files changed

Lines changed: 273 additions & 49 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@policystack/core": minor
3+
"@policystack/react": minor
4+
"@policystack/vue": minor
5+
"@policystack/svelte": minor
6+
"@policystack/solid": minor
7+
"@policystack/angular": minor
8+
---
9+
10+
Consent preference toggles are now staged. `toggle()` writes to `state.draft` instead of live decisions, and nothing is gated, persisted, or script-loaded until `save()` promotes the draft in one step — scripts no longer load on checkbox tick before "Save", and returning visitors no longer get their stored record rewritten on every tick (#157). Leaving the preferences route without saving discards the draft.
11+
12+
API changes: `toggle(key)` no longer accepts `ActionOptions` (name the record source at `save()` instead), and `ConsentState` gains a required `draft` field. Per-category `granted` accessors in all framework bindings read `draft ?? decisions` so checkboxes respond instantly; custom panels rendering checkboxes from raw `decisions` should apply the same merge.

‎apps/web/content/blog/astro-cookie-banner.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ Astro's `<script>` tags are bundled by Vite, so this looks like a normal module
144144
prefs.hidden = state.route !== "preferences";
145145
for (const cb of checkboxes) {
146146
const key = cb.dataset.category;
147-
if (key) cb.checked = state.decisions[key] ?? false;
147+
// draft holds staged (unsaved) toggles; decisions is what save() applied.
148+
if (key) cb.checked = (state.draft ?? state.decisions)[key] ?? false;
148149
}
149150
};
150151
store.subscribe(render);

‎apps/web/content/docs/consent/angular.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,16 @@ export class BannerComponent {
7777
}
7878
```
7979

80-
Signal properties: `route`, `categories`, `decisions`, `jurisdiction`, `policyVersion`, `decidedAt`, `repromptReason`, `state`.
80+
Signal properties: `route`, `categories`, `decisions`, `draft`, `jurisdiction`, `policyVersion`, `decidedAt`, `repromptReason`, `state`.
8181

8282
Methods: `acceptAll`, `acceptNecessary`, `reject`, `toggle`, `save`, `setRoute`, `has`, `getConsentRecord`, `getPreviousRecord`.
8383

8484
### `injectCategory(key)`
8585

8686
Granular per-category access. Must be called inside an injection context (e.g. a component constructor or field initializer).
8787

88+
`toggle` stages the change and `granted()` reflects it instantly (it reads the pending `state.draft`), but nothing is applied — `has()`, `<ConsentGate>`, script gating, and storage only change when `save()` promotes the draft. Leaving the preferences route without saving discards it.
89+
8890
```ts
8991
import { Component } from "@angular/core";
9092
import { injectCategory } from "@policystack/angular/consent";

‎apps/web/content/docs/consent/core.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ store.acceptAll();
3535

3636
The store's surface: `getState()`, `subscribe()`, `acceptAll()`, `acceptNecessary()`, `reject()`, `toggle(key)`, `save()`, `setRoute()`, `has(expr)`, `getConsentRecord()`, `getPreviousRecord()`, `refreshJurisdiction()`. See [`types.ts`](./src/types.ts) for the full shape.
3737

38+
### Staged preferences (`state.draft`)
39+
40+
`toggle(key)` never changes live consent. It stages the flip in `state.draft`, and gating (`has()` / `<ConsentGate>`), storage, and gated scripts keep reading `decisions` until `save()` promotes the draft in one step and stamps `decidedAt`. Leaving the preferences flow without saving — any `setRoute` that does not land on `"preferences"` — discards the draft, so "Back" genuinely abandons unsaved edits and nothing was loaded or persisted in the meantime.
41+
42+
Render preference checkboxes from `draft ?? decisions` so the panel responds instantly; the framework bindings' per-category `granted` accessor does exactly this.
43+
3844
## Storage adapters
3945

4046
Decisions persist via a `StorageAdapter` passed to `createConsentStore({ adapter })`. Three adapters ship as subpath imports:
@@ -104,7 +110,7 @@ const store = createConsentStore({ categories });
104110
// Brave (and any browser asserting GPC) starts with all opt-outs denied.
105111
```
106112

107-
Once a user makes an explicit decision (`acceptAll`, `toggle`, etc.) the resulting record has `state.source === "user"` and is preserved on reload — `applyGPC` will not overwrite it.
113+
Once a user makes an explicit decision (`acceptAll`, `save`, etc.) the resulting record has `state.source === "user"` and is preserved on reload — `applyGPC` will not overwrite it.
108114

109115
To scope GPC to the legally-required US states only:
110116

@@ -158,7 +164,7 @@ type ConsentRecord = {
158164
- `"api"` — set via a programmatic call (override with `acceptAll({ source: "api" })`, etc.).
159165
- `"import"` — migrated from a legacy or unrecognised record.
160166

161-
The store infers `source` from `state.route` at the moment the decision is taken; pass `{ source }` to any action to override it.
167+
The store infers `source` from `state.route` at the moment the decision is taken; pass `{ source }` to any decision action (`acceptAll`, `acceptNecessary`, `reject`, `save`) to override it. `toggle` takes no options — it only stages a draft, and the eventual `save` names the source.
162168

163169
Read the current record via `store.getConsentRecord()` (or the binding-level `useConsent().getConsentRecord()`). It returns `null` until a decision has been recorded.
164170

@@ -184,7 +190,7 @@ store.getConsentRecord();
184190

185191
Records produced by older versions of Consent are tolerated on read: missing fields fall back to safe defaults, the legacy `source: "user"` flag is mapped to `"banner"`, and any other unrecognised legacy source becomes `"import"`. The next user decision rewrites the record in the v1 shape.
186192

187-
GPC alone does not produce a record — the visitor has not made a decision. `getConsentRecord()` keeps returning `null` until the user accepts, rejects, saves, or toggles a category.
193+
GPC alone does not produce a record — the visitor has not made a decision. `getConsentRecord()` keeps returning `null` until the user accepts, rejects, or saves their preference changes.
188194

189195
## Re-consent triggers
190196

‎apps/web/content/docs/consent/react.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ function Banner() {
5959

6060
Granular per-category access. Returns `{ granted, toggle }`.
6161

62+
`toggle` stages the change and `granted` reflects it instantly (it reads the pending `state.draft`), but nothing is applied — `has()`, `<ConsentGate>`, script gating, and storage only change when `save()` promotes the draft. Leaving the preferences route without saving discards it.
63+
6264
```tsx
6365
import { useCategory } from "@policystack/react/consent";
6466

‎apps/web/content/docs/consent/solid.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ function Banner() {
6363

6464
Granular per-category access.
6565

66+
`toggle` stages the change and `granted()` reflects it instantly (it reads the pending `state.draft`), but nothing is applied — `has()`, `<ConsentGate>`, script gating, and storage only change when `save()` promotes the draft. Leaving the preferences route without saving discards it.
67+
6668
```tsx
6769
import { useCategory } from "@policystack/solid/consent";
6870

‎apps/web/content/docs/consent/svelte.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ Returns a reactive object whose properties are tracked via `$state`. Read direct
6565

6666
Granular per-category access.
6767

68+
`toggle` stages the change and `granted` reflects it instantly (it reads the pending `state.draft`), but nothing is applied — `has()`, `<ConsentGate>`, script gating, and storage only change when `save()` promotes the draft. Leaving the preferences route without saving discards it.
69+
6870
```svelte
6971
<script lang="ts">
7072
import { getCategory } from "@policystack/svelte/consent";

‎apps/web/content/docs/consent/vue.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const { route, decisions, acceptAll, acceptNecessary, setRoute } = useConsent();
5959

6060
Granular per-category access. Returns a `granted` computed and a `toggle` action.
6161

62+
`toggle` stages the change and `granted` reflects it instantly (it reads the pending `state.draft`), but nothing is applied — `has()`, `<ConsentGate>`, script gating, and storage only change when `save()` promotes the draft. Leaving the preferences route without saving discards it.
63+
6264
```vue
6365
<script setup lang="ts">
6466
import { useCategory } from "@policystack/vue/consent";

‎packages/angular/src/category.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ export type CategoryRef = {
66
toggle: () => void;
77
};
88

9+
// `granted` is the checkbox view and includes staged draft edits; effective
10+
// consent (`has()` / <ConsentGate>) only moves on save().
911
export function injectCategory(key: string): CategoryRef {
1012
const consent = inject(ConsentService);
1113
return {
12-
granted: computed(() => consent.decisions()[key] === true),
14+
granted: computed(() => (consent.draft() ?? consent.decisions())[key] === true),
1315
toggle: () => consent.toggle(key),
1416
};
1517
}

‎packages/angular/src/consent.service.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export class ConsentService {
2121
readonly route: Signal<Route> = computed(() => this._state().route);
2222
readonly categories: Signal<Category[]> = computed(() => this._state().categories);
2323
readonly decisions: Signal<Record<string, boolean>> = computed(() => this._state().decisions);
24+
readonly draft: Signal<Record<string, boolean> | null> = computed(() => this._state().draft);
2425
readonly jurisdiction: Signal<JurisdictionId | null> = computed(() => this._state().jurisdiction);
2526
readonly policyVersion: Signal<string> = computed(() => this._state().policyVersion);
2627
readonly decidedAt: Signal<string | null> = computed(() => this._state().decidedAt);
@@ -45,8 +46,8 @@ export class ConsentService {
4546
this.store.reject(opts);
4647
}
4748

48-
toggle(category: string, opts?: ActionOptions): void {
49-
this.store.toggle(category, opts);
49+
toggle(category: string): void {
50+
this.store.toggle(category);
5051
}
5152

5253
save(opts?: ActionOptions): void {

0 commit comments

Comments
 (0)