Skip to content

Reset navigation projection state per transform (#1334) - #1336

Merged
chubes4 merged 1 commit into
trunkfrom
fix-1334-navigation-suppression-session
Aug 29, 2026
Merged

Reset navigation projection state per transform (#1334)#1336
chubes4 merged 1 commit into
trunkfrom
fix-1334-navigation-suppression-session

Conversation

@chubes4

@chubes4 chubes4 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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 reused HtmlTransformer inherited 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:

documents differing
Current trunk 143 of 374
This branch 0 of 374

38% of the corpus was order-dependent.

Reachable paths, both in-tree:

  • CorpusDiagnostics/CorpusDiagnosticsRunner constructs one transformer and loops the whole corpus through it, so the findings worklist it produces depended on iteration order.
  • VisualParity/StaticStyleParityRunner holds 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 NavigationProjectionState on HtmlTransformerSession, alongside the runtime, evidence, provenance and style state that already reset with every transform. NavigationToggleSuppressor reaches 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:

A fresh session is installed for every transform so a reused transformer retains only its immutable collaborators and shared analysis cache.

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 pass DOMElements and no call site hand-rolls getNodePath() into an array key.

Verification

The regression test fails without the fix. tests/unit/navigation-projection-state-isolation.php asserts 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:

Navigation projection isolation contract failed:
  reused transformer produced 56473 bytes, fresh produced 58830.

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_blocks SHA-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 test exit 0.

Note on the corpus-diagnostics worklist

This changes corpus-diagnostics output, 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.

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.
@chubes4
chubes4 merged commit fcca8ef into trunk Aug 29, 2026
10 checks passed
@chubes4
chubes4 deleted the fix-1334-navigation-suppression-session branch August 29, 2026 02:06
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.

Navigation toggle suppression state leaks between transform() calls on a reused HtmlTransformer

1 participant