Reset navigation projection state per transform (#1334) - #1336
Merged
Conversation
Navigation projection state is keyed by DOMElement::getNodePath(), and those paths collide freely across unrelated documents. The three maps holding it lived on the transformer rather than the per-transform session and were never reset, so a document transformed on a reused HtmlTransformer inherited suppression decisions belonging to whatever was transformed before it, and silently lost block markup. 143 of 374 corpus documents produce different output when transformed through one shared HtmlTransformer instead of a fresh one. After this change, 0 do. CorpusDiagnosticsRunner loops the entire corpus through a single transformer, so its findings worklist was order-dependent. StaticStyleParityRunner holds one across comparisons. The maps move into a NavigationProjectionState on HtmlTransformerSession, alongside the runtime, evidence and style state that already reset with every transform, and the suppressor reaches them through its context. HtmlTransformerSession already documented this guarantee -- "a fresh session is installed for every transform so a reused transformer retains only its immutable collaborators and shared analysis cache" -- which these three maps had quietly violated. Adds tests/unit/navigation-projection-state-isolation.php, which asserts a reused transformer reproduces fresh-instance output byte for byte and that projection writes do not survive a transform. Verified to fail on the parent commit and pass here. Fresh-instance behavior is unchanged: 383 fixture documents, 0 differing, 0 throwing. composer test exit 0.
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 #1334.
Navigation projection state is keyed by
DOMElement::getNodePath()— values like/html/body/div[1]/button[1]that are only meaningful inside the document they came from. The three maps holding it lived on the transformer rather than the per-transform session and were never reset, so a document transformed on a reusedHtmlTransformerinherited suppression decisions belonging to whatever ran before it.Blast radius is larger than the report
#1334 demonstrated one document losing 2,357 bytes. Running the full corpus through a single shared transformer and comparing each document against its own fresh-instance output:
trunk38% of the corpus was order-dependent.
Reachable paths, both in-tree:
CorpusDiagnostics/CorpusDiagnosticsRunnerconstructs one transformer and loops the whole corpus through it, so the findings worklist it produces depended on iteration order.VisualParity/StaticStyleParityRunnerholds one transformer across comparisons.Consumers that build a transformer per document were never affected, which is why the fixture suite stayed green over this.
The fix
The maps move into a
NavigationProjectionStateonHtmlTransformerSession, alongside the runtime, evidence, provenance and style state that already reset with every transform.NavigationToggleSuppressorreaches them through its context rather than owning them.No reset call was added.
transform()already builds a fresh session, so correctness follows from where the state lives rather than from remembering to clear it. That distinction is the point — a reset call is a thing to forget, and this state was previously forgotten for exactly that reason.HtmlTransformerSession's own docblock already promised this:These three maps had quietly violated that guarantee. The class now means what it says.
The typed accessors (
projectTarget,suppress,isSuppressed,markImplicitDialogControl, …) also move node-path keying inside the state object, so callers passDOMElements and no call site hand-rollsgetNodePath()into an array key.Verification
The regression test fails without the fix.
tests/unit/navigation-projection-state-isolation.phpasserts a reused transformer reproduces fresh-instance output byte for byte, and that projection writes do not survive a transform. Against the parent commit it fails as intended:It also guards against passing for the wrong reason: it verifies the priming document genuinely populates projection state, so the test cannot go green because the fixture stopped exercising the path.
Fresh-instance behavior is unchanged. 383 fixture documents with stylesheets attached,
serialized_blocksSHA-256 plus block, diagnostic, fallback, asset and coverage fingerprints — 0 differing, 0 throwing, against a fingerprint verified discriminating at 383 distinct hashes over 22 MB of markup.composer testexit 0.Note on the corpus-diagnostics worklist
This changes
corpus-diagnosticsoutput, because that tool was reading contaminated results for 143 documents. The new output is the correct one. Anyone holding a stored worklist from before this lands should regenerate rather than diff against it.Follow-up worth considering
The same shape may exist elsewhere: state that survived the migration into
Session/by living on a collaborator or trait instead. A cheap check is to run the corpus through one shared transformer and diff against fresh-instance output — that is now zero, so any future regression of this class shows up immediately as a non-zero count. Turning that into a standing contract test would make the guarantee enforceable rather than incidental, though it is a slow check and would want a place outside the fast unit suite.AI assistance disclosure: implemented and drafted by Claude Sonnet 4.6 running in Claude Code, operated by @chubes4. The AI had found and reported the underlying defect in #1334 while measuring an unrelated refactor slice; here it moved the state onto the session, wrote the regression test, verified that test fails on the parent commit, and measured the 143/374 corpus figure quoted above. Reviewed by a human before opening.