Repository navigation
Conversation
The HTML spec runs "maybe clone an option into selectedcontent" whenever an option element is popped off the stack of open elements; whatwg/html 4ce63af1 moved it there from a dedicated "</option>" step. html5ever only ran it from a special case for "</option>", so options closed implicitly, by another <option>, an <optgroup> or <hr>, a </select> end tag or the end of the input, were never cloned. Separately, several paths dropped elements from the stack of open elements without calling TreeSink::pop: the truncations in the adoption agency algorithm, in "any other end tag", for <frameset> and for end tags in foreign content; the adoption agency's removal of nodes between the formatting element and the furthest block; and pop_until and pop_until_current, which most end tags go through. Sinks that track pops missed all of those elements. Route every removal from the stack of open elements through one helper, which tells the sink about the element and then, for HTML option elements, calls TreeSink::maybe_clone_an_option_into_selectedcontent. The "</option>" special case goes away, and "</option>" is handled by "any other end tag", as in the spec. rcdom's implementation of the hook never found the selectedcontent element, because it looked at the select's name instead of each descendant's, so it never cloned anything. Fix that, and make the clone safe to run: replace the selectedcontent's children without leaving them with stale parent pointers, clone without recursing, and when dropping a node leave alone any child that is still referenced elsewhere, since the clone can remove elements that are still on the stack of open elements from the tree. Add tree construction tests for options closed in each of these ways, in a new directory of custom html5lib-style tests, and tests for the pops and selectedcontent calls the tree builder makes to its sink. webkit02.dat-44 and -45 stay skipped: they also depend on the first option being selected by default, which rcdom does not model. Fixes servo#712
staylor
marked this pull request as ready for review
October 9, 2026 18:12
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 #712.
Problem
The tree builder only called
TreeSink::maybe_clone_an_option_into_selectedcontentfrom a special case for an explicit</option>end tag (the FIXME inrules.rsthat points at #712). If the option was closed any other way, it was never cloned into the<selectedcontent>. That covers another<option>,<optgroup>or<hr>,</select>,<input>or a nested<select>, an ancestor's end tag, the adoption agency algorithm, clearing the stack back to a table context, and the end of the input:A related problem: several paths removed elements from the stack of open elements without calling
TreeSink::pop, so sinks that track pops never heard about those elements:open_elems.truncate(..)in the adoption agency algorithm (when there is no furthest block), in "any other end tag" (process_end_tag_in_body), for<frameset>, and for end tags in foreign content;open_elems.remove(..)in the adoption agency's inner loop, which removes nodes between the formatting element and the furthest block;pop_untilandpop_until_current, which most end tags go through (</div>,</select>,</table>, closing table cells, and so on).For
<div><span></div>onmain, the sink hears about neither thespannor thediv(print-tree-actionsonly reports head, body and html being popped). Even the explicit</option>path called the selectedcontent hook without ever reporting that the option was popped.Spec
Fix
TreeBuilder::remove_from_stack_at(index). It removes the element, callsTreeSink::pop, and then, for HTMLoptionelements, callsTreeSink::maybe_clone_an_option_into_selectedcontent. The other helpers are built on it:pop,remove_from_stack, a newpop_to_len(which replaces the four truncations and the drain inend()),pop_until,pop_until_current, and the adoption agency's inner-loop removals. TheRefCellborrow of the stack is released before calling into the sink. The oldend()held it across all of itsTreeSink::popcalls.</option>special case is gone.</option>is now handled by "any other end tag", as in the spec.remove_from_stackalready reported these to the sink, and Blink does the same:HTMLElementStack::RemoveNonTopCommoncallsFinishParsingChildren, which is where it updates selectedcontent. The adoption agency's "replace the entry for node in the stack of open elements" does not count as a pop. It only ever replaces formatting elements, and Blink'sHTMLElementStack::Replacedoesn't treat it as one either.TreeSink::maybe_clone_an_option_into_selectedcontentnow describe when it is called: right afterTreeSink::pop, for every popped option.rcdom
The tree construction tests run through rcdom, and rcdom's hook never did anything.
get_a_selects_enabled_selectedcontentchecked the select's own name (self.data) instead of each descendant's (node.data), so it never found a<selectedcontent>, not even after an explicit</option>. Fixing that meant rcdom's clone code ran for the first time, and that showed three problems with it:selectedcontent.childrenwithout clearing the removed children's parent pointers. Clones also copied their originals' parent pointers. With input like<select><b><selectedcontent><div><option selected>X</option></b>, the adoption agency later panicked with "have parent but couldn't find in parent's children!". The fuzz target parses with RcDom, so a fuzzer could reach this.Drop for Nodetook apart nodes that were still referenced and left dangling parent pointers.Dropand serializer.The clone now detaches the old children and
appends the new ones.clone_with_subtreeis iterative and sets parent pointers correctly.Dropclears the parent pointer of each child of a node it drops, and leaves alone any child that is still referenced elsewhere.Tests
rcdom/custom-html5lib-tree-construction-tests/selectedcontent.dat: 13 html5lib-format tree construction tests.rcdom/tests/html-tree-builder.rsnow reads this directory as well ashtml5lib-tests/tree-construction, the same wayhtml-tokenizer.rsreadscustom-html5lib-tokenizer-tests. Each test closes a selected option a different way:</option><option>,<optgroup>or<hr></select>, with and without a child of the option still openselected)<select>, or<input><td>, which clears the stack back to a table row contextThe options use a
selectedattribute because rcdom only treats options with that attribute as selected. I checked the expected trees against Chromium 147 (headless,DOMParser) and all 13 match. The#errorssections come from the spec, and their counts match what html5ever reports withexact_errors.html5ever/tests/tree_builder.rs: uses a sink that builds no tree and logspopandmaybe_clone_an_option_into_selectedcontentcalls.pop_until, clearing the stack back to a table context, the adoption agency with and without a furthest block, "any other end tag", end tags in foreign content, and<frameset>.</option>,<option>,<optgroup>,<hr>,</select>or the end of the input. That includes</option>, so a double call would be caught.<option>doesn't get a hook call.Both tests fail on
main.Results, before and after this change. For the html5lib and WPT rows I ran the compiled
html-tree-builderharness with an empty ignore list:mainselectedcontent.dat(13 cases × 2 scripting modes)</option>only)webkit02.dat-44,-45(pinned submodule, skipped indata/test/ignore)html/syntax/parsing/resources/webkit02.dat-44…-47(tree construction tests now live in WPT;-46and-47aren't in the pinned submodule)-47)html5ever/tests/tree_builder.rsWith an empty ignore list, the only html5lib failures on both
mainand this branch are webkit02.dat-44 and -45 (×2 scripting modes). Both still fail under rcdom for a reason that has nothing to do with the tree builder. Neither option has aselectedattribute, and the spec selects the first option by default, which rcdom doesn't model (see html5lib/html5lib-tests#180). With a sink that does model this, the tree builder now passes them, so they stay in the ignore list for now. WPT-46 fails for the same reason.On top of that I ran a randomized stress test outside the repo. It parsed 2.3 million random, select-heavy tag-soup documents with RcDom, as documents and as
select-context fragments, and serialized them, like the fuzz target. With only the lookup fix it found the rcdom panic described above. With this PR it finds nothing. A 200,000-deep subtree inside a selected option also clones fine.cargo fmt --all -- --check,cargo clippy --all-features --all-targets,cargo test --workspace,cargo docwith-D warnings, and the MSRVcargo check --lib --all-featureson Rust 1.85 all pass.Notes for reviewers
maybe_clone_an_option_into_selectedcontentfor implicitly closed options. It will also getTreeSink::popfor elements it never heard about before. Itspophooks (<style>,<title>,<textarea>) were already reached throughpop()in the "text" insertion mode, so they are unaffected.TreeSink::popshould move that work tomaybe_clone_an_option_into_selectedcontent, or it will clone twice.TreeSink::popis never called for them;maybe_clone_an_option_into_selectedcontent(#maybe-clone-an-option-into-selectedcontent) no longer resolves since whatwg/html@b346db728f renamed the algorithm.This patch and its description were written with an AI coding assistant.