Skip to content

Session Scope

View raw .md

How a controller that caches one user's (or one team's) data is reset when the signed-in identity changes, using magic's SessionScoped contract and the identity key this package declares.

On this page

Introduction#

Any controller that caches data belonging to one signed-in user, or to one of that user's teams, has to be told when that user changes. Magic core ships the two pieces: the SessionScoped contract, which a controller (or a repository) implements to declare "my rows belong to exactly one session", and SessionScope, which watches Auth.stateNotifier and resets every scoped holder whenever the authenticated identity changes.

This package's part is the identity: MagicStarterServiceProvider sets SessionScope.identity to <userId>:<teamId> in register(), so a team switch by the same user counts as a change. Attaching stays your app's call (see Wiring the Sync).

The Leak#

Magic resolves controllers as type-keyed singletons: Magic.findOrPut(ProjectController.new) returns the same instance for the whole process lifetime. The initial fetch usually lives in onInit(), and MagicStatefulViewState only calls it while the controller has not been initialised yet, so onInit() runs once per instance lifetime, not once per mount. Follow the consequence through:

  1. User A signs in, opens the projects screen. ProjectController.onInit() fetches A's projects.
  2. A signs out. The controller instance survives: nothing disposes it, and its rows are still in memory.
  3. User B signs in on the same device and opens the projects screen. The controller is already initialised, so onInit() does not run and no fetch happens.
  4. B is looking at A's projects until the app is restarted.

The same happens without a logout: a team switch changes which rows the API returns, while every cached controller keeps the old team's data.

The Contract#

Implement magic's SessionScoped on every controller that caches session-scoped data. A repository or any other non-controller holder implements it too and registers itself with SessionScope.register(holder) (and SessionScope.unregister(holder) on dispose); controllers are found in Magic.controllers without registration.

dart
import 'package:magic/magic.dart';

class ProjectController extends MagicController
    with MagicStateMixin<List<Project>>
    implements SessionScoped {
  static ProjectController get instance =>
      Magic.findOrPut(ProjectController.new);

  final List<Project> projects = [];

  @override
  Future<void> onInit() async {
    super.onInit();
    await loadProjects();
  }

  @override
  Future<void> resetForSession() async {
    // 1. Publish the cleared state BEFORE the refetch.
    projects.clear();
    setLoading();

    // 2. Refetch for whoever is authenticated now.
    await loadProjects();
  }
}

resetForSession() is the only member of the contract. It is called on login and on team switch, and it is expected to drop every cached row, publish that cleared state, and then refetch.

Clear Before You Refetch#

resetForSession() is destructive on purpose, and the clear must land before the refetch is attempted.

An ordinary reload() is deliberately non-destructive: when a refresh fails, keeping the last-known-good rows is right, because a five-second network blip should not blank a dashboard the user is reading. Across an identity change that behaviour is exactly wrong, since the rows on screen belong to the previous session:

Ordinary reload() resetForSession()
Refetch succeeds New rows New rows
Refetch fails Previous rows stay (same user, stale data) Must be empty (the previous rows belong to another session)

So the order inside resetForSession() is load-bearing:

dart
@override
Future<void> resetForSession() async {
  projects.clear();      // 1. Drop the previous session's rows.
  setLoading();          // 2. Publish it, so the UI is empty even if step 3 throws.
  await loadProjects();  // 3. Only now go to the network.
}

Wiring the Sync#

Attach SessionScope once, from your app's service provider boot(), after MagicStarterServiceProvider has booted and as the last Auth.stateNotifier listener you add. The starter never attaches it for you:

dart
import 'package:magic/magic.dart';
import 'package:magic_starter/magic_starter.dart';

class AppServiceProvider extends ServiceProvider {
  AppServiceProvider(super.app);

  @override
  void register() {}

  @override
  Future<void> boot() async {
    MagicStarter.useUserModel((data) => User.fromMap(data));
    // MagicStarter.useTeamResolver(...) goes here, see Teams.

    // Register last: notification polling, realtime subscriptions and locale
    // should already point at the new session before its data is refetched.
    SessionScope.attach();
  }
}

attach() subscribes to Auth.stateNotifier and records the identity the app boots with. It resets nothing at boot: no view has resolved a controller yet, so there is nothing cached to clear, and the first real change is the first one that resets.

Member Purpose
SessionScope.attach() Subscribe to auth state changes. Idempotent; a second call while attached is a no-op.
SessionScope.detach() Unsubscribe and forget the recorded identity. Mainly for tests.
SessionScope.isAttached Whether a subscription is active.
SessionScope.register(holder) / unregister(holder) Add or remove a non-controller holder.
SessionScope.identity The identity resolver. This package sets it in register(); see below.

The Identity Key#

The sync compares a string key, not object identity. Magic's default is the user id alone; MagicStarterServiceProvider replaces it in register() with a two-leg key:

text
<userId>:<teamId>
Leg Source Reads null when
userId Auth.id() Never while Auth.check() is true
teamId MagicStarter.currentTeamId(), the team resolver's current team id as a string Teams are disabled, or no team resolver is registered

Two legs, because a team-scoped API scopes its endpoints to the current team and not just to the user: switching teams changes every response body while Auth.id() stays put. Reading the team through the starter's own team resolver keeps this package independent of your User model.

The whole key is null when, and only when, nobody is signed in (Auth.check() is the discriminator). Two rules follow:

  • An unchanged key is a no-op. Auth.stateNotifier also bumps on a plain session restore, and without this check every incidental bump would stampede a fresh wave of refetches.
  • Only a change to a non-null key resets.

Team switching triggers the sync for free: MagicStarter.switchTeam() goes through MagicStarterTeamController.switchTeam(), which applies the fresh user the switch answer carries (falling back to Auth.restore() only when the answer carries none) and bumps Auth.stateNotifier either way. If you implement your own switch path, apply the answer's user directly rather than always calling Auth.restore(), which re-applies the cached user, still on the previous team.

An app that needs a different key (an organisation leg, say) sets SessionScope.identity in its own provider's boot(), after this one.

Why Logout Does Not Reset#

On logout the key goes from 1:10 to null, and nothing is reset. There is no identity to refetch for: a reset on logout would clear every controller and then fire a wave of requests from the login screen, where the token is already gone, so they could only come back 401.

The stale rows sit unreachable in memory behind the auth guard, and the next login resets them before any authenticated view renders, because Auth.login bumps Auth.stateNotifier before it returns to the code that navigates. The key is still recorded on logout (as null), which is what makes signing back in as the same user count as a change (null to 1:10) and refetch.

Error Isolation#

Each resetForSession() is fired independently: Auth.stateNotifier listeners are synchronous, so the sync does not await them, and each future carries its own error guard. One controller throwing does not stop the others, and the failure is logged rather than swallowed:

text
[SessionScope] session reset failed: <error>

Handle your own failures the way any other reload does (setError(), a fallback message). A throw that escapes leaves that one controller cleared and unrefetched: safe, but an empty screen with no explanation.

Testing#

SessionScope holds process-wide statics that neither MagicApp.reset() nor Magic.flush() clears, so call detach() in both setUp and tearDown. detach() keeps the identity resolver, so a test that boots the starter provider restores the one it found:

dart
setUp(() {
  MagicApp.reset();
  Magic.flush();
  // bind 'log', 'auth' and 'magic_starter', register the team resolver...
  SessionScope.detach();
});

tearDown(() => SessionScope.detach());

test('drops the previous tenant rows when another user logs in', () async {
  await Auth.login({'token': 'a'}, userA);
  SessionScope.attach();

  final controller = Magic.put(ProjectController());
  controller.projects.addAll(projectsOfA);

  await Auth.login({'token': 'b'}, userB);
  await pumpEventQueue();

  expect(controller.projects, isNot(contains(projectsOfA.first)));
});

detach() unsubscribes from the exact notifier instance it subscribed to. That matters in tests: Auth.stateNotifier resolves through the container, so rebinding the guard hands back a different notifier.

Gotchas#

Mistake Fix
Expecting the starter to attach the sync It only declares the identity in register(). Call SessionScope.attach() yourself, last in your provider's boot().
Writing resetForSession() as await reload() Clear the rows and publish the cleared state first, then refetch. A failed refetch must leave an empty screen.
Switching teams through your own path with Auth.restore() Apply the switch answer's user directly, as MagicStarterTeamController.switchTeam() does, or the identity flips late.
Caching rows in a repository A non-controller holder is not found automatically: implement SessionScoped and call SessionScope.register(holder).
Leaving SessionScope attached between tests Call SessionScope.detach() in setUp and tearDown.
  • State Management: MagicController, MagicStateMixin and the singleton accessor pattern this contract sits on.
  • Teams: the team resolver that supplies the second leg of the identity key.
  • Authentication: Auth.stateNotifier, login, logout and session restore.
  • Service Provider: where attach() belongs in the boot sequence.
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.