diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 6fa3c132..77d0a252 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -113,7 +113,8 @@ le DI est construit. Ordre, dans cet ordre : | `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. | | `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. | | `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. | | `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 -Le handler `OpenSettingsRequested` doit garantir qu'une seule -`SettingsPage` est au sommet de la pile à un instant donné. -Sans garde, plusieurs clics sur **Paramètres** empilent -plusieurs instances (chacune résolue en `Transient`), et -l'utilisateur doit appuyer N fois sur **Retour** pour sortir. +`NavigationPage.PushAsync` n'est pas idempotent : pousser deux +fois la même instance l'empile deux fois, et l'utilisateur doit +taper **Retour** N fois pour sortir. Le handler +`OpenSettingsRequested` est gardé pour bloquer ce cas : -L'invariant à implémenter dans le handler : +```csharp +var settingsPage = provider.GetRequiredService(); +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à -> une `SettingsPage`, ne pas empiler une nouvelle instance -> (no-op silencieux). Sinon, `PushAsync` une nouvelle instance -> comme aujourd'hui. - -Le détail d'implémentation (lecture de la pile, gestion des -cas "SettingsPage est plus bas dans la pile") reste à coder -quand l'UI le demandera. +La comparaison est par référence, pas par type : on ne veut +empêcher qu'un push de *cette* instance particulière, pas +celui d'une éventuelle autre `SettingsPage` (il n'en existe +qu'une, mais l'invariant est plus clair comme ça). La garde +repose sur le fait que `SettingsPage` est un singleton ; si on +repassait en `Transient`, `ReferenceEquals` resterait correct +mais la pertinence de la garde s'évaporerait (chaque push +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 diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 84312064..a73096d2 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,7 +60,18 @@ public partial class App : Application // Vues services.AddTransient(); - services.AddTransient(); + // 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(); services.AddTransient(); services.AddTransient(); @@ -93,6 +104,17 @@ public partial class App : Application DataTemplates.Clear(); 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().DataContext = + provider.GetRequiredService(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var homePage = provider.GetRequiredService(); @@ -130,18 +152,30 @@ public partial class App : Application }; // When the user clicks the "Paramètres" button on the - // session banner, push the SettingsPage on top of the - // current navigation stack. Resolved from DI so the - // ViewLocator + service-locator dance stays out of the - // VM, and bound to the same Settings singleton the rest - // of the app is using (the one we Load()'d at startup). - // Two-way bindings on the page mutate that singleton - // in place; callers re-read on next access. + // session banner, push the SettingsPage singleton on top + // of the current navigation stack. The DataContext is + // already wired at composition time (see the + // provider.GetRequiredService().DataContext + // assignment above), so this handler is a pure + // navigation concern. + // + // 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 += () => { var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var settingsPage = provider.GetRequiredService(); - settingsPage.DataContext = provider.GetRequiredService(); + var stack = w.NavRoot.NavigationStack; + if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) + { + return; + } _ = w.NavRoot.PushAsync(settingsPage); };