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
- Why It Exists
- Attaching
- The Identity Resolver
- SessionScoped
- Registering a Non-Controller Holder
- Failure Modes
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) |