postIt: SettingsPage is a singleton, navigation is idempotent

Two related changes that close the loop on the SettingsPage
push semantics.

1. The SettingsPage used to be registered as Transient. Each
   click on the Paramètres button resolved a fresh instance,
   re-bound it to the Settings singleton, and pushed it onto
   the navigation stack. Repeated clicks accumulated stacked
   instances, each fully bound, and the user had to tap Back
   N times to leave. The fix is to register the page as a
   Singleton in the DI container. There is now one and only
   one SettingsPage ContentPage for the lifetime of the app:
     - its DataContext is wired once, at composition time
       (just after the ViewLocator is added to DataTemplates),
       not on every push;
     - the OpenSettingsRequested handler is a pure navigation
       concern, with no DI resolution and no rebinding;
     - the in-memory Settings state is preserved across visits
       (any in-flight edit stays in the same instance).

2. The OpenSettingsRequested handler is guarded so that if the
   SettingsPage is already at the top of NavigationStack, the
   push is a no-op. NavigationPage.PushAsync does not
   deduplicate; without the guard, calling it twice with the
   same instance pushes it a second time, and the user has to
   tap Back twice to leave. The guard is a reference comparison
   on NavigationStack[Count - 1] against the singleton
   instance, which is correct precisely because the page is
   a singleton.

doc/architecture/postit.md is updated to match: the DI table
reflects the new lifetime, and the 'Garde anti-empilement'
section is rewritten from 'to be implemented' to the actual
implementation, including the rationale for reference
comparison and the cross-dependency between the singleton
lifetime and the guard.

The Settings-singleton invariant (in the same doc) is
unchanged: Settings is still a singleton, and adding a
transient override would still be the bug it always was.
The new SettingsPage singleton sits alongside it cleanly.

Build: 0 errors. Tests: 3/3 SettingsLoadTests green.
This commit is contained in:
Paul Schneider 2026-07-09 22:00:00 +01:00
commit 1733dababb
2 changed files with 68 additions and 24 deletions

View file

@ -113,7 +113,8 @@ le DI est construit. Ordre, dans cet ordre :
| `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. | | `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. |
| `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. | | `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. |
| `BlogApiClient` | Singleton | Mapper stateless, partagé. | | `BlogApiClient` | Singleton | Mapper stateless, partagé. |
| `MainPage` / `SettingsPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. | | `SettingsPage` | **Singleton** | Une seule instance pour la vie de l'app : le `DataContext` est câblé une fois au boot, le push est idempotent (cf. section *Garde anti-empilement* ci-dessous). |
| `MainPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. |
| `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` | Transient | VM reconstruites à chaque navigation ; pas d'état partagé à conserver. | | `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` | Transient | VM reconstruites à chaque navigation ; pas d'état partagé à conserver. |
| `SessionStatusViewModel` + `SessionStatusBanner` | Singleton + Transient | Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée). | | `SessionStatusViewModel` + `SessionStatusBanner` | Singleton + Transient | Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée). |
@ -139,22 +140,31 @@ posé sur `MainWindow.axaml`. La pile est gérée par les
### Garde anti-empilement ### Garde anti-empilement
Le handler `OpenSettingsRequested` doit garantir qu'une seule `NavigationPage.PushAsync` n'est pas idempotent : pousser deux
`SettingsPage` est au sommet de la pile à un instant donné. fois la même instance l'empile deux fois, et l'utilisateur doit
Sans garde, plusieurs clics sur **Paramètres** empilent taper **Retour** N fois pour sortir. Le handler
plusieurs instances (chacune résolue en `Transient`), et `OpenSettingsRequested` est gardé pour bloquer ce cas :
l'utilisateur doit appuyer N fois sur **Retour** pour sortir.
L'invariant à implémenter dans le handler : ```csharp
var settingsPage = provider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
return; // déjà au sommet, no-op silencieux
}
_ = w.NavRoot.PushAsync(settingsPage);
```
> Si la page du sommet de `NavRoot.NavigationStack` est déjà La comparaison est par référence, pas par type : on ne veut
> une `SettingsPage`, ne pas empiler une nouvelle instance empêcher qu'un push de *cette* instance particulière, pas
> (no-op silencieux). Sinon, `PushAsync` une nouvelle instance celui d'une éventuelle autre `SettingsPage` (il n'en existe
> comme aujourd'hui. qu'une, mais l'invariant est plus clair comme ça). La garde
repose sur le fait que `SettingsPage` est un singleton ; si on
Le détail d'implémentation (lecture de la pile, gestion des repassait en `Transient`, `ReferenceEquals` resterait correct
cas "SettingsPage est plus bas dans la pile") reste à coder mais la pertinence de la garde s'évaporerait (chaque push
quand l'UI le demandera. apporterait une nouvelle instance et l'anti-empilement
reposerait sur l'invariant « la même est déjà au sommet »,
qui ne tiendrait plus).
## ViewModels et invariants d'état ## ViewModels et invariants d'état

View file

@ -60,7 +60,18 @@ public partial class App : Application
// Vues // Vues
services.AddTransient<MainPage>(); services.AddTransient<MainPage>();
services.AddTransient<SettingsPage>(); // SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) the OpenSettingsRequested handler is a
// pure push with a no-op-if-already-on-top guard, never a
// re-resolution from DI. Transient would let the user
// accumulate stale SettingsPage instances on the navigation
// stack, each bound to a fresh SettingsViewModel and missing
// any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>(); services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>(); services.AddTransient<SignaturePage>();
@ -93,6 +104,17 @@ public partial class App : Application
DataTemplates.Clear(); DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(provider)); DataTemplates.Add(new ViewLocator(provider));
// Wire the Settings singleton onto the SettingsPage singleton
// once, at composition time. The page is registered as a
// singleton (see above) precisely so this binding is stable
// for the lifetime of the app: every push to / pop from the
// navigation stack finds the same ContentPage with the same
// DataContext, and the TwoWay bindings inside the page keep
// mutating the same in-memory Settings instance that the rest
// of the app reads (OidcClientOptions construction, etc.).
provider.GetRequiredService<SettingsPage>().DataContext =
provider.GetRequiredService<Settings>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var homePage = provider.GetRequiredService<HomePage>(); var homePage = provider.GetRequiredService<HomePage>();
@ -130,18 +152,30 @@ public partial class App : Application
}; };
// When the user clicks the "Paramètres" button on the // When the user clicks the "Paramètres" button on the
// session banner, push the SettingsPage on top of the // session banner, push the SettingsPage singleton on top
// current navigation stack. Resolved from DI so the // of the current navigation stack. The DataContext is
// ViewLocator + service-locator dance stays out of the // already wired at composition time (see the
// VM, and bound to the same Settings singleton the rest // provider.GetRequiredService<SettingsPage>().DataContext
// of the app is using (the one we Load()'d at startup). // assignment above), so this handler is a pure
// Two-way bindings on the page mutate that singleton // navigation concern.
// in place; callers re-read on next access. //
// Anti-empilement guard: if the SettingsPage is already
// at the top of the stack, do nothing. NavigationPage's
// PushAsync does not deduplicate; calling it twice with
// the same instance would push it a second time and the
// user would have to tap Back twice to leave. Reference
// comparison is correct here because SettingsPage is a
// singleton — there is exactly one instance to compare
// against.
sessionStatus.OpenSettingsRequested += () => sessionStatus.OpenSettingsRequested += () =>
{ {
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = provider.GetRequiredService<SettingsPage>(); var settingsPage = provider.GetRequiredService<SettingsPage>();
settingsPage.DataContext = provider.GetRequiredService<Settings>(); var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
return;
}
_ = w.NavRoot.PushAsync(settingsPage); _ = w.NavRoot.PushAsync(settingsPage);
}; };