minor
#29552
TLPhotoViewer fetches its image after the control was disposed when a <switch> replaces it (404 on react-api/data, "Command target NOT FOUND" WARN)
Affects 8.0.0-SNAPSHOT (2026-09-11), tl-layout-react / tl-layout-view.
Reproduction
A view with a <switch> over a derived channel that renders <image input="receipt"/> for one case and <pdf input="receipt"/> for another (the Expenses app, my-expenses.view.xml, right pane). Select a row whose receipt is an image (the photo viewer renders it), then select a row whose receipt is a PDF.
The PDF viewer appears correctly, but at the moment of the switch:
- the browser console logs Failed to load resource: 404 … /react-api/data?controlId=v66&windowName=… followed by [TLPhotoViewer] Failed to fetch image: 404 (tl-react-controls.js:1054),
- the server logs WARN SSEUpdateQueue - Command target 'v66' NOT FOUND in window '…': 48 controls registered, 37 attached.
v66 is the id of the disposed photo viewer control.
Analysis
The row selection changes a channel that two listeners observe. The ImageElement listener updates the photo viewer's BinaryDataValue, which bumps the dataRevision state and sends a PatchEvent to the client immediately (SSEUpdateQueue.enqueue flushes at once). The ReactSwitchControl listener swaps the active case but defers the disposal of the old subtree until the channel notification has unwound (ChannelNotificationScope.afterNotification). The patch therefore reaches the client for a control the same interaction is about to unregister: the client-side TLPhotoViewer effect fetches the image with the stale control id, and by the time the request arrives the server has dropped the control (404 + WARN).
Channel listeners run in registration order. Derived channels register when the view is loaded, case content later, so whether the viewer or the container is notified first depends on the view: with the viewer reading the switch's input channel the container runs first, with the viewer reading a channel derived from it the viewer runs first. Reproduced in the React demo: on every switch the client received PatchEvent {hasPhoto:false} and {dataRevision:2} for the viewer being replaced, before the container's activeChild patch. The two SSE messages are processed in separate browser tasks, so aborting the fetch on unmount alone cannot keep the request from reaching the server.
A second detail: ImageElement accepted any BinaryData as an image, so a PDF in the channel set hasPhoto to true and was handed to an <img> element.
Same pattern in the form: fields rebound to an object the form is leaving
Deleting a ticket on the React demo's Tickets page logged ERROR ReactCommandInvoker - @ReactCommandHandler failed with Evaluation of 'container($comment).comments' failed … Target object is deleted; the delete itself succeeded and the UI updated. The delete command clears the ticket channel, the <object-list> of the conversation pane re-evaluates and resets its draft comment because the container changed, and the composer form receives the new draft on its input channel. FormControl.handleInputChanged first ran exitEditMode(), which fires a full form-state change while the form still holds the old draft: every field rebinds to it, and the select field of Comment#cited recomputes its options by evaluating the draft's container, the deleted ticket. The same intermediate notification ran in onCurrentObjectDeleted; AttributeFieldControl carried a tValid() guard for exactly this notification, which covers a deleted bound object but not a valid draft whose container is gone. Computing anything for an object the form is about to stop displaying is the form-level counterpart of patching a detached control.
With the composer holding unsaved input, the same delete failed on another path: the draft reset was vetoed by the dirty form, an "Ungespeicherte Änderungen" dialog opened for a ticket that no longer existed, and "Verwerfen" cancelled the form, whose notification for the unchanged draft evaluated the deleted container again. Model events are synthesized only after the command ran, so no deletion event can reach the form before the channel write; the draft itself must know that it belongs to a deleted object.
Lösung
- Delivery settles per interaction. ReactWindowRegistry.beginInteraction() returns an Interaction (AutoCloseable) that holds the session's request lock and opens a thread-local DeliveryScope. Within it, SSEUpdateQueue.enqueue only queues; when the outermost interaction closes, every touched queue is settled: PatchEvents / StateEvents addressed to a control that is no longer registered or no longer attached are dropped, the rest is flushed. Command dispatch, route navigation, view pick, upload, window close, the unloaded-window sweep, script replay and the headless AgentServlet all run as interactions (getRequestLock() is replaced by beginInteraction()). Events enqueued outside an interaction (SSE connect, heartbeat model-event synthesis) are flushed immediately as before. Within an interaction the updates reach the wire when it closes, i.e. after the command's HTTP response is written.
- ReactControl sends no PatchEvent / state resend while it is detached (receivesUpdates()): a detached control is not displayed; a control that becomes displayed again is serialized with its full state.
- ContentControls.retire(ReactControl) detaches replaced content synchronously and defers only its disposal (cleanupTree) via ChannelNotificationScope.afterNotification; used by ReactSwitchControl, ReactAdaptiveDetailControl and ReactTileStackControl.
- The client-side TLPhotoViewer aborts an in-flight image fetch when its effect is cleaned up (AbortController) and logs nothing for an aborted request.
- ImageElement only treats BinaryData with an image/* content type as an image (case-insensitive, parameters tolerated).
- The React demo's "Document Viewer" page (demo/pdf-demo.view.xml, sidebar entry "Dokumentanzeige") is a split panel: the table on the left lists the objects of the demo type demo.documents:Document (name, data of type tl.core:Binary), the panel on the right previews the selected one through a <switch> on the content type of its data - the photo viewer for image/*, the PDF viewer for application/pdf. Both viewers read a channel derived from the switch input, so the listener order that reproduced the defect stays verifiable in the app. Two documents are seeded from WEB-INF/data/Z03-documents-demo.objects.xml (an SVG picture and a PDF); the toolbar's <upload-command> adds a document per uploaded file and selects it, and a delete command removes the selected one. The preview panel's only content child is the switch, so the PDF viewer fills the pane height.
- Binary values in the instance XML format keep content type and file name. XMLInstanceExporter.serialize writes a BinaryDataSource as data URI data:<type>;name=<percent-encoded name>;base64,… (BinaryDataURI in tl-basic); XMLInstanceImporter.parse reads that form as well as the bare base64 string of existing files (imported as application/octet-stream without name). Without the content type a seeded picture would not be accepted by the photo viewer.
- TL-Script binaryContentType($data) and binarySize($data) (BinaryFunctions, @ScriptPrefix("binary")) read the content type and the size of a binary value; both are null-safe. A content type may carry parameters (; charset=utf-8), so a check for a kind of content is written with stringStartsWith.
- SSEUpdateQueue.pendingEventCount() exposes the queued events for tests.
- A form leaving an object notifies its fields once, for the new object. FormControl separates the teardown of the edit session (overlay, lock, input veto, validation model, participants, edit-mode and dirty state) from the form-state notification. An input-channel switch and the deletion of the current object tear the session down silently, switch the object and fire a single form-state change, so no field is rebound to the object being left; save and cancel keep teardown plus notification, because there the object stays and the fields show its base values again.
- A transient part dies with its whole. TransientTLObjectImpl.tValid() (and NewObject of the grid, the other TransientObject created in a container) is true only while the tContainer() the object was created in is valid, or it has none; TLObject.tValid() documents the rule. A form whose current object is invalid holds nothing that can be saved: FormControl reports itself clean, publishes no dirty state and vetoes no object switch. An <object-list> treats a deleted container as none (ListContainer.aliveOrNull): no element function is evaluated on it, nothing is displayed for it, and the draft composed for it is replaced by one that belongs to nobody. Deleting the ticket while the composer holds input thus opens no dialog and logs no error.
- DefaultReactContext has only the constructor taking the ReactWindowRegistry: the registry-less variant served tests only and its getModelScope() failed with a NullPointerException, so a control that observes the model on attach could not be tested with it. Tests pass a ReactWindowRegistry of their own; the window entry and its model scope are created on first access.
Tests: TestInteractionDelivery, TestDetachedControlUpdates (tl-layout-react), TestContentControls, TestImageElement, TestFormObjectSwitch, TestObjectListControl (tl-layout-view), TestTransientObjectValidity (tl-core), TestBinaryDataURI (tl-basic), TestXMLInstanceImporter round trip (tl-element), TestBinaryFunctions (tl-model-search). Verified in the browser on the demo's document viewer: the PDF frame fills the preview panel's content height, no patch addressed to a replaced viewer, no 404, no WARN when switching between picture and PDF; upload and delete of a document work; the page reload after a language switch keeps the window name, so the second variant reported in the comment does not occur. Deleting a ticket on the Tickets page, with and without unsaved input in the composer, logs no error and opens no dialog; adding a comment afterwards works.
Notizen
Observed during verification, not changed here: when a channel listener writes another channel and that write is vetoed (selecting another ticket while the composer holds a draft: the ticket channel notifies the object list, whose draft reset the dirty form vetoes), the ChannelVetoException unwinds through the outer channel's notification. The outer channel already holds the new value, its remaining listeners are not notified, and the dialog's continuation retries only the inner write. After "Verwerfen" the URL shows the newly selected ticket while the table highlight and the conversation pane still show the previous one until the selection is made again. The veto would have to be asked before the outer write (ViewChannel.dirtyHandlers(), as OpenDialogAction does for dialog bindings), transitively over the listeners' writes.