major
#29530
URL routing in TL Views: model objects in route parameters, drill-down path in the URL, query bindings
Problem
The view layer (com.top_logic.layout.view) mirrors part of its UI state into the browser URL: the RouteManager composes the address from the segments of the RoutingParticipant`s the display contains, pushes changes to the client over SSE (`RouteChangeEvent), and turns browser back/forward (popstate) into a navigateToRoute command. Participants are the sidebar (ReactSidebarControl, one segment per <nav-item>), the tab bar (ReactTabBarControl) and the view-level <param-bindings> (ParamBindingParticipant).
What kept a URL from describing the state a user actually sees:
1. Route parameters could not carry model objects
ParamBindingParticipant writes the URL segment into the bound channel and the channel value back into the URL, both as text. A model object therefore needed a text that identifies it, and TL-Script had none: the registered functions cover model parts (resolveModelPart), enum literals (resolveEnum) and aliases (resolveAlias), but nothing yields the identifier of an object or finds an object again by one. An object could be put into a URL only through a business key some expression could look up, e.g. all(`demo.tickets:Ticket).filter(t -> $t.get(demo.tickets:Ticket#name) == $key).firstElement()`, at the price of the uniqueness and URL-safety such a key has to have.
The classic UI identifies objects in URLs through BookmarkService and its pluggable BookmarkHandler`s (#object=<id>` arguments, resolved by DefaultBookmarkHandler); nothing of that is reachable from the view layer.
2. The tile-stack drill-down path is not in the URL
<tile-stack> (com.top_logic.layout.view.tiles) holds its drill-down path as a List<TileFrame> on a channel, and both <navigate-push> and <navigate-pop> are writes to that channel — by design, so that "URL persistence becomes a normal <param-bindings> concern external to the tile infrastructure". That concern is unimplemented: a TileFrame is a view reference plus a label plus a snapshot of bound channel values (typically model objects), and a route parameter carries a single text. ReactTileStackControl is not a RoutingParticipant either. The drill-down state of the tiles demo (select an account, open its detail frame) therefore survives neither a reload nor the back button.
The stack is also reachable only from a Java ViewCommand: <navigate-push>, <navigate-pop> and <navigate-pop-to> are ViewCommand`s carrying their own label, image, placement and executability, so none of them can be a link in the action chain of a `<generic-command>, where each ViewAction's result feeds the next (<execute-script>, <with-transaction>, <write-channel>, <open-dialog>). A button that deletes an object and then leaves the frame that displayed it is therefore inexpressible, which is why the delete button of the object list demo sits in the list instead of on the detail page. The stack itself needs no name - a frame reaches it ambiently through the TileStackScope in its ViewContext - but there is no declarative access to the path either, so a command cannot depend on the depth it runs at (a back button that disables itself on the initial frame, for instance).
3. Query bindings do not exist at runtime
QueryBindingConfig and ViewElement.Config.getQueryBindings() are declared, but the property is never read: no participant is created, and no query string is ever written or parsed. Filter state (the documented use case: ?type=...&rooms=... written with replaceState so filter input does not flood the history) cannot be bound at all.
4. The feature was undocumented and unused
<param-bindings> was used by no view in the engine or in the demo applications, so the code path was unexercised outside the RouteManager unit tests. Neither route binding nor the tile stack appeared in docs/faq/react-view-layer.md; the tile stack was described only in its package-info and in a design plan, which is why it is easy to miss even though <tile-stack>, <tile-breadcrumb> and <navigate-push> are @InApp and thus offered in the configuration editor.
5. Defects of the routing runtime
Found with the object list demo of com.top_logic.demo.react, which binds the selected ticket to a route parameter:
- Segments of a subtree that is not displayed reached the URL. After a deep link had switched the sidebar to the object list, the address became tickets/deeplink-demo/table: table is the tab segment of the attribute view, the sidebar item that was active before. Pressing back then produced table/tickets/deeplink-demo and table/tickets - the same segment, in a different position. RouteManager.currentUrl() concatenated the segments in registration order, and participants register when their control attaches, which for a tab happens lazily at render time (ReactTabBarControl.onBeforeWrite()). Registration order followed neither the display hierarchy nor dropped what had left the display.
- A history-driven route change pushed a history entry. Walking back emitted a RouteChangeEvent with replace = false while the browser was already navigating, which appends an entry instead of consuming one and makes repeated back presses non-monotonic.
- A URL was adopted only by a display that did not exist yet. A pending URL is consumed by participants as they register, which is what a display being built up for the first time does. A reloaded page is rendered into the control tree the window already holds, whose participants are registered before the URL to adopt is known - so a deep link into an existing browser tab left the display unchanged, and the address bar then either kept a URL nothing displayed or was overwritten with the state the tab still showed. A click on a sidebar item appended its segment to such a stale path, giving /view/tickets/tickets.
- A page loaded without a route left its address bar incomplete. The composed URL was withheld because the manager remembered having sent it to the client of the previous page, which the freshly loaded one had never seen.
- A route parameter kept a segment that resolved to nothing. The participant reported the consumed text as its segment even when the binding resolved it to no object, so a URL naming a deleted or unknown object stayed in the address bar beside a view that displayed no such object.
- An in-app navigation created no history entry. Exchanging the display is how a navigation is carried out, and the participants disappearing and appearing on the way reported their own corrections of the address bar. Such a correction arrived before the navigation could report itself, leaving it with an address bar that already showed its target - and the user with no entry to come back to. Switching the sidebar item into a view with tabs even destroyed the entry of the view left behind.
- A deferred selection reached the highlight but not the display. Selecting a sidebar item or a tab before anything is rendered updated the state naming the selection, but left the content of the item before it in place.
- A URL the browser navigated to was adopted without a session context. The navigateToRoute command bypassed the subsession and update phase that every other command installs, so a channel bound to a route parameter could not look up the object the URL named: pressing back onto /view/tickets/<id> left the list without a selection, and the address bar was corrected to the state that was still displayed.
- A URL naming less than the display showed left the display standing. A URL adopted into a session that already held a drill-down - the bare view URL entered while a frame was open, a deep link with an identifier no object carries - kept the frame and rewrote the address bar to it: nothing told a participant that the URL named no route for it. A URL refused by a dirty-form veto never ended its adoption either, so the user's next navigation was reported as a replacement instead of a history entry.
- A value written into a channel before its table attached was ignored. RowSourceObserver (behind <table>, <object-list>, <calendar>) evaluated the rows once at creation and started listening to its input channels only when its control attached, without re-reading. A channel a URL parameter writes on the root's attach - which precedes the attach of the table below it - therefore reached the filter input but not the rows, until the user edited the filter. The same gap swallowed every change made while a display was detached.
- The two stacks of the multi-tab tiles demo shared one path. Tab content shares the channels of the enclosing view, and both tabs' nested <view>`s declared `navPath, so the second took the first one's channel: drilling down in one tab drilled down in both.
Solution
- objectId and objectResolve (ObjectFunctions, @ScriptPrefix("object")): the identifier of an object as a text for a URL, and the object of a type with a given identifier. Nothing for a transient object, and nothing for an identifier that no object of the type carries - a deleted object, a mistyped link - so an unusable link leaves no segment behind.
- Route parameters that carry a model object, through a bidirectional derived channel over those two functions.
- A URL composed from the control tree instead of the registration sequence, with no contribution from participants that have left the display.
- A replace flag that follows the cause of a change: adopting a URL the client already displays reports no history entry, an in-app navigation reports exactly one, and a display change that navigates is applied as one navigation rather than as a series of corrections. An adoption always ends - finishAdoption() where the URL was taken up, cancelAdoption() where a veto refused it - and only its end reports the address bar, so participants leaving and entering the display on the way report nothing (ViewServlet, ReactServlet, AgentServlet).
- A URL adopted by the display that exists, not only by one that is being built, under the same context as any other command. A segment the display cannot reproduce is dropped and corrected in the address bar, and a participant displaying a route the URL neither named nor brought in is asked to resetRoute() - which is how the bare URL of a view returns a drill-down to its initial frame.
- A URL that leaves a route parameter unspecified keeps the value the view establishes - the element a table selects by default, or the selection the session holds - and the address bar is completed with it.
- Route parameter values are percent-encoded path segments (RFC 3986, RouteEncoding applied by RoutePattern.produce and undone by RoutePattern.match), so a value containing a slash, a space or a percent sign stays the one segment it fills; the servers adopt a URL in the form the browser sends it (ViewServlet reads the route from the raw request URI). An encoded slash needs a container that accepts an ambiguous path: the embedded Jetty of tl-ide-jetty allows AMBIGUOUS_PATH_SEPARATOR and decodes ambiguous URIs, a production container needs the equivalent setting (documented in docs/faq/react-view-layer.md; object identifiers never contain a slash, so this concerns business keys only). A static prefix on ParamBindingConfig addresses a parameter as ticket/:ticket rather than positionally.
- The drill-down path of a <tile-stack> in the URL: the stack declares its frames as <frame view="..." route="person/:person"> with a <param name expr reverse> conversion per parameter (object to text, text to object) and a <label> provider naming the frame. One TileFrameRouteParticipant per mounted frame composes the routes of every frame on the path and takes up the URL frame by frame (RoutingParticipant.acceptsRouteSequence), so a reload restores the drill-down and back/forward walk it. A parameter that resolves to nothing ends the path, and a frame pushed without a label takes the one the stack declares for its view.
- <navigate-pop> and <navigate-pop-to> as ViewAction`s (`NavigatePopAction, NavigatePopToAction), usable in the chain of a <generic-command> beside <execute-script>, <with-transaction> and <write-channel>, so that a command can finish its work and then leave the frame it worked in. The commands of the same names delegate to the actions; TileStackScope.lookup is the one scope resolution all of them use.
- bind-path-to on <tile-stack> names the channel under which each frame sees the path, so a derived channel yields the depth and a <visible-if> rule hides a back button on the initial frame.
- A runtime for <query-bindings>: a QueryBindingParticipant per binding, the query string split off an adopted URL and offered to every participant (RoutingParticipant.activateQuery), query parameters composed from the displayed participants. A change of the query alone is written with replaceState, a change of the path with pushState. RowSourceObserver re-reads its elements when it begins observing, so a value a URL wrote before the table attached is displayed.
- <text-input value="channel"> (TextInputElement): an input writing the text value of a channel in both directions, composed from the existing text input control and field model - the counterpart of a <field> for a value that belongs to the view rather than to a model object, such as a filter term.
- Documentation of URL routing and of the tile-stack drill-down in docs/faq/react-view-layer.md. The demos of com.top_logic.demo.react: the object list carries its selected ticket (/view/tickets/ticket/<id>) and its filter term (?q=) in the address bar; the tiles demo carries its drill-down (/view/tiles-demo/person/<id>) and shows a depth-guarded Back command; the multi-tab tiles demo holds its two stacks on channels of their own.