fix(growth): preserve non-accept consent decisions on banner re-init by seanoliver · Pull Request #45187 · supabase/supabase · GitHub
Skip to content

fix(growth): preserve non-accept consent decisions on banner re-init#45187

Merged
seanoliver merged 2 commits intomasterfrom
sean/growth-790-cookie-banner-re-prompts-users-with-prior-non-accepted
Apr 23, 2026
Merged

fix(growth): preserve non-accept consent decisions on banner re-init#45187
seanoliver merged 2 commits intomasterfrom
sean/growth-790-cookie-banner-re-prompts-users-with-prior-non-accepted

Conversation

@seanoliver
Copy link
Copy Markdown
Contributor

@seanoliver seanoliver commented Apr 23, 2026

Problem

Cookie banner keeps re-prompting GDPR users who denied consent or made a partial opt-out via Privacy Settings — they can't get rid of it. Reported by Christian Gedde-Dahl (Front SU-362240, mygame.no) and a Supabase support engineer independently.

The March fix for FE-2648 handled the accept case — users got their banner dismissal stomped when GTM's Usercentrics integration migrated localStorage from uc_settings to ucData/ucString. But that fix only recognized uniformly-accepted ucData, so any other shape (deny-all, essentials-plus-some-tracking, partial opt-out via the Privacy Settings modal) fell through and was treated as "no prior decision." Banner re-prompts on every page load.

Christian's and his colleague's ucData.consent.services showed 13 services accepted (essentials + functional) and 4 tracking services denied — the shape you get from toggling off the Marketing category in Privacy Settings. Our detection ignored it.

Changes

  • detectPriorConsent now returns a discriminated union — null, { kind: 'uniform-accept' }, or { kind: 'decisions'; decisions } — so we restore per-service state faithfully instead of flattening to deny-all.
  • Parse uc_settings for the fast cross-app nav case. The old fallback treated uc_user_interaction === "true" as uniform-accept, which silently upgraded deny users (GDPR violation waiting to happen). Now the flag is just a gate confirming the user actually interacted, and we read real decisions from uc_settings.services[].
  • Extracted the post-init orchestration from initUserCentrics into exported applyPriorDecisionToSDK(UC, initialUIValues, priorDecision) so it's unit-testable without mocking the dynamic SDK import.
  • Added a coverage cross-check: if the Usercentrics ruleset has a non-essential service that isn't in the user's stored decisions, force a re-prompt rather than silently defaulting the new service. Essentials are skipped because the SDK forces them on regardless.
  • Everything fails closed on partial ucData / uc_settings corruption. Cherry-picking the valid subset would bias toward over-consent, which is the worst-direction bias in this domain.

Testing

27 unit tests covering detectPriorConsent parsing (both ucData and uc_settings paths, fail-closed on malformed or partially-corrupt blobs, combined scenarios) and applyPriorDecisionToSDK orchestration (uniform accept, decisions with full coverage, uncovered-non-essential compliance guard, essentials-only negative control, null fallthrough, SDK-already-consented passthrough).

Can't fully repro on staging — CSP blocks GTM on preview, so the ucData migration never fires. Same limitation as the original FE-2648 fix. Will verify in production post-merge by asking Christian to reload and watching support ticket volume.

Reviewed twice by Codex with a full iteration between passes; final pass found no functional blockers.

GROWTH-790

Summary by CodeRabbit

  • Bug Fixes

    • Fail-closed validation for stored consent data: malformed, partial, or missing entries now yield null and avoid unsafe restoration.
    • Improved precedence and fallback so corrupt prior data won’t incorrectly restore consent.
  • Refactor

    • Consent detection now returns richer prior-decision results (uniform accept, per-service decisions, or null).
    • Applying prior decisions to the SDK uses stricter coverage checks before restoring per-service consent.
  • Tests

    • Expanded tests covering varied stored-consent shapes, gating rules, precedence, recovery, and SDK application behavior.

Users in GDPR regions who denied or partially opted out via Privacy
Settings were getting re-prompted on every page load. The March
FE-2648 fix only recognized uniformly-accepted ucData — any other
shape (deny-all, mixed, partial opt-out) fell through and was treated
as no prior decision.

detectPriorConsent now returns per-service decisions, with uc_settings
parsing for the fast cross-app nav case (previously treated as uniform
accept, which silently upgraded deny users). Orchestration extracted
from initUserCentrics into applyPriorDecisionToSDK with a coverage
cross-check that re-prompts on newly-added ruleset services. All
parse paths fail closed on corruption to avoid over-consent bias.

GROWTH-790
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 23, 2026

@supabase
Copy link
Copy Markdown

supabase Bot commented Apr 23, 2026

This pull request has been ignored for the connected project xguihxuzqibwxjnimxev because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 875a10d9-791f-41cc-8f41-057ec01f4971

📥 Commits

Reviewing files that changed from the base of the PR and between 91117fc and acc146a.

📒 Files selected for processing (2)
  • packages/common/consent-state.test.ts
  • packages/common/consent-state.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/common/consent-state.test.ts

📝 Walkthrough

Walkthrough

detectPriorConsent now returns a structured PriorConsentDecision (null | {kind:'uniform-accept'} | {kind:'decisions', decisions: UserDecision[]}) and localStorage parsing fails closed on malformed data. A new applyPriorDecisionToSDK applies prior decisions to the Usercentrics SDK after init with coverage checks and banner suppression.

Changes

Cohort / File(s) Summary
Tests
packages/common/consent-state.test.ts
Refactored and expanded tests: detectPriorConsent now asserts structured return types and null-on-malformed data; added applyPriorDecisionToSDK suite covering acceptAll/updateServices flows, banner/toast suppression, coverage/GROWTH-790 guards, and edge cases.
Core Logic
packages/common/consent-state.ts
detectPriorConsent signature changed to return PriorConsentDecision; parsing of ucData and gated uc_settings added with strict validation and fail-closed behavior. New exported applyPriorDecisionToSDK replays uniform or per-service decisions (with coverage checks) and suppresses banner when appropriate; handles UC.init() error path by only honoring uniform-accept.
API Surface
packages/common/consent-state.ts
Added PriorConsentDecision type export and applyPriorDecisionToSDK(UC, initialUIValues, priorDecision) export; updated detectPriorConsent return type.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ConsentState
    participant UC as Usercentrics
    Client->>ConsentState: detectPriorConsent()
    activate ConsentState
    ConsentState->>ConsentState: parse localStorage (ucData / uc_settings)
    alt valid uniform accept
        ConsentState-->>Client: {kind: "uniform-accept"}
    else valid per-service decisions
        ConsentState-->>Client: {kind: "decisions", decisions: [...]}
    else malformed / missing
        ConsentState-->>Client: null
    end
    deactivate ConsentState

    Client->>UC: UC.init()
    activate UC
    UC-->>Client: initialized / throws
    deactivate UC

    Client->>ConsentState: applyPriorDecisionToSDK(UC, initialUIValues, priorDecision)
    activate ConsentState
    alt priorDecision = {kind:"uniform-accept"}
        ConsentState->>UC: acceptAllServices()
        ConsentState->>Client: suppress banner/toast
    else priorDecision = {kind:"decisions", decisions}
        ConsentState->>ConsentState: check coverage vs current non-essential services
        alt coverage complete
            ConsentState->>UC: updateServices(decisions)
            ConsentState->>Client: suppress banner/toast
        else coverage incomplete
            ConsentState->>Client: show banner (do not restore)
        end
    else priorDecision = null
        ConsentState->>Client: show banner
    end
    deactivate ConsentState
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I dug through storage, tidy and neat,

Found answers uniform or choices discrete.
I whisper to SDK, "Accept" or "apply,"
Banner tucked under, if coverage is shy.
Hooray — consent hops back, neat as a treat!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: preserving non-accept consent decisions when the banner re-initializes, which is the core fix addressing the reported bug.
Description check ✅ Passed The description is comprehensive and well-structured, covering the problem statement, detailed changes, testing approach, and relevant context. All required template sections are addressed with substantial detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sean/growth-790-cookie-banner-re-prompts-users-with-prior-non-accepted

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@seanoliver seanoliver merged commit 1f31858 into master Apr 23, 2026
32 checks passed
@seanoliver seanoliver deleted the sean/growth-790-cookie-banner-re-prompts-users-with-prior-non-accepted branch April 23, 2026 22:32
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 23, 2026

Braintrust eval report

Assistant (master-1776983630)

Score Average Improvements Regressions
Completeness 96.3% (-2pp) 1 🟢 2 🔴
Conciseness 30.6% (+4pp) 6 🟢 5 🔴
Correctness 93.3% (-7.000000000000001pp) - 1 🔴
Docs Faithfulness 69% (+3pp) 3 🟢 3 🔴
Goal Completion 88.9% (+3pp) 4 🟢 3 🔴
Knowledge Usage 100% (+3pp) 1 🟢 -
SQL Identifier Quoting 100% (+0pp) - -
SQL Validity 100% (+0pp) - -
Tool Usage 71.4% (+0pp) - -
Time_to_first_token 0.01tok (+0tok) 4 🟢 14 🔴
Llm_calls 11.09 (+0.63) 12 🟢 2 🔴
Tool_calls 3.83 (+0.02) 7 🟢 9 🔴
Errors 0 (+0) - -
Llm_errors 0 (+0) - -
Tool_errors 0 (+0) - -
Prompt_tokens 24435.52tok (-659.09tok) 6 🟢 12 🔴
Prompt_cached_tokens 5376tok (-1099.85tok) 11 🟢 6 🔴
Prompt_cache_creation_tokens 0tok (+0tok) - -
Completion_tokens 615.65tok (+7.09tok) 10 🟢 8 🔴
Completion_reasoning_tokens 104.52tok (+5.91tok) 10 🟢 8 🔴
Completion_accepted_prediction_tokens 0tok (+0tok) - -
Completion_rejected_prediction_tokens 0tok (+0tok) - -
Completion_audio_tokens 0tok (+0tok) - -
Total_tokens 25051.17tok (-652tok) 7 🟢 11 🔴
Estimated_cost 0$ (+0$) 7 🟢 10 🔴
Duration 17.77s (+2.48s) 5 🟢 13 🔴
Llm_duration 10.45s (-0.01s) 12 🟢 6 🔴

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants