Repository navigation
fix(php-transformer): reproduce the captured disclosure runtime faithfully in native dialogs - #2735
Merged
Merged
Conversation
…fully in native dialogs - Carry aria-* and data-* ancestor bindings to the native dialog and replay them like the disclosure runtime (set or remove, closed state on load), through one shared attribute allowlist. - Keep a projected captured dialog as a native dialog block instead of core/details, which cannot express Escape, aria-expanded or ancestor bindings. - Verify the published digest on every runtime retirement path (navigation submenu, gallery helper, projected dialogs); a page carrying an unrecognized runtime body keeps that runtime and its wiring. - Re-record the equivalence cases these changes affect and document them.
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.
Refs Automattic/data-liberation-agent#787 (follow-up). Refs #2729.
Summary
#2729 listed three existing gaps in how native replacement reproduces the capture's disclosure runtime ("Known gaps"). This PR closes all three:
aria-*anddata-*ancestor bindings.core/details.1.
aria-*/data-*ancestor bindingsRoot cause.
CapturedDialogProjector::triggerAncestorState()kept only rows whose attributes wereclass,styleorhidden. The producer recordsclass|style|hidden|aria-*|data-*, andDISCLOSURE_RUNTIME.applyAncestorsreplays every recorded attribute. A row like{class, data-section-theme}was therefore dropped as a whole.What the runtime does, and what the native dialog now does. For
hidden,aria-*anddata-*the runtime callssetAttribute(name, value), orremoveAttribute(name)when the recorded value isnull. It does this with the opened value on open and the closed value on close. It also applies the closed value on load (ready()→apply()→applyAncestors(trigger, false)). The native view script now does the same:replayAttributes()sets or removes the attribute on open, on close and Escape, and at mount.data-section-themeon a header that becomescore/group. The runtime restores them on load, and now the native dialog does too.classwhose recorded value isnullis now removed. This is the small difference fix(php-transformer): recognize the current capture disclosure and gallery runtimes #2729 noted.div. Each trigger resolves its ancestors once, at mount.Attribute allowlist.
CapturedDialogBlockGenerator::ANCESTOR_ATTRIBUTE_PATTERN=^(?:class|style|hidden|aria-[a-z0-9-]+|data-(?!blocks-engine-)[a-z0-9_.:-]+)$. The same constant is used by the projector,CapturedDialogConverterand the view script (as aRegExp), through one sanitizer,CapturedDialogBlockGenerator::ancestorState().DROPDOWN_ANCESTOR_ATTRIBUTE_PATTERN, and the runtime replays only what was recorded. So this is exactly the set the runtime can toggle.on*,href,src,srcset,actionand similar never match, because the producer never records them and the runtime never sets them. No URL policy is needed.data-blocks-engine-*holds the native dialog's own state (data-blocks-engine-triggers,data-blocks-engine-mounted, ...) and capture evidence. Replaying it would let input rewire the native runtime. This plays the role the producer's(?!lib-|dla-)exclusion played for its own former namespaces.data-blocks-engine-ancestor-statethrough unfiltered. It now uses the same sanitizer, and the view script checks names again before callingsetAttribute.Proof. The new
capture-runtime-attribute-parity{.php,-browser.mjs}drives the published runtime and the native dialog through initial → open → close → reopen → Escape → open again. Its rows are attribute-only: headerdata-section-theme, scopearia-expandednull→trueanddata-state, and bodydata-menunull→open.aria-expandedand the header paint that is keyed on the attribute.initial:data-section-themeisnullnatively anddarkin the source. The PHP half fails on trunk because the rows don't reach the dialog.2. Text-labelled triggers becoming
core/detailsRoot cause. The projector replaces the panel with a native dialog (
data-blocks-engine-captured-dialog="true") and retires the runtime. During conversion, the trigger and that dialog have the shape of a source aria disclosure.DetailsPattern::matchDisclosureruns on the parent and claims them beforeCapturedDialogConvertersees the dialog. An icon-only trigger escaped only because its summary text was empty.Decision rule (now in
docs/contracts/capture-evidence.md):aria-expandedand replays ancestor bindings.core/detailsexpresses none of these, so it would lose something even when no ancestor binding is present.core/detailsis still chosen for disclosures that no capture runtime drives (source aria disclosures, native<details>), where it loses nothing.Proof. The new
captured-dialog-text-trigger.phpcovers three cases:core/details.The first two fail on trunk. The browser parity test above also uses a text trigger.
3. Runtime retirement without a digest check
Root cause. Only
canReplaceScopedDisclosure(wired dropdowns in a declared document scope) compared digests.removeDisclosureRuntime()removed any marked script on three paths:Fix.
CapturedDialogProjector::isRecognizedRuntimeScript($script, $marker)maps each marker to itsCaptureEvidence::*_RUNTIME_SHA256. Every check and every removal goes through it:canReplaceScopedDisclosure, andretireRecognizedRuntime(), which replacesremoveDisclosureRuntime()and the gallery loop.projectPage,retireOwnedDisclosureWithoutProjection). Checking only at removal would leave a kept runtime togglinghiddenon a native<dialog>that the view script also drives.Proof. Each test checks a published body and the same body with one byte changed. The altered-body assertions fail on trunk.
captured-navigation-runtime.php: the published helper replaces a stand-in body. The altered body is kept on all three navigation paths (observed states, no observed states, no interaction report), in both whole and staged compilation.navigation-button-dropdown-submenu.php: the menu-only capture keeps an altered body.captured-dialog-projector.php: unscoped close-helper and wired-panel projection with the published body. With an altered body the page is left byte-identical, with no replacement claim.captured-gallery-contract.phpandhead-script-retirement: the published gallery body is retired with a proof that carries its digest. An altered body ships with the page and gets no native lightbox.Equivalence gate
The gate passes 349/349. 36 expectations are re-recorded deliberately. For each one, I compared this branch's normalized output with trunk's, and replayed with this branch's PHP plus trunk's view script to separate the two causes. The 36 case IDs are listed in
tools/capture-evidence-equivalence/README.md, which also explains how to regenerate them. Gap 2 changes no corpus case.window.captureGallery=true;,document.querySelectorAll(…)), not the published ones. Their pages are now left as captured.07f8a0bd 1e87fd71 28e1a0f1 2f545afa 3e283668 647b8b53 bab702ca): the stand-in is no longer retired.3317930e 461f0a83 5eccb541 f67e5ca6): the wiring and runtime are kept and no dialog is projected.0bf7a2ea 12d3ff94 3b0aed43 62d3b1f8 8d155e3b 90115e60 bcd4e29b): the gallery is kept as captured.captured_dialog_trigger_unmatched,captured_dialog_unsafe_or_truncated;6cdada86 6ed86c41 b913b3b4 eaad29ef fcc75319 feef8efc). The page is skipped, so those warnings are no longer emitted.0a9c1bea 15082c2d 21e5cdbf 2915d21f 37284b0e 55b724ac 7a7af898 7c2d1217 927aa368 944dd63d df9c24a8 e5ea5743.The gate's success line now says that the expectations include these deliberate changes. The throw-classification guard is unchanged: 623/623 throw sites classified, and no throw sites were added or moved.
Not changed (observed)
data-blocks-engine-capture-collection-runtimeanddata-blocks-engine-capture-local-disclosure-runtime(CapturedCollectionProjector). Neither has a published body or digest yet.stylerows are still replayed per declaration, while the runtime replaces the wholestyleattribute.core/buttonkeeps its id on the wrapper. The view script drives the inner button'saria-expanded, butaria-controlsandaria-haspopupare not carried.Verification (local, macOS, PHP 8.5)
composer install,composer validate --strict,composer lint(PHPStan clean) andcomposer test:lint: pass.composer test: pass, including the equivalence gate (349/349), the throw-classification guard and both new unit tests.php tests/contract/production-acceptance-matrix.php: pass.npm ci,npm run lintandnpm test(with pre/posttest) pass, as does every browser step in the job, including the new attribute-parity step.layout-media-tracks, default and responsive, passed against the local WordPress 7.1 install instead of the 7.0.4 tarball CI downloads, because I used local fixtures only.origin/trunkcheckout and fails there as described above.AI disclosure: written by Claude in OpenCode with Chris Huber (direct implementation authorized by the operator); Claude implemented, tested and opened this PR.