enhancement
major
minor
major
minor
major
#30055
TL Views: a table with active-preset and search-term channels renders every filtered row instead of a window; 15k rows load 88 MB and crash the browser tab
Problem
Measured in the TL Development app on snapshot build 59 (2026-10-29), Chrome at 1600x900, with two <table>`s over the same `tl.dev:Ticket rows:
- Global ticket list (tickets-list.view.xml): <table active-preset="activeFilter" search-term="searchTerm" personalization-key="tl-dev-tickets" selection="…" types="tl.dev:Ticket" observed-types="tl.dev:Ticket"> with <presets initial="open"> in a filling <panel>.
- A project's Tickets tab (project.view.xml): <table group-by="milestones" personalization-key="tl-dev-project-tickets" …> with <presets initial="open">, but no active-preset and no search-term channel.
The global list has a bounded scroll viewport: .tl-table__body is 689 px high and scrolls a .tl-table__canvas of 68,400 px with absolutely positioned rows. The fill contract holds. Even so, the client renders every filtered row:
| Situation | Rows | DOM rows | Initial load | Browser heap |
| Global list, preset Open | 1,900 of 15,513 | 1,901 | 87.8 MB, 11.6 s | 0.57-1.9 GB |
| Global list, preset All | 15,513 | 1,921 | 89.2 MB, 23.4 s; a later command 15.8 s | 1.81 GB |
| Global list, All, then a command opening a dialog | server 9.0 s | tab crashed (renderer at full CPU, more than 6 GB) | ||
| Project Tickets tab, All | 14,958 | 75 | update 272 KB, command 3.7 s | 62 MB |
| Project board (three tables) | 1,552 / 311 / 13,095 | 71 per lane | 6.4 MB | 193 MB |
- The payload of the global list is about 88 MB whatever the preset, so it follows all 15.5k rows rather than the filtered ones. On one load the console logged about 24,750 per-cell updates.
- During the 88 MB load the server log got about 6,000 lines "SSE connection lost for window …" within one second.
- After the event stream had dropped and the client fell back to polling, a preset switch (2.3 s on the server) reached the client only as a row count. The poll jumped from event 107 to 120, events 108-119 never arrived, and the rows and the preset stayed on the old state for more than 60 s.
Expected
- A table sends and renders a window of rows around its viewport whatever its configuration. Presets, filters and search bound to channels do not change that.
- The payload of a table update is proportional to the window, not to the row count.
- Updates lost while the event stream is down are re-delivered (or the client resynchronizes) when the client polls.
- A connection loss is logged once per window, not once per event.
Test
A table over 15,000 objects with active-preset and search-term channels and presets, in a filling panel: the DOM holds no more than roughly a viewport of rows, and switching the preset transfers less than 1 MB.
Lösung
Cause of the table payload. The server sends only a window of rows (the requested viewport plus 20 prefetched rows on each side). The 1,901 / 1,921 DOM rows equal the 68,400 px canvas divided by the row height (1,900 rows plus prefetch): the client requested a viewport of about 1,900 rows. On its first layout the table body is not bounded yet - the containers fill only a render after the table reports filling - so it is as high as all its rows, and the scroll restore of the mounted body reported that height as its viewport. When the body was bounded afterwards, no scroll command followed, so the server kept the oversized viewport for every later update (preset switch, refresh, command). The 88 MB are about 1,900 rows times 13 cells, not the 15.5k rows. The channels are not the cause; the global list is merely the view that is mounted first.
Table (TLTableView, TableViewControl).
- The client reports as viewport only the part of the table body that is actually visible: for a body bounded by its container its own scroll window, for a body as high as all its rows (a table in a content-sized container, or a body the containers have not bounded yet) the part that lies within the scroller around it and the browser window. Such a table therefore pages in its rows while the surrounding panel scrolls, instead of showing only its first window; skeleton rows are drawn only in the visible part.
- Whenever the visible part changes - a resize, the body getting bounded, a scroller around it scrolling - the viewport is reported again (debounced, only when it differs), so a window measured too large corrects itself.
- The keyboard cursor scrolls the surrounding scroller when the table body does not scroll itself.
- The server limits every viewport to 200 rows (TableViewControl.MAX_VIEWPORT_ROWS), whatever the client reports.
Event stream (SSEUpdateQueue, SSEServlet, sse-client.ts).
- The loss of an event stream is logged once per connection. The full state sent to a new stream stops at the first failed write instead of serializing and writing the state of every remaining control to the dead stream (that loop produced the thousands of log lines); a connection that was lost, replaced or given up is no longer written to by any thread.
- A poll never continues after a gap: it reads every event after the number it sends, or the full state. Previously the numbers forgotten on a reconnect or a new page, and the events dropped from the retained buffer while a poll was waiting, let a poll continue after them (the jump from 107 to 120).
- The first heartbeat of a stream carries the number of the last event, so a page loaded anew no longer takes the number a command answer reports for events its stream never brought as a sign of a blocked stream, and no longer switches to polling for good.
- A stream that reconnects names the last event it received (Last-Event-ID, or the after parameter for a stream the client opens again itself); the events written after it that the full state does not contain - navigations, scripts - are replayed with their numbers. Events are written to a new stream only after its full state, its replay and its numbered heartbeat. The numbers of a new page start above those of the previous page, so a stream of the previous page replays nothing of the new one.
Demo. In com.top_logic.demo.react (Objektliste), "Generate samples" creates the number of tickets entered in the field "Number of samples" (default 8); 15,000 give the Open list more than 11,000 rows.