search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs

Session Scope

SessionScope keeps every cached controller and repository pointed at the identity that is authenticated right now, so a login after a logout, or a team switch by the same user, never leaves a previous tenant's rows on screen.

Introduction

Magic resolves controllers as Type-keyed singletons and runs onInit exactly once per instance lifetime. On a single-tenant app that is the whole point: a controller's first fetch happens once and stays put. On a team-scoped product it is a defect waiting to happen: MonitorController boots for team A, caches team A's rows, and a switch to team B (or a logout followed by a different user's login) leaves those rows on screen with nothing that would ever clear them.

Why It Exists

SessionScope is the one seam every scoped controller and repository routes through, rather than each one hand-rolling its own Auth.stateNotifier listener. It calls resetForSession() on every registered holder whenever the resolved identity changes, and does so in place: it never deletes and recreates a controller. A mounted MagicStatefulViewState holds its own controller instance directly, so a delete-then-recreate would freeze that view rather than refresh it.

Attaching

SessionScope.attach() is explicit: no core provider calls it, because it must be the LAST Auth.stateNotifier listener so realtime, polling, and locale already point at the new session before any scoped holder's data is refetched.

@override
Future boot() async {
  SessionScope.identity = () => Auth.check() ? '${Auth.id()}:$teamId' : null;
  SessionScope.attach();
}

attach() is idempotent while already attached, and records the boot identity before subscribing, so the FIRST real change (not the app's own startup) is the first one that resets anything. detach() unsubscribes and forgets the recorded identity; registered holders and the identity resolver survive it, since they belong to their owners, not to the subscription.

The Identity Resolver

SessionScope.identity defaults to the authenticated user id. A team-scoped app overrides it to fold the active team in, so a team switch by the same user counts as a change too:

SessionScope.identity = () => Auth.check() ? '${Auth.id()}:$teamId' : null;

A null identity means unauthenticated and nothing else. A change TO null (a logout) is recorded without triggering a reset: from the login screen a refetch can only 401. The next login resets before any authenticated view renders.

SessionScoped

Implement SessionScoped on a controller or a holder that caches per-tenant rows:

class MonitorController extends MagicController
    with MagicStateMixin>
    implements SessionScoped {
  @override
  Future resetForSession() async {
    setEmpty();
    await loadMonitors();
  }
}

resetForSession must clear before it refetches. Ordinary reload paths are deliberately non-destructive (a transport failure keeps the last-known-good rows on screen), and that is the wrong behaviour across an identity change: a failed refetch here must leave the screen empty, never populated with the previous tenant's data.

A controller implementing SessionScoped needs no explicit registration: SessionScope.sync() finds it by walking Magic.controllers.whereType() on every identity change.

Registering a Non-Controller Holder

A Repository is not a controller, so it registers itself explicitly. This already happens in Repository's own constructor; the pattern below is what a hand-rolled SessionScoped holder (something other than a Repository) does the same way:

class SearchIndex implements SessionScoped {
  SearchIndex() {
    SessionScope.register(this);
  }

  @override
  Future resetForSession() async {
    _entries.clear();
  }

  void dispose() {
    SessionScope.unregister(this);
  }
}

SessionScope.unregister must run when the holder is disposed; an unregistered-but-live holder would otherwise be reset forever, even after nothing references it.

Failure Modes

Situation What happens
Two logins under the same identity in a row The second sync() is a no-op: the identity did not change
A resetForSession() throws Logged via Log.error, isolated per holder: one holder's throw never stops another's reset
A reset resolves another controller mid-iteration Safe: the scoped set is snapshotted before iterating
A controller is also a registered holder (both a SessionScoped controller and separately registered) Resets exactly once: the snapshot is a Set
attach() called twice The second call is a no-op; only one subscription is ever active
A logout Recorded, never triggers a reset (would only 401)