Keep a table open across content a browser would put in front of it (#342) - #481
Merged
Conversation
A browser puts content that cannot go inside a table, such as a div between its rows, in front of the table and keeps the table open, so that the next row pops the content and carries on in the same table. The tag balancer closed the table to make room for the content, and when the row came it opened a new table inside the content, which then stayed open until its parent closed and swallowed every row and everything after the table (#342). The balancer now keeps the table, and any row group and row the content was pushed out of, on its stack while closing them in the output, marked as pushed out. They still bound end tags for the elements below them, as in a browser, and when a part of the table arrives they take it back: the pushed-out content is closed, the entries that cannot hold the part are dropped, and the rest are written again as a new table for the part to go in. A table arriving instead pops the open one, as in a browser. The output cannot put anything in front of a tag already written, so the table is written twice, once empty and once with the later rows, and text pushed out of a table follows it rather than preceding it. A link pushed out of a table and closed when the table resumes is not written again around another link or where one is open, since nested links do not survive a browser's parse; and a link that ends the link open before it does so through a table between them rather than pushing the table out. Fixes #342 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ydng7gBm6Ax5zfwt4vZip
This was referenced Sep 11, 2026
A browser looks for the table to return to within table scope only, so a table or part arriving inside a template in the pushed-out content stays there, and the pushed-out table waits for a part arriving outside it. The return used to close the template and jump back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ydng7gBm6Ax5zfwt4vZip
A review of the push-out found four shapes where content beside a pushed-out table was handled as if the table still contained it, three of them regressions against the behaviour before the push-out landed. A browser puts content a table cannot hold in front of the table, so the element that holds the table is the one that holds the content, and it is what decides which elements are implied around the content and what has to close. The pushed-out entries stay on the stack, so every step after the push-out was reading a table as the container: * Implied elements were computed against the pushed-out table, so an option beside one lost the select the tables never leave to chance, and the output was not idempotent. * Containment was not re-checked below the pushed-out run, so an element that cannot hold the content nested it instead of closing: a heading inside a heading, a button inside a button, neither of which survives a browser's parse. * Text whose container could not hold text was written into it and dropped by the policy's text gate rather than closing it, losing the text after a table inside a colgroup. * A cell returning to a pushed-out row group was given a fresh table inside the re-opened group, since a non-empty implied path was read as the group being able to hold the cell even when that path leads through a table. The push-out now happens before anything asks what contains the content, and only when the table cannot hold it with implied elements between; a container index skips a pushed-out run, so the implied elements and the close loop see the real container and close it when it cannot hold the content; and the return to a pushed-out table keeps an entry only when the implied path to the part runs through it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ydng7gBm6Ax5zfwt4vZip
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #342.
A browser puts content that cannot go inside a table, such as a
divbetween its rows, in front of the table and keeps the table open, so the next row pops the content and carries on in the same table. The balancer closed the table to make room for the content, and when the row came it opened a new table inside the content, which then stayed open until its parent closed and swallowed every row and everything after the table. With the reporter's own HTML, thedivwithdisplay:nonewrapped the whole rest of the message.Design
TagBalancingHtmlStreamEventReceivergains apushedOutbit set over its open-element stack. When content a browser would foster-parent arrives and the open table cannot hold it, the table and any row group and row are closed in the output but kept on the stack and marked. While marked they still bound end tags for elements below them, as in a browser, so</div>for adivenclosing the table is ignored until the table ends. When a part of the table arrives, the pushed-out content is closed, the marked entries that cannot hold the part are dropped, and the rest are written again as a new table for the part to go in. Atablestart tag pops the pushed-out table instead, as in a browser. The table's own end tag closes the content and pops the marked entries without writing anything. The return stops at a table-scope boundary, such as atemplatein the pushed-out content, where a browser would not look past it.The element that holds the table is the element that holds the content, since that is where a browser puts it. So the push-out happens before anything asks what contains the content, and a container index skips a pushed-out run: the implied elements and the containment checks see the real container, and close it when it cannot hold the content.
The output cannot put anything in front of a tag already written, so the table is written twice, once empty and once with the later rows, and the second copy has no attributes, like a formatting element written again after being closed. Text pushed out of a table follows it rather than preceding it. Apart from those two things a browser now reads the output as it reads the input, which the tests check with the validator.nu parser.
Second commit: the review's findings
An independent review of the first commit found four shapes where content beside a pushed-out table was still handled as if the table contained it, three of them regressions against
main. All four are fixed in77fc910with a test each, verified against amainbuild case by case:<table>x<option>o</option></table>z<option>, not idempotent<select>wrapper restored, idempotent<h1><table><tr><td>a</td></tr><h1>b</h1></table>h1, not idempotenth1closes, idempotent<table><col><tr><td>a</td></tr>tail</table>tailsilently droppedtailkept, as onmain<table><thead>x<td>y</td></table>tailthead, not idempotentA 52-shape differential sweep against
mainnow shows no idempotence or balance regressions, two fewer non-idempotent shapes thanmain, and two shapes wheremainloses text that this keeps. The two that remain non-idempotent are the pre-existingcolmis-implication, filed as #483.Links
Two guards, since nested links do not survive a browser's parse and would make the output read back differently: a link that ends the link open before it still does so through a table between them rather than pushing the table out (an existing test with a dropped
captioncaught this), and a link closed on the way back to a table is not written again around or inside another link. The second also fixes a table-free case onmain, where<div><a href=u>x</div><a href=v>yproduced nested links, and has its own regression test.Known limitation
A formatting element pushed out of a table is queued by the pre-existing resume mechanism and can be written again inside the table's next cell, where a browser's cell marker would stop it:
<table><b>x<tr><td>y</td></tr></table>zputs a<b>around the cell text.mainhas the cell text inside the outer<b>instead, so it renders the same either way, and the output is stable. Teaching the resume queue about formatting markers would changetestTablesGuarded, which pins resumption across cells, so it is left alone.Tests
Four at the balancer level and fifteen through
PolicyFactory: the issue's shape, the reporter's shape with row groups and attributes, nesting inside pushed-out content, end tags below the table, the table's own end tag, a nested table, two levels of pushed-out tables, the scope boundary, every table part, pushed-out links and text, the four review shapes, the table-free link case, and the nesting limit with 300 pushed-out elements and 300 push-out cycles. All 583 tests pass on JDK 11, 17, 21 and 25, and the three fuzzers pass at six fixed seeds.Out of scope, filed separately
A table part arriving in a container that was never pushed out still implies a new table there (#483), a
formdirectly inside a table stays open as a container (#484), and the element at the nesting limit is written but its end tag is not (#485). Each is unchanged by this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_014Ydng7gBm6Ax5zfwt4vZip