Skip to content

fix(supabase_flutter)!: persist the session with SharedPreferencesAsync - #1680

Merged
spydon merged 11 commits into
mainfrom
breaking/shared-preferences-async
Aug 12, 2026
Merged

fix(supabase_flutter)!: persist the session with SharedPreferencesAsync#1680
spydon merged 11 commits into
mainfrom
breaking/shared-preferences-async

Conversation

@spydon

@spydon spydon commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Bug fix, breaking change.

What is the current behavior?

SharedPreferencesLocalStorage and SharedPreferencesGotrueAsyncStorage, the storage implementations Supabase.initialize uses by default, write through the legacy SharedPreferences API. On Windows and Linux both APIs rewrite the same shared_preferences.json in full from their own in-memory cache, so an app that has migrated its own code to SharedPreferencesAsync has its session dropped and the user is signed out on the next launch.

Closes #1276, and supersedes #1164.

What is the new behavior?

Both implementations use SharedPreferencesAsync. The minimum Flutter version is already well past the 3.22 that API needs, so nothing had to be bumped.

SharedPreferencesLocalStorage.initialize() moves a session written by v2 over to the new store, so existing users stay signed in:

  • It runs once. A <persistSessionKey>-legacy-migrated key in the new store records that it happened, so the legacy store is read at most once and a signed-out user is never signed back in.
  • Deleting the legacy entry is not enough on its own to get that guarantee: on Windows and Linux a later write through either API can bring a deleted entry back. The delete still happens, but only so a stale token does not lie around.
  • A session already in the new store wins, so a stale legacy one never overwrites it.
  • The legacy store is reload()ed first, since the instance is shared with the app and may have been loaded before the session was last written.

On web nothing changes: the session still goes into window.localStorage under the same key, as it did before.

SharedPreferencesGotrueAsyncStorage no longer needs its initialization Completer, since SharedPreferencesAsync has no asynchronous setup. It is created on first use so that constructing the storage does not require the bindings to be initialized yet.

The pending PKCE code verifier is not migrated. It is valid for the length of one sign-in round trip, and the failure mode of losing it is a retryable sign-in.

Superseding #1164

That PR made the same core change. Here is where each point from its review thread landed:

Raised there Here
Windows loses the session when the app uses SharedPreferencesAsync Fixed, both default storages moved to the async API
Transfer the existing token to the new store on initialization Done, and made one-time, order-safe and precedence-aware as described above
Needs Flutter 3.22 / Dart 3.4 for SharedPreferencesAsync Already satisfied on main: Flutter 3.35, Dart 3.9, shared_preferences: ^2.5.5
Prefer two storage classes over a useSharedPreferencesAsync flag Neither. v3 uses the async API unconditionally, which is what was asked for on that thread instead
A heavy breaking change for apps that still use the legacy API themselves MIGRATION.md now states per platform what mixing costs, including that a session write can drop the app's own legacy preferences, and ships a LocalStorage to copy for staying on the legacy store
SharedPreferencesGotrueAsyncStorage left on the legacy API Moved over too, so the PKCE code verifier does not keep the mixing problem alive
No test coverage (0 of 8 changed lines) Six migration tests, listed below

One comment on that thread reported refresh_token_already_used on a cold start after token expiry. That is an auth-refresh problem rather than a storage one, so it is deliberately out of scope here.

Additional context

MIGRATION.md documents the change, including a LocalStorage implementation to copy for anyone who wants to keep the session in the legacy store for now.

Tests cover the migration: a legacy session is moved over, the legacy entry is gone afterwards, an existing session in the new store wins, a session that was signed out of is not restored on the next launch, the same holds when both stores had a session, and a legacy entry written back after the migration is ignored. They live in a non-web test file, since on web there is nothing to migrate. The other test files now mock both shared_preferences APIs through a mockSharedPreferences helper.

Summary by CodeRabbit

  • New Features
    • Mobile session persistence now uses the modern asynchronous preferences API.
    • Existing sessions migrate automatically from legacy storage.
  • Bug Fixes
    • Prevented session loss and storage conflicts during migration.
    • Signed-out sessions are not restored.
    • Preserved browser localStorage behavior on web.
  • Documentation
    • Added migration guidance and updated custom storage documentation, including compatibility considerations for legacy storage.

The default storage implementations wrote through the legacy
SharedPreferences API. Mixing that API with SharedPreferencesAsync in one
app drops values on some platforms, so an app that had migrated its own
code to the new API lost the persisted session.

SharedPreferencesLocalStorage and SharedPreferencesGotrueAsyncStorage now
use SharedPreferencesAsync, and initialize() moves a session written by v2
over to the new store so users stay signed in.

Closes #1276
@spydon
spydon requested a review from a team as a code owner August 10, 2026 13:23
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change migrates Flutter session persistence to SharedPreferencesAsync, transfers legacy sessions during initialization, updates tests and mocks, and documents migration behavior and mixed API usage.

Changes

Session persistence migration

Layer / File(s) Summary
Async session storage implementation
packages/supabase_flutter/lib/src/local_storage.dart
Session storage now uses SharedPreferencesAsync. Initialization migrates legacy sessions and removes migrated entries. GoTrue storage creates the async preference instance lazily.
Storage test and mock updates
packages/supabase_flutter/test/utils.dart, packages/supabase_flutter/test/local_storage_migration_test.dart, packages/supabase_flutter/test/storage_test.dart, packages/supabase_flutter/test/deep_link_test.dart, packages/supabase_flutter/test/dispose_test.dart, packages/supabase_flutter/test/initialization_test.dart, packages/supabase_flutter/pubspec.yaml
Shared test setup resets both preference stores. Tests cover migration, cleanup, precedence, signed-out sessions, and async persistence.
Migration and persistence documentation
MIGRATION.md, packages/supabase_flutter/README.md, AGENTS.md
Documentation describes SharedPreferencesAsync, legacy-session migration, mixed API behavior, and custom legacy storage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SharedPreferencesLocalStorage
  participant SharedPreferencesAsync
  participant SharedPreferences
  SharedPreferencesLocalStorage->>SharedPreferencesAsync: Read current session
  SharedPreferencesLocalStorage->>SharedPreferences: Read legacy session
  SharedPreferences->>SharedPreferences: Remove migrated entry
  SharedPreferencesLocalStorage->>SharedPreferencesAsync: Write migrated session
Loading

Suggested labels: v3

Suggested reviewers: tr00d

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary breaking change: session persistence now uses SharedPreferencesAsync.
Linked Issues check ✅ Passed The changes migrate session storage, document the migration, preserve existing sessions, and test the SharedPreferences API conflict fix required by issue [#1276].
Out of Scope Changes check ✅ Passed The code, tests, dependency update, and documentation changes directly support the SharedPreferencesAsync migration and its compatibility requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch breaking/shared-preferences-async

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
MIGRATION.md (1)

389-389: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Wrap the Dart example to the configured line length.

Line 389 exceeds 80 characters. Wrap the persistSessionKey argument as dart format would.

As per coding guidelines, Dart code must use dart format and an 80-character line length.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MIGRATION.md` at line 389, Wrap the persistSessionKey argument in the Dart
example to comply with dart format’s 80-character line length, preserving the
existing expression and behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/supabase_flutter/lib/src/local_storage.dart`:
- Around line 92-106: Update _migrateLegacySession so it removes the legacy
persistSessionKey from SharedPreferences before returning when _preferences
already contains the key. Add a regression test that seeds both stores, calls
removePersistedSession(), recreates the storage, and verifies no session is
restored.

In `@packages/supabase_flutter/README.md`:
- Line 507: Update the opening phrase in the session persistence documentation
from “As default” to “By default,” leaving the remainder of the guidance
unchanged.

---

Nitpick comments:
In `@MIGRATION.md`:
- Line 389: Wrap the persistSessionKey argument in the Dart example to comply
with dart format’s 80-character line length, preserving the existing expression
and behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 19a2762c-5456-4815-b829-23aaec258860

📥 Commits

Reviewing files that changed from the base of the PR and between 32d5ece and b02fe97.

📒 Files selected for processing (10)
  • AGENTS.md
  • MIGRATION.md
  • packages/supabase_flutter/README.md
  • packages/supabase_flutter/lib/src/local_storage.dart
  • packages/supabase_flutter/pubspec.yaml
  • packages/supabase_flutter/test/deep_link_test.dart
  • packages/supabase_flutter/test/dispose_test.dart
  • packages/supabase_flutter/test/initialization_test.dart
  • packages/supabase_flutter/test/storage_test.dart
  • packages/supabase_flutter/test/utils.dart

Comment thread packages/supabase_flutter/lib/src/local_storage.dart Outdated
Comment thread packages/supabase_flutter/README.md Outdated
…web file

The migration only runs on the platforms that store the session through
shared_preferences. On web the session lives in window.localStorage under
the same key as it did in v2, so there is nothing to migrate and the tests
failed there.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates Supabase Flutter session and PKCE persistence to SharedPreferencesAsync, including legacy-session migration.

Changes:

  • Replaces legacy preference storage with the asynchronous API.
  • Adds migration logic and regression tests.
  • Documents the breaking change and migration options.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
AGENTS.md Updates storage architecture guidance.
MIGRATION.md Documents migration and legacy-storage fallback.
packages/supabase_flutter/README.md Updates session-storage guidance.
packages/supabase_flutter/pubspec.yaml Adds the test platform-interface dependency.
packages/supabase_flutter/lib/src/local_storage.dart Implements asynchronous storage and legacy migration.
packages/supabase_flutter/test/utils.dart Adds shared-preferences test mocks.
packages/supabase_flutter/test/storage_test.dart Updates storage tests for the asynchronous API.
packages/supabase_flutter/test/local_storage_migration_test.dart Tests legacy-session migration scenarios.
packages/supabase_flutter/test/initialization_test.dart Uses the shared test mock.
packages/supabase_flutter/test/dispose_test.dart Uses the shared test mock.
packages/supabase_flutter/test/deep_link_test.dart Verifies persistence through the asynchronous API.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/supabase_flutter/lib/src/local_storage.dart Outdated
spydon added 2 commits August 10, 2026 16:08
Deleting the legacy entry does not make the move one-time. On the platforms
where both shared_preferences APIs rewrite one file from their own cache, a
later write through either API can bring the deleted entry back, and the
delete was skipped altogether when the new store already had a session. In
both cases the next launch after a sign-out restored the stale session and
signed the user back in.

A key in the new store now records that the move happened, so the legacy
store is read at most once. The legacy entry is still deleted, but only so
that a stale token does not lie around.
… platform

The guide framed the collision as the session going missing, which is the v2
direction. From v3 on it runs the other way as well: on Windows and Linux a
session write by the SDK can drop preferences the app wrote through the legacy
API, which is the risk raised on the superseded pull request.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/supabase_flutter/lib/src/local_storage.dart`:
- Around line 102-109: Update _migrateLegacySession so it obtains and reloads
SharedPreferences before checking _legacyMigrationKey, then removes
persistSessionKey when the migration marker already exists before returning.
Preserve the existing migration behavior for unmarked stores, and extend the
relevant local storage migration test to verify a recreated legacy session is
removed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ec980533-c7b4-4723-9a8b-724b7e3f4078

📥 Commits

Reviewing files that changed from the base of the PR and between 8266fe6 and ea0d9fe.

📒 Files selected for processing (4)
  • MIGRATION.md
  • packages/supabase_flutter/README.md
  • packages/supabase_flutter/lib/src/local_storage.dart
  • packages/supabase_flutter/test/local_storage_migration_test.dart
🚧 Files skipped from review as they are similar to previous changes (2)
  • MIGRATION.md
  • packages/supabase_flutter/README.md

Comment thread packages/supabase_flutter/lib/src/local_storage.dart Outdated
spydon added 3 commits August 10, 2026 16:47
…SessionKey (#1681)

Stacked on #1680, review that one first.

## What kind of change does this PR introduce?

Cleanup and a small feature, breaking change.

## What is the current behavior?

`supabase_flutter` still carries the leftovers of the v1 to v2
migration:

- `supabasePersistSessionKey`, a public constant whose own dartdoc says
"Only used for migration from Hive to SharedPreferences. Not actually in
use." The SDK never reads it. It is only the key v1 stored the session
under.
- A README section with `MigrationLocalStorage` and `HiveLocalStorage`
snippets to copy, which move a session out of
[hive](https://pub.dev/packages/hive) on the first launch after
upgrading from v1.

The README's `MySecureStorage` example used the constant as its storage
key, which was wrong for anything but a v1 leftover: the session lives
under the key that is passed to `LocalStorage`,
`sb-<project-ref>-auth-token` for the default storage. That key was
derived in two places (`Supabase.initialize` and, for the broadcast
channel name, `GoTrueClient`) with nothing public to call, so a custom
`LocalStorage` had no way to name the same key other than writing the
format out by hand.

## What is the new behavior?

Both leftovers are gone. Anyone still on v1 can upgrade to v2, let it
migrate the session, and then move to v3.

`defaultPersistSessionKey(url)` replaces the hand-written format. It
lives in `supabase_common`, which gotrue and supabase_flutter both
already depend on, and `supabase_flutter` exports it the way `postgrest`
and `functions_client` export `HttpMethod`:

```dart
authOptions: FlutterAuthClientOptions(
  localStorage: MySecureStorage(
    persistSessionKey: defaultPersistSessionKey(supabaseUrl),
  ),
),
```

The derivation now exists in exactly one place, and both call sites use
it. Nothing changes for the default path: `Supabase.initialize` still
fills the key in, so `Supabase.initialize(url:, publishableKey:)`
remains all a caller writes.

The `Migrating Guide` section of the README pointed at the v1 to v2
upgrade guide on the docs site, and now points at `MIGRATION.md`, which
is where the v2 to v3 changes are written down. Both docs also say
explicitly that the key is derived for you and only needs naming when
you supply your own storage, which was easy to misread before.

## Additional context

`MIGRATION.md` documents the removal, including the literal value of the
constant for anyone who copied the old example and needs to keep reading
the same key.

`sdk-compliance.yaml` drops `supabasePersistSessionKey` and registers
`defaultPersistSessionKey` under
`client.session_management.persist_session`. The capability itself stays
`implemented`. The symbol, drift and schema checks were run locally
against the base and PR symbol dumps.
…session migration

Review of the storage switch turned up four problems.

The pkce store changed API and key with no migration, so a magic link or a
password reset started on v2 could not be completed after the app updated:
the code verifier that began the flow was written through the legacy API and
was no longer visible. It is now moved over on the first read that misses.

Its constructor also stopped calling WidgetsFlutterBinding.ensureInitialized(),
which on v2 ran on every Supabase.initialize and was the only thing
initializing the binding when persistSession was false or a custom
LocalStorage was passed. Restored.

The session migration wrote the new entry only after deleting the legacy one,
so an interruption in between lost the session, and it left the legacy cache
as a pre-migration snapshot, so on Windows and Linux the app's next legacy
write dropped the session that had just been migrated. The new entry is now
written first and the legacy store is reloaded before the delete.

A failure to read the legacy store took Supabase.initialize with it, leaving
the app unable to start over a session it may not even have had. It is logged
instead.

Also batches the two reads the migration starts with, drops a redundant full
reload of the legacy store, documents the widget-test setup the new storage
needs, and counts the storage test keys instead of timestamping them, which
was flaky on web where the resolution is milliseconds.
@spydon
spydon enabled auto-merge (squash) August 11, 2026 16:06
spydon added 2 commits August 11, 2026 18:14
User.fromJson requires created_at since timestamps became DateTime, and the
mock response only carried an id, so every test in the file threw.
@spydon
spydon merged commit 41500b7 into main Aug 12, 2026
39 checks passed
@spydon
spydon deleted the breaking/shared-preferences-async branch August 12, 2026 06:31
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.

Migrate from deprecated SharedPreferences to SharedPreferencesAsync

4 participants