Skip to content

fix: a batch of QA-found engine defects, plus 0.4.2 - #93

Merged
pathscale merged 16 commits into
masterfrom
fix/qa-engine-defects-2026-09-09
Sep 9, 2026
Merged

pathscale merged 16 commits into
masterfrom
fix/qa-engine-defects-2026-09-09

Conversation

@pathscale

Copy link
Copy Markdown
Owner

Fixes a batch of engine defects found by twelve end-to-end QA suites run against real sites, each with a regression test that fails before its fix.

Fixed

# Defect Test
1 <select> got no layout box at all: no select rule in the user-agent sheet, so it computed display: inline, and option { display: none } left it with no in-flow content. Even an authored height reported 0x0. tests/blitz-tests/tests/select_layout.rs
3 input[type=number] measured 6x6, its padding and border alone, against 306x25.2 for every other text-like type. The type list that grants the intrinsic content box omitted number while the list that creates the text editor included it. tests/blitz-tests/tests/number_input_sizing.rs
4 <tr>, <thead> and <tbody> reported 0x0 and "not displayed". Table layout flattens rows into a grid of cells, so those nodes never reach Taffy and nothing wrote a layout for them. tests/blitz-tests/tests/table_row_boxes.rs
6 table { display: block } rendered every row side by side. tests/blitz-tests/tests/display_block_table.rs
7 invalid SlotMap key used at keyboard.rs, killing the host. controls_to_form was never pruned, and the walk indexed the slab for every id it held. tests/blitz-tests/tests/implicit_form_submission.rs
8 A second invalid SlotMap key used, at resolve.rs:863. A stacking-context host inside a display: none subtree never rebuilds its child list, and resolve_hoisted_positions walks every host, hidden or not. tests/blitz-tests/tests/hoisted_child_survives_removal.rs
9 Enter never submitted a form with more than one explicitly typed field. The spec gates that rule on the form having no submit button; it was applied unconditionally. tests/blitz-tests/tests/implicit_form_submission.rs
10 change was synthesised only for checkbox and radio, so any onChange on a text field was dead. packages/blitz-script/tests/text_input_change.rs
11 scrollIntoView was not exposed to page scripts. packages/blitz-script/tests/scroll_into_view.rs
12 window.location had no assign, replace or reload. packages/blitz-script/tests/location_navigation.rs
13 cancelBubble existed nowhere, so a delegated dispatcher had nothing to read and stopPropagation did not hold. packages/blitz-script/tests/cancel_bubble.rs
14 history.back dispatched no popstate, so routes were one-way. packages/blitz-script/tests/popstate.rs

Two findings needed a mechanism that did not exist. location.assign had nowhere to go, because resolve_url and the navigation provider are both internal to blitz-dom, so BaseDocument grows navigate_to_url and current_url. popstate had no way to be raised at all, because Node.prototype.dispatchEvent walks a node chain and never reaches the window listeners, so window.dispatchEvent is added and the history shim uses it.

change on a text control is a commit, not a blur notification: the value at focus is recorded and compared on blur, and an untouched field still fires nothing.

Corrected

15, :hover producing no observable pixel change: not an engine defect. The finding asked which of two causes it was. It is neither, and the evidence is in tests/blitz-tests/tests/hover_pointer_move_repaint.rs.

  • The hover media feature cannot evaluate false in any host. make_device passes a fixed PointerCapabilities::default() for both pointer fields, with no host input and no configuration.
  • The restyle does reach the painted frame. A UiEvent::PointerMove, the event a host actually sends, followed by a resolve and a paint, flips the sampled pixel from #0000ff to #ff0000 on a plain :hover rule under @media (hover: hover), and on a second case where the hover colour arrives through a CSS custom property redefined inside that media query, which is the shape the component library ships.
  • The in-repo capture path resolves before it paints: commit_cpu_frame calls inner.resolve immediately before render_to_buffer.

The two new tests are kept as regression guards. The defect is in the QA harness's own hover step or its pixel comparison, not in ps-blitz.

Deferred

2, <select> as a widget. The box is landed, which is the blocker for everything else: a select can now be found, measured and pressed. Not implemented: the popup, per-option layout and hit-testing, selection state, and the change a selection would fire. Options remain in the tree and queryable, but 0x0,hidden, and pressing a select still does nothing. SpecialElementData still has no select variant.

5, position: sticky. Not implemented, and not half-landed. stylo_taffy::convert::position still maps Position::Sticky to taffy::Position::Relative. Sticky needs a per-scroll adjustment pass that keeps a box within its containing block as the scrollport passes it, plus the paint offsets to match, which is a feature rather than a fix and does not belong in a batch of them. Every sticky navbar and doc sidebar still scrolls away.

Notes

  • One refactor commit folds a second definition of "is this a submit button" back onto the existing ElementData::is_submit_button.
  • packages/blitz-dom/src/layout/table.rs gains a cell range per row and a row range per group. blitz_dom::layout is a private module, so no public type changed shape.
  • The anonymous table box is fused into the container rather than inserted under it, which is exact when the container holds nothing but table-internal boxes. A container with mixed content still needs a separate anonymous box and does not get one here.
  • ps-blitz-shell does not build on this branch, and does not build on master either: seven errors against a drifted winit. Untouched here.
  • Version bumped to 0.4.2 in this branch, per the repo's publish-on-bump rule.

meh added 14 commits September 9, 2026 19:38
The type list that grants the 300px-by-line-height intrinsic content box
omitted "number", while the list in layout::construct that decides which
inputs get a text editor included it. A number input therefore got an
editor and a zero content box, measuring 6x6 (its padding and border)
against 306x25.2 for every other text-like type.
…g it

flush_styles_to_layout returns early on a display:none subtree, so a
stacking-context host hidden after its child list was built never rebuilds
that list. resolve_hoisted_positions walks every node that has a context,
hidden or not, and indexed the slab directly, so removing one of the listed
children panicked with "invalid SlotMap key used" at resolve.rs:863 on the
next resolve. Reported 6 times out of 6 from a signup page whose header menu
switched language and then opened a chat launcher.
Two defects on the same walk of controls_to_form.

The map is never pruned when a control leaves the document, so a framework
that re-renders a field leaves a freed id in it. The walk indexed the slab
for every id it found, so setting a value twice and then pressing Enter
panicked with "invalid SlotMap key used" and took the host down. The walk
now goes over the tree, which only yields live nodes, and removal prunes the
map so it cannot grow without bound.

The "more than one field that blocks implicit submission" rule is gated by
the spec on the form having no submit button. It was applied unconditionally,
so Enter never submitted any form with two explicitly typed fields even when
the form had a Sign in button. Three sites hit this.
Answers a QA finding that reported hover leaving every rendered pixel
unchanged on four controls. Neither proposed cause holds in the engine: the
hover media feature cannot evaluate false here (make_device passes a fixed
PointerCapabilities::default(), with no host input), and a UiEvent::PointerMove
followed by a resolve does reach the paint, including when the hover colour
arrives through a custom property gated on (hover: hover), which is the shape
the component library ships.
…tton

The implicit-submission helper had grown a second definition of the
button case. Delegate to the existing one, which also handles the command
attributes that take a button back out of the Submit Button state, and keep
only the input types here.
The legacy alias for the stop-propagation flag, in neither the shim nor the
bundled framework, so a delegated dispatcher had nothing to read and
stopPropagation did not hold. Measured on two sites: pressing a
cookie-preferences category dismissed the entire dialog, because the closing
backdrop is an ancestor of the panel. Reading it reports the flag; setting it
true raises it, and setting it false does nothing, which is what the DOM
standard says.
blitz-dom has had scroll_to_node all along, for the harness's agent action,
but page script could not reach it: getElementById(...).scrollIntoView()
raised "TypeError: not a callable function". Any anchor-scrolling router, a
"back to top" control and a validation-error focuser all call it. The
argument is accepted and ignored, because scroll_to_node lands the node at the
top-left of each scrollport, which is the default block: "start".
location was a plain data object, so a page calling any of the three threw a
TypeError out of whatever ran it. All three now go through the document's
navigation provider, which is where a link click already goes; BaseDocument
grows navigate_to_url and current_url because both resolve_url and the
provider were internal to blitz-dom.
…window dispatchEvent

history.back and history.forward changed the URL and announced nothing, so a
router's navigate(-1) never redrew and every route was one-way. Announcing it
needed a way to raise a window-targeted event at all: Node.prototype.dispatchEvent
walks a node chain and never reaches the window listeners, so window.dispatchEvent
is added here and the history shim uses it.
…een edited

change was synthesised only for checkbox and radio, so any onChange on a text
field was dead. That shipped a product bug: a phone-number field bound to
change gated a Confirm button that nothing could ever open. A text control
commits rather than notifying, so the value at focus is recorded and compared
on blur; an untouched field still fires nothing.

The markup5ever dev-dependency is for the test, which sets a field's value the
way the DOM does.
There was no select rule in the user-agent stylesheet, so a select computed
display: inline, and option { display: none } left it with no in-flow content.
Height on a non-replaced inline is ignored, so even a select with an authored
height reported 0x0 and nothing on a page could find it or press it.

Its content size cannot come from flow, because the options it is sized from
are not in it, so layout measures the widest option label directly. The
character-count estimate is the same one the textarea cols attribute already
uses: a select's label is not laid out as text anywhere yet.

The popup, option hit-testing and selection are NOT implemented. A select now
has a box and can be found and pressed; pressing it still does nothing.
Table layout flattens rows into a CSS grid of cells, so tr, thead and tbody
nodes have their box construction damage cleared and never reach Taffy. Nothing
ever wrote a layout for them: every one reported 0x0 and, to anything asking
whether an element is displayed, "no". A QA check walking a table by row had
nothing to walk.

A row is not laid out, it is described, so the context now records which cells
belong to which row and which rows to which group, and a pass after rounding
derives each box from them. After rounding because final_layout is what every
geometry query reads and the rounding pass is what fills it; reading the cells
any earlier gets zeroes.
…e internals

table { display: block } is the standard wide-table horizontal-scroll pattern,
and it made every row render side by side: the header on one line, then every
data row at the same y and increasing x. With the table no longer a table, its
thead and tbody were plain block children, and their own displays have no
mapping in the style conversion, so they fell through to Taffy's default, which
is flex, and a flex container lays its items out in a row.

CSS 2.1 17.2.1 requires an anonymous table box around misparented
table-internal boxes. When the container holds nothing else, which is this
whole pattern and every case seen on a real page, that anonymous box would be
its only child and take its content box, so it is fused into the container
instead of inserted under it. A container with mixed content still needs a
separate anonymous box and does not get one here.
The QA fixes in this branch are what downstream needs, and nothing can ask for
them until they are released. The branch originally carried a bump to 0.4.2;
master reached that version independently and has since released 0.4.6, so the
same intent is now a bump to 0.4.7.

Thirteen commits sit above the 0.4.6 release commit and none of them are on
crates.io:

  0fbbf52  size input[type=number] like the other text inputs
  01b484e  prune removed nodes from a stacking context before resolving it
  594d298  make implicit form submission safe and spec-correct
  3bcf5f8  reuse ElementData::is_submit_button for the default button
  afcb61e  expose Event.cancelBubble
  2995467  expose Element.scrollIntoView
  bddcd96  give window.location assign, replace and reload
  44de0c7  dispatch popstate on a history traversal, and give the window dispatchEvent
  e405cc3  fire change on a text control that loses focus having been edited
  fc4eee8  give <select> a layout box
  1e40949  give table rows and row groups the box their cells occupy
  c6c185c  generate a table box for a block whose children are table internals

Two of them close SlotMap panics that took the host process down: a freed
control id left in controls_to_form, and a freed child left in a stacking
context.

The patch, not the minor: every change is a fix or an addition. The new public
surface is BaseDocument::navigate_to_url and current_url, plus Element
scrollIntoView, Event.cancelBubble, window.dispatchEvent and location assign,
replace and reload on the script side. Nothing public is removed or narrowed:
the table context gained fields, but blitz_dom::layout is a private module.

The in-repo ranges stay at ^0.4.0, which accepts 0.4.7, so the whole bump is
one line.
@pathscale
pathscale force-pushed the fix/qa-engine-defects-2026-09-09 branch 2 times, most recently from e454d46 to e6287bd Compare September 9, 2026 13:09
meh added 2 commits September 9, 2026 20:55
`tests/qa/run.sh` built `qa-inspect-host`. ps-observability deleted that
crate in "refactor: delete qa-inspect-host, so there is one headless
browser", so every run since has died with

    error: package ID specification `qa-inspect-host` did not match any packages

including on master, whose last green run predates the deletion by forty
minutes and so only looks fine because nothing has rebuilt it.

`chuzz-headless` replaced it. The patches move with it, because judging this
checkout rather than the last release is the whole reason the host is built
here, and they now cover every ps-blitz crate chuzz reaches instead of the
five the old host needed: patching some and not others resolves the rest
from crates.io, and an engine built half from this checkout and half from a
release does not compile.

`bun install` in chuzz's frontend first. chuzz's build script builds its
Solid chrome even for a headless binary that never links it, and without
`node_modules/.bin` it panics with `solid-layouts-library: command not
found`. The gate that skips it is in chuzz's own open PR, so without this
install ps-blitz could not go green until chuzz merged, while chuzz should
not merge until ps-blitz publishes. It is cheap, and harmless once the gate
lands.

Verified end to end against chuzz master, the state that has the defect:
all fixture groups pass, failed: 0.
`cargo doc --workspace` rebuilt the whole graph on every pull request and
nothing downstream read its output. Gating it behind workflow_dispatch was
the half measure; the job is gone.

Nothing depended on it: no other job named it in `needs`, and it produced
no artifact.
@pathscale
pathscale force-pushed the fix/qa-engine-defects-2026-09-09 branch from 1131c44 to d6859cd Compare September 9, 2026 14:12
@pathscale
pathscale merged commit 37fe66c into master Sep 9, 2026
10 checks passed
@pathscale
pathscale deleted the fix/qa-engine-defects-2026-09-09 branch September 9, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant