enhancement
major
minor
major
minor
major
#29618
TL Views: a URL switches the sidebar away from an item with unsaved changes without asking
Problem
A <nav-item> of a <sidebar> can name a channel that reports unsaved changes of what it displays. Leaving such an item is refused while it holds unsaved changes: ReactSidebarControl.revealChild() asks the item's dirty channel for its handlers and raises a ChannelVetoException carrying them, so the user is asked whether the changes may be dropped and the switch is carried out only when they agree.
That question is asked only when the item is left through the user interface. A sidebar is also a RoutingParticipant, and the route a URL names activates an item through ReactSidebarControl.activateRoute(), which calls selectItem() directly. Entering the URL of another section into the address bar, following a deep link into one, or walking the browser history therefore switches the sidebar away from an item with an unsaved form without asking anything, and the input is gone.
The <tab-bar> has the same defect: ReactTabBarControl.activateRoute() calls selectTab() directly, while only revealChild() consults the tab's dirty channel.
A third gap makes the sidebar's question fail even for a click: a form inside a <tab-bar> reports its unsaved changes to the tab's dirty channel only, which replaces the sidebar item's channel for everything inside the tab. The item's channel stays empty, so a page whose forms sit in tabs (every page of tl-demo-react) is left silently either way.
A fourth defect sits in the client's answer to a refused URL: the address bar is restored with history.replaceState, which rewrites the history entry the browser had moved to (over the back or forward button), not the one the page belongs to. That entry is lost: the next press of the same button lands on an entry carrying the current address and does nothing, and the press after that asks for a target two steps away.
Expected
Leaving an item is refused the same way regardless of what causes it: a URL that activates another item asks about unsaved changes just as a click on the sidebar does, and carries the switch out only once the question has been answered. Unsaved changes inside a tab count for the sidebar item displaying that tab. A refused back or forward press leaves the history as it is; after "Verwerfen" or "Speichern" the press is completed as the same move, so the page left stays reachable with the opposite button.
Technical note
Found while analysing #29602 (which has a different cause and does not touch this). The two entries into the item switch are ReactSidebarControl.activateRoute(RouteMatch) and ReactSidebarControl.revealChild(String); only the latter consults NavigationItem.getDirtyChannel() before calling selectItem(String). Note that a veto raised during the adoption of a URL has to end the adoption (RouteManager.cancelAdoption()), the way a dirty-form veto refusing a URL already does.
Lösung
- ReactSidebarControl.activateRoute() und ReactTabBarControl.activateRoute() laufen über revealChild(): Eine URL, die ein Element mit ungesicherten Änderungen verlässt, wird mit derselben ChannelVetoException abgewiesen wie ein Klick. Das Veto verlässt RouteManager.navigateToRoute(). Nur das Verlassen fragt: Eine URL oder ein Klick, die das bereits angezeigte Element benennen (ein tieferes Segment, ein Query-Parameter, ein Schritt zwischen zwei Adressen derselben Seite), stellen keine Frage.
- Der navigateToRoute-Handler des ReactServlet beendet bei einer ChannelVetoException die Übernahme (cancelAdoption()) und beantwortet den Befehl selbst mit refused und der Adresse der Seite, die bleibt (currentUrl); das SSE-Ereignis RouteVetoEvent entfällt. Die Antwort gehört eindeutig zu dem History-Schritt, den der Client gemeldet hat – ein SSE-Ereignis würde erst beim Schließen der Interaktion, also nach der Antwort, gesendet und ließe sich nicht zuordnen. Anschließend öffnet der Handler den Dialog zu ungesicherten Änderungen (DirtyConfirmDialogControl). Nach Sichern oder Verwerfen erhält der Client ein RouteResumeEvent mit der URL und führt den abgewiesenen History-Schritt selbst erneut aus; ein weiteres Veto tiefer in der URL kommt als gewöhnliche Abweisung zurück und fragt erneut. Abbrechen lässt Anzeige und Adresszeile, wie das Veto sie belassen hat.
- Client (route-sync.ts): Jeder History-Eintrag, den die Anwendung schreibt, trägt in history.state seine Position und die Kennung des Seitenaufrufs (tlNav). Bei einer Abweisung kehrt der Client mit history.go() um die Differenz zum Eintrag der angezeigten Seite zurück, ohne einen Eintrag zu überschreiben; der abgewiesene Schritt wird gemerkt und beim RouteResumeEvent in der gedrückten Richtung wiederholt, sodass der Browser auf dem Ziel des Tastendrucks landet und die verlassene Seite mit der Gegentaste erreichbar bleibt. Ein Eintrag eines früheren Seitenaufrufs (ohne verwertbare Position) wird wie bisher per replaceState korrigiert und beim Fortsetzen als neuer Eintrag angesteuert.
- Verschachtelte Bereiche: Ein DirtyChannel kann den Kanal des umschließenden Bereichs kennen (DirtyChannel(parent)) und meldet jeden Zustand dorthin weiter. <tab-bar> und <sidebar> legen ihre Kanäle mit dem Kanal des umschließenden Kontexts an, sodass ein Formular in einem Tab beim Verlassen des Tabs und beim Verlassen des Sidebar-Elements zählt.
- Andere Ausnahmen bei der URL-Übernahme werden ebenso mit refused beantwortet (ohne Dialog); das AgentServlet meldet ein Veto weiterhin als Fehlschlag.