From 29ccad700270abcef16ba4b5e6df912251b55ac0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 26 Sep 2026 22:58:07 +0200 Subject: [PATCH] Test lifecycle-aware Escape ownership routing without settling delays Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9 --- TREE.md | 8 + research/README.md | 4 + research/candidates/FocusReturn.md | 12 + research/catalog/SourceCatalog.md | 6 + .../experiments/boundary-recovery/README.md | 25 + .../boundary-recovery/routing-design.md | 30 + .../routing-development-results.json | 2120 +++++++++++++++++ .../routing-development-runner.mjs | 46 + .../boundary-recovery/routing-fixture.jsx | 35 + .../boundary-recovery/routing-results.json | 2109 ++++++++++++++++ .../boundary-recovery/routing-transform.mjs | 49 + .../experiments/boundary-recovery/routing.mjs | 46 + research/plan/OpenQuestions.md | 5 + research/reviews/NestedDismissalIsolation.md | 8 + research/reviews/NestedOwnershipRouting.md | 97 + workplans/FEP-WP-0003-contrasting-schools.md | 17 + 16 files changed, 4617 insertions(+) create mode 100644 research/experiments/boundary-recovery/routing-design.md create mode 100644 research/experiments/boundary-recovery/routing-development-results.json create mode 100644 research/experiments/boundary-recovery/routing-development-runner.mjs create mode 100644 research/experiments/boundary-recovery/routing-fixture.jsx create mode 100644 research/experiments/boundary-recovery/routing-results.json create mode 100644 research/experiments/boundary-recovery/routing-transform.mjs create mode 100644 research/experiments/boundary-recovery/routing.mjs create mode 100644 research/reviews/NestedOwnershipRouting.md diff --git a/TREE.md b/TREE.md index 743178c..26836dc 100644 --- a/TREE.md +++ b/TREE.md @@ -360,6 +360,13 @@ research/experiments/boundary-recovery/package.json research/experiments/boundary-recovery/provenance.json research/experiments/boundary-recovery/result-immediate.json research/experiments/boundary-recovery/result-settled.json +research/experiments/boundary-recovery/routing-design.md +research/experiments/boundary-recovery/routing-development-results.json +research/experiments/boundary-recovery/routing-development-runner.mjs +research/experiments/boundary-recovery/routing-fixture.jsx +research/experiments/boundary-recovery/routing-results.json +research/experiments/boundary-recovery/routing-transform.mjs +research/experiments/boundary-recovery/routing.mjs research/experiments/matrix-recovery/README.md research/experiments/matrix-recovery/experiment.mjs research/experiments/matrix-recovery/package-lock.json @@ -383,6 +390,7 @@ research/reviews/MatrixRecoveryExperiment.md research/reviews/NavigationCoverage.md research/reviews/NavigationRenderingEvidence.md research/reviews/NestedDismissalIsolation.md +research/reviews/NestedOwnershipRouting.md research/reviews/ObsidianConflictScreen.json research/reviews/PatternBreadthExpansion.md research/reviews/RecoveryReviewMetrics.json diff --git a/research/README.md b/research/README.md index 2333a20..5590638 100644 --- a/research/README.md +++ b/research/README.md @@ -56,3 +56,7 @@ and a timing-sensitive nested-dialog failure; no production or accessibility cer Nested-dialog follow-up: [ownership isolation](reviews/NestedDismissalIsolation.md) captures a stale Escape-listener interval in a 48-trial browser/build comparison. A partial guard loses input; a complete remedy remains open. Original results retained. + +Latest bounded mechanism test: [ownership routing](reviews/NestedOwnershipRouting.md) +meets 24/24 routed assertions versus 20/24 unmodified controls, with explicit scope +limits. No delay or readiness wait; no production fork or canonical promotion. diff --git a/research/candidates/FocusReturn.md b/research/candidates/FocusReturn.md index e1070d5..910b1c8 100644 --- a/research/candidates/FocusReturn.md +++ b/research/candidates/FocusReturn.md @@ -62,3 +62,15 @@ A current-owner guard can lose the keypress; private listener-readiness waiting its trials but is not a product solution. Test ownership of dismissal as well as return focus, including activation/teardown input. No arbitrary-delay fix, production repair or full-pattern promotion is established. + +## Experimental routing evidence — 2026-09-26 + +[Routing comparison](../reviews/NestedOwnershipRouting.md) tests layout registration, +a single document Escape dispatcher and event-time owner selection. The experimental +router meets 24/24 scenario assertions across two browsers/builds; its unmodified +control meets 20/24. It preserves cancellation and removes ownership on teardown +without a delay or private-readiness wait. Return to a replacement target still +requires application policy. This supports separating input ownership from focus +restoration, not a general production-ready fix: animated exits, competing owners, +rapid input bursts and assistive technology remain untested. Sixteen hypotheses; +no new pattern or maturity promotion. diff --git a/research/catalog/SourceCatalog.md b/research/catalog/SourceCatalog.md index 7224047..6610b34 100644 --- a/research/catalog/SourceCatalog.md +++ b/research/catalog/SourceCatalog.md @@ -127,3 +127,9 @@ SRC-0029 follow-up: [nested dismissal isolation](../reviews/NestedDismissalIsola reuses the same pinned packages across two browsers/build modes. Instrumented and experimentally modified bundles are explicitly separated from the unmodified control; this is additional evidence from the same family, not a new independent use. + +SRC-0029 further follow-up: [experimental ownership routing](../reviews/NestedOwnershipRouting.md) +compares unchanged packages with an explicitly modified in-memory Escape mechanism. +The routed results are evidence for our proposed design, not observations of shipped +vendor behavior. Current raw control also reproduces production wrong-owner dismissal +in the new controlled fixture. Prior receipts remain unchanged. diff --git a/research/experiments/boundary-recovery/README.md b/research/experiments/boundary-recovery/README.md index b1a2a14..058dde4 100644 --- a/research/experiments/boundary-recovery/README.md +++ b/research/experiments/boundary-recovery/README.md @@ -86,3 +86,28 @@ Instrumentation and an experimental guard are applied in memory during bundling; installed vendor files are not modified. The readiness variant observes private listener state and is not a proposed public readiness API. The guard can lose input and is not a validated fix. Do not replace raw failures with readiness passes. + +## Follow-up: experimental routing + +[Routing design](routing-design.md), [review](../../reviews/NestedOwnershipRouting.md) +and [corrected results](routing-results.json) compare a single document router with +unmodified behavior. Six scenarios across two browsers/builds: routed 24/24, raw +20/24. This is a research patch, not a production repair or published dependency fork. + +```bash +PLAYWRIGHT_BROWSERS_PATH=/tmp/fep-playwright-browsers FEP_CHROMIUM_PATH=/path/to/chrome node routing.mjs +``` + +Uses the same installed dependencies/browsers as isolation.mjs. Outputs +`/tmp/fep-routing.json` (override with `FEP_RESULT_PATH`) and an incomplete checkpoint +at `/tmp/fep-routing-checkpoint.json`. No sleep, readiness wait or automatic retry. +The transform changes Escape routing only in memory and verifies the pinned source +hash before use. `routing-fixture.jsx` explicitly controls both dialogs, so its +results cannot be pooled with the earlier uncontrolled-inner fixture as matched trials. + +The initial [development results](routing-development-results.json) used an incorrect +accessibility-exposed dialog count for cancellation. Their original `routing.mjs` +bytes are retained as [routing-development-runner.mjs](routing-development-runner.mjs) +for hash verification; that archived file is provenance, not the reproduction entry. +The corrected runner counts hidden parents and checks listener cleanup. Both runs +are retained, and the earlier faulty cancellation scores are excluded from conclusions. diff --git a/research/experiments/boundary-recovery/routing-design.md b/research/experiments/boundary-recovery/routing-design.md new file mode 100644 index 0000000..1975c31 --- /dev/null +++ b/research/experiments/boundary-recovery/routing-design.md @@ -0,0 +1,30 @@ +# Experimental Escape routing + +This is a synthetic alternative for one pinned Radix module, implemented through an +in-memory bundle transform. It is not an upstream patch, supported API or deployment. +The existing isolation experiment remains unchanged. + +Proposed contract: + +1. Register a connected dialog owner during layout, before ordinary user interaction. +2. Keep one capture listener per document. On Escape, snapshot one current owner. +3. Invoke that owner's callback once; honor cancellation without falling through to + its parent. Invoke dismissal only if the callback has not prevented it. +4. Remove ownership during layout cleanup; disconnecting the final owner removes + the document listener. Ignore detached nodes when selecting an owner. +5. Keep focus restoration separate: a removed invoker still needs an application + target, and asynchronous focus restoration has its own lifecycle. + +The registry currently uses mount insertion order, which is a limited proxy for +ownership in the tested two-level nesting. It does not model arbitrary z-order, +multiple library copies, nested documents, shadow DOM or animation-preserved exiting +layers. A controlled dialog can reject a dismissal; this router does not mark it +closed merely because a callback ran. Rapid repeats, same-turn bursts and competing +non-dialog Escape handlers require separate contracts and testing. + +The six scenarios are ordinary close, immediate nested close, cancelled inner close, +removed invoker with an application target, programmatic inner removal then reopen, +and ancestor teardown followed by Escape. Each runs once per browser/build/variant: +Chromium/Firefox, development/production, original behavior/experimental router. +There are no sleeps, private-readiness waits or automatic retries. This is a mechanism +comparison, not an estimate of occurrence rates or proof of a general repair. diff --git a/research/experiments/boundary-recovery/routing-development-results.json b/research/experiments/boundary-recovery/routing-development-results.json new file mode 100644 index 0000000..844f91e --- /dev/null +++ b/research/experiments/boundary-recovery/routing-development-results.json @@ -0,0 +1,2120 @@ +{ + "date": "2026-09-26T20:51:27.836Z", + "node": "v24.11.1", + "files": { + "routing.mjs": "9761a98198623d81967f5460c8647eeb13041849f33de1982109f6e046b4e5b7", + "routing-fixture.jsx": "d99875390941752047eda63a93c921af977c35b36371dd609cd46e7ba730a51d", + "routing-transform.mjs": "417589ab170ea1576452d0a903fbd8e1870d3d91d2a49b50707c36b0aef56335", + "package.json": "a0ad37e8518b0c561d6b4a091ee51f5bca90e0ce329b2be7ba75347f6dc26ae7", + "package-lock.json": "ec4b544d441f89d9c512bc9931c1818473327105d5ef9047dc04d6a721e68463" + }, + "sourceSha256": "01df449a23d65bab1f2f2215a953cba74741419496ebd9a7932533f267cc873d", + "transformedSha256": "1f42e26b9789a12263182ca1ee2b623870499d73456e4ad90172a8fc39c8618b", + "bundleHashes": { + "development-raw": "7fc5a9df75b15ec7da3c1a625624e2166c74609788cf46dc3ac03a88d5a639d6", + "development-routed": "fcdf229aeb5025a038ee6baaec7927031e032b75ed5899d1294ea199cd0921b1", + "production-raw": "1846d180a6fcf20426febaa355f47bc86e3b2e59a139fec55882a1ba460ab56d", + "production-routed": "fd73a8c4684b7cbb90ac87f08ef318a48c3b9444f8747b994e21217ee9666740" + }, + "summary": [ + { + "engine": "chromium", + "mode": "development", + "variant": "raw", + "passed": 4, + "total": 6 + }, + { + "engine": "chromium", + "mode": "development", + "variant": "routed", + "passed": 5, + "total": 6 + }, + { + "engine": "chromium", + "mode": "production", + "variant": "raw", + "passed": 4, + "total": 6 + }, + { + "engine": "chromium", + "mode": "production", + "variant": "routed", + "passed": 5, + "total": 6 + }, + { + "engine": "firefox", + "mode": "development", + "variant": "raw", + "passed": 5, + "total": 6 + }, + { + "engine": "firefox", + "mode": "development", + "variant": "routed", + "passed": 5, + "total": 6 + }, + { + "engine": "firefox", + "mode": "production", + "variant": "raw", + "passed": 5, + "total": 6 + }, + { + "engine": "firefox", + "mode": "production", + "variant": "routed", + "passed": 5, + "total": 6 + } + ], + "results": [ + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "nested-immediate", + "status": "fail", + "error": "page.waitForFunction: Timeout 1200ms exceeded.", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "cancel-inner", + "status": "fail", + "error": "Expected values to be strictly equal:\n\n1 !== 2\n", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "cancel-inner", + "status": "fail", + "error": "Expected values to be strictly equal:\n\n1 !== 2\n", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "cancel-inner", + "status": "fail", + "error": "Expected values to be strictly equal:\n\n1 !== 2\n", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "remove-and-reopen", + "status": "fail", + "error": "page.waitForFunction: Timeout 1200ms exceeded.", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "cancel-inner", + "status": "fail", + "error": "Expected values to be strictly equal:\n\n1 !== 2\n", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "cancel-inner", + "status": "fail", + "error": "Expected values to be strictly equal:\n\n1 !== 2\n", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "cancel-inner", + "status": "fail", + "error": "Expected values to be strictly equal:\n\n1 !== 2\n", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "cancel-inner", + "status": "fail", + "error": "Expected values to be strictly equal:\n\n1 !== 2\n", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "cancel-inner", + "status": "fail", + "error": "Expected values to be strictly equal:\n\n1 !== 2\n", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + } + ] +} diff --git a/research/experiments/boundary-recovery/routing-development-runner.mjs b/research/experiments/boundary-recovery/routing-development-runner.mjs new file mode 100644 index 0000000..2b2011d --- /dev/null +++ b/research/experiments/boundary-recovery/routing-development-runner.mjs @@ -0,0 +1,46 @@ +import {build} from 'esbuild'; +import {chromium, firefox} from 'playwright'; +import {createServer} from 'node:http'; +import {once} from 'node:events'; +import {readFile, writeFile} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {fileURLToPath} from 'node:url'; +import assert from 'node:assert/strict'; +import {routeEscape} from './routing-transform.mjs'; +const dir=fileURLToPath(new URL('.',import.meta.url)); +const hash=x=>createHash('sha256').update(x).digest('hex'); +const source=await readFile(dir+'node_modules/@radix-ui/react-dismissable-layer/dist/index.mjs','utf8'); +assert.equal(hash(source),JSON.parse(await readFile(dir+'provenance.json')).inspected_files['@radix-ui/react-dismissable-layer/dist/index.mjs'].sha256); +const bundles=new Map(); +for(const mode of ['development','production']) for(const variant of ['raw','routed']) { + const b=await build({entryPoints:[dir+'routing-fixture.jsx'],bundle:true,write:false,format:'iife',define:{'process.env.NODE_ENV':JSON.stringify(mode)},plugins:variant==='raw'?[]:[{name:'experimental-escape-router',setup(b){b.onLoad({filter:/@radix-ui\/react-dismissable-layer\/dist\/index\.mjs$/},()=>({contents:routeEscape(source),loader:'js',resolveDir:dir+'node_modules/@radix-ui/react-dismissable-layer/dist'}));}}]}); + bundles.set(mode+'-'+variant,b.outputFiles[0].contents); +} +const server=createServer((req,res)=>{const u=new URL(req.url,'http://localhost');if(u.pathname==='/bundle.js'){res.setHeader('Content-Type','application/javascript');res.end(bundles.get(u.searchParams.get('key')));}else{res.setHeader('Content-Type','text/html');res.end(`Routing experiment
`);}}); +server.listen(0,'127.0.0.1');await once(server,'listening'); +const results=[]; +async function focused(page,id){await page.waitForFunction(id=>document.activeElement?.id===id,id,{timeout:1200});} +async function outer(page){await page.locator('#outer-trigger').focus();await page.keyboard.press('Enter');await focused(page,'inner-trigger');} +async function inner(page){await outer(page);await page.keyboard.press('Enter');await focused(page,'inner-close');} +const cases={ + 'ordinary':async p=>{await outer(p);await p.keyboard.press('Escape');await focused(p,'outer-trigger');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),['outer']);}, + 'nested-immediate':async p=>{await inner(p);await p.keyboard.press('Escape');await focused(p,'inner-trigger');assert.equal(await p.getByRole('dialog').count(),1);await p.keyboard.press('Escape');await focused(p,'outer-trigger');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),['inner','outer']);}, + 'cancel-inner':async p=>{await inner(p);await p.keyboard.press('Escape');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),['inner']);assert.equal(await p.getByRole('dialog').count(),2);await focused(p,'inner-close');}, + 'remove-trigger':async p=>{await inner(p);await p.keyboard.press('Tab');await focused(p,'remove-trigger');await p.keyboard.press('Enter');await focused(p,'outer-close');await p.keyboard.press('Escape');await focused(p,'outer-trigger');}, + 'remove-and-reopen':async p=>{await inner(p);await p.evaluate(()=>window.removeInner());await focused(p,'inner-trigger');await p.keyboard.press('Enter');await focused(p,'inner-close');await p.keyboard.press('Escape');await focused(p,'inner-trigger');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),['inner']);}, + 'ancestor-teardown':async p=>{await inner(p);await p.evaluate(()=>window.removeTask());await p.getByRole('dialog').waitFor({state:'detached'});await p.locator('#next').focus();await p.keyboard.press('Escape');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),[]);await focused(p,'next');} +}; +try{ + for(const [engine,launcher]of [['chromium',chromium],['firefox',firefox]]){ + const browser=await launcher.launch({headless:true,...(engine==='chromium'?{executablePath:process.env.FEP_CHROMIUM_PATH}: {})}); + try{for(const mode of ['development','production'])for(const variant of ['raw','routed'])for(const [name,run]of Object.entries(cases)){ + const page=await browser.newPage();page.setDefaultTimeout(2000);const errors=[];page.on('pageerror',e=>errors.push(e.message));const r={engine,browser:browser.version(),mode,variant,case:name,status:'pass'}; + try{await page.goto(`http://127.0.0.1:${server.address().port}/?key=${mode}-${variant}${name==='cancel-inner'?'&cancel=1':''}`);await run(page);assert.deepEqual(errors,[]);}catch(e){r.status='fail';r.error=e.message;} + r.observed=await page.evaluate(()=>({active:document.activeElement?.id,dialogs:document.querySelectorAll('[role=dialog]').length,...window.routingObserved}));r.browserErrors=errors;results.push(r);await page.close(); + await writeFile('/tmp/fep-routing-checkpoint.json',JSON.stringify({status:'incomplete',results},null,2)+'\n'); + }}finally{await browser.close();} + } +}finally{await new Promise(resolve=>server.close(resolve));} +const files={};for(const name of ['routing.mjs','routing-fixture.jsx','routing-transform.mjs','package.json','package-lock.json'])files[name]=hash(await readFile(dir+name)); +const summary=[];for(const engine of ['chromium','firefox'])for(const mode of ['development','production'])for(const variant of ['raw','routed']){const r=results.filter(x=>x.engine===engine&&x.mode===mode&&x.variant===variant);summary.push({engine,mode,variant,passed:r.filter(x=>x.status==='pass').length,total:r.length});} +const output=process.env.FEP_RESULT_PATH||'/tmp/fep-routing.json';await writeFile(output,JSON.stringify({date:new Date().toISOString(),node:process.version,files,sourceSha256:hash(source),transformedSha256:hash(routeEscape(source)),bundleHashes:Object.fromEntries([...bundles].map(([k,v])=>[k,hash(v)])),summary,results},null,2)+'\n');console.log(JSON.stringify({output,summary},null,2)); diff --git a/research/experiments/boundary-recovery/routing-fixture.jsx b/research/experiments/boundary-recovery/routing-fixture.jsx new file mode 100644 index 0000000..4518731 --- /dev/null +++ b/research/experiments/boundary-recovery/routing-fixture.jsx @@ -0,0 +1,35 @@ +import React, {useState, useRef} from 'react'; +import {createRoot} from 'react-dom/client'; +import * as Dialog from '@radix-ui/react-dialog'; +window.routingObserved = {changes: [], escapes: [], trace: []}; +const options = new URLSearchParams(location.search); +function App() { + const [outer, setOuter] = useState(false); + const [inner, setInner] = useState(false); + const [mounted, setMounted] = useState(true); + const [trigger, setTrigger] = useState(true); + const next = useRef(null); + const change = (name, setter) => value => {window.routingObserved.changes.push({name, value});setter(value);}; + window.removeInner = () => setInner(false); + window.removeTask = () => setMounted(false); + return
+ {mounted && + Open outer + window.routingObserved.escapes.push('outer')}> + Outer task + + {trigger && Open inner} + {window.routingObserved.escapes.push('inner');if(options.has('cancel')) event.preventDefault();}} + onCloseAutoFocus={event => {if(!trigger){event.preventDefault();document.getElementById('outer-close')?.focus();}}}> + Inner task + Close inner + + + + Close outer + + } +
; +} +createRoot(document.getElementById('root')).render(); diff --git a/research/experiments/boundary-recovery/routing-results.json b/research/experiments/boundary-recovery/routing-results.json new file mode 100644 index 0000000..bbff8a1 --- /dev/null +++ b/research/experiments/boundary-recovery/routing-results.json @@ -0,0 +1,2109 @@ +{ + "date": "2026-09-26T20:56:21.684Z", + "node": "v24.11.1", + "files": { + "routing.mjs": "dfe92f2d5bc12ce0e1fedf7c2cb7f69511fff3fea7219d89fd3095dfb2ccdee8", + "routing-fixture.jsx": "d99875390941752047eda63a93c921af977c35b36371dd609cd46e7ba730a51d", + "routing-transform.mjs": "417589ab170ea1576452d0a903fbd8e1870d3d91d2a49b50707c36b0aef56335", + "package.json": "a0ad37e8518b0c561d6b4a091ee51f5bca90e0ce329b2be7ba75347f6dc26ae7", + "package-lock.json": "ec4b544d441f89d9c512bc9931c1818473327105d5ef9047dc04d6a721e68463" + }, + "sourceSha256": "01df449a23d65bab1f2f2215a953cba74741419496ebd9a7932533f267cc873d", + "transformedSha256": "1f42e26b9789a12263182ca1ee2b623870499d73456e4ad90172a8fc39c8618b", + "bundleHashes": { + "development-raw": "7fc5a9df75b15ec7da3c1a625624e2166c74609788cf46dc3ac03a88d5a639d6", + "development-routed": "fcdf229aeb5025a038ee6baaec7927031e032b75ed5899d1294ea199cd0921b1", + "production-raw": "1846d180a6fcf20426febaa355f47bc86e3b2e59a139fec55882a1ba460ab56d", + "production-routed": "fd73a8c4684b7cbb90ac87f08ef318a48c3b9444f8747b994e21217ee9666740" + }, + "summary": [ + { + "engine": "chromium", + "mode": "development", + "variant": "raw", + "passed": 4, + "total": 6 + }, + { + "engine": "chromium", + "mode": "development", + "variant": "routed", + "passed": 6, + "total": 6 + }, + { + "engine": "chromium", + "mode": "production", + "variant": "raw", + "passed": 4, + "total": 6 + }, + { + "engine": "chromium", + "mode": "production", + "variant": "routed", + "passed": 6, + "total": 6 + }, + { + "engine": "firefox", + "mode": "development", + "variant": "raw", + "passed": 6, + "total": 6 + }, + { + "engine": "firefox", + "mode": "development", + "variant": "routed", + "passed": 6, + "total": 6 + }, + { + "engine": "firefox", + "mode": "production", + "variant": "raw", + "passed": 6, + "total": 6 + }, + { + "engine": "firefox", + "mode": "production", + "variant": "routed", + "passed": 6, + "total": 6 + } + ], + "results": [ + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "nested-immediate", + "status": "fail", + "error": "page.waitForFunction: Timeout 1200ms exceeded.", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "cancel-inner", + "status": "pass", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "remove-and-reopen", + "status": "fail", + "error": "page.waitForFunction: Timeout 1200ms exceeded.", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "raw", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "cancel-inner", + "status": "pass", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "development", + "variant": "routed", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "nested-immediate", + "status": "fail", + "error": "page.waitForFunction: Timeout 1200ms exceeded.", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "cancel-inner", + "status": "pass", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "remove-and-reopen", + "status": "fail", + "error": "page.waitForFunction: Timeout 1200ms exceeded.", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "raw", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "cancel-inner", + "status": "pass", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + } + ] + }, + "browserErrors": [] + }, + { + "engine": "chromium", + "browser": "151.0.7922.34", + "mode": "production", + "variant": "routed", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "cancel-inner", + "status": "pass", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "raw", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "cancel-inner", + "status": "pass", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "development", + "variant": "routed", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "cancel-inner", + "status": "pass", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "raw", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "ordinary", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "nested-immediate", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "inner", + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "cancel-inner", + "status": "pass", + "observed": { + "active": "inner-close", + "dialogs": 2, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "remove-trigger", + "status": "pass", + "observed": { + "active": "outer-trigger", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "outer", + "value": false + } + ], + "escapes": [ + "outer" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "dispatch", + "owner": "radix-_r_1_" + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "remove-and-reopen", + "status": "pass", + "observed": { + "active": "inner-trigger", + "dialogs": 1, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": true + }, + { + "name": "inner", + "value": false + } + ], + "escapes": [ + "inner" + ], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "dispatch", + "owner": "radix-_r_4_" + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 1 + } + ] + }, + "browserErrors": [] + }, + { + "engine": "firefox", + "browser": "155.0", + "mode": "production", + "variant": "routed", + "case": "ancestor-teardown", + "status": "pass", + "observed": { + "active": "next", + "dialogs": 0, + "changes": [ + { + "name": "outer", + "value": true + }, + { + "name": "inner", + "value": true + } + ], + "escapes": [], + "trace": [ + { + "kind": "register", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "register", + "owner": "radix-_r_4_", + "count": 2 + }, + { + "kind": "unregister", + "owner": "radix-_r_1_", + "count": 1 + }, + { + "kind": "unregister", + "owner": "radix-_r_4_", + "count": 0 + }, + { + "kind": "listener-removed" + } + ] + }, + "browserErrors": [] + } + ] +} diff --git a/research/experiments/boundary-recovery/routing-transform.mjs b/research/experiments/boundary-recovery/routing-transform.mjs new file mode 100644 index 0000000..6c54c44 --- /dev/null +++ b/research/experiments/boundary-recovery/routing-transform.mjs @@ -0,0 +1,49 @@ +// Experimental in-memory patch to one pinned module, not an upstream/product change. +export function routeEscape(source) { + const start = source.indexOf(' const isHighestLayer = node ? index === layers.length - 1 : false;'); + const end = source.indexOf(' React.useEffect(() => {\n if (!node) return;', start); + if (start < 0 || end < 0) throw new Error('Pinned source locators changed'); + const helper = ` +const fepDocuments = new WeakMap(); +function fepRegister(doc, node, callback) { + let state = fepDocuments.get(doc); + if (!state) { + state = { owners: new Map() }; + state.listener = event => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + const entry = [...state.owners].filter(([element]) => element.isConnected).at(-1); + if (!entry) return; + window.routingObserved.trace.push({kind:'dispatch', owner:entry[0].getAttribute('aria-labelledby')}); + // Snapshot one owner. No fall-through to the parent after cancellation or teardown. + entry[1](event); + }; + doc.addEventListener('keydown', state.listener, {capture:true}); + fepDocuments.set(doc, state); + } + state.owners.set(node, callback); + window.routingObserved.trace.push({kind:'register', owner:node.getAttribute('aria-labelledby'), count:state.owners.size}); + return () => { + state.owners.delete(node); + window.routingObserved.trace.push({kind:'unregister', owner:node.getAttribute('aria-labelledby'), count:state.owners.size}); + if (!state.owners.size) { + doc.removeEventListener('keydown', state.listener, {capture:true}); + fepDocuments.delete(doc); + window.routingObserved.trace.push({kind:'listener-removed'}); + } + }; +} +`; + const replacement = ` const fepHandler = React.useRef(null); + React.useLayoutEffect(() => { + fepHandler.current = event => { + onEscapeKeyDown?.(event); + if (!event.defaultPrevented && onDismiss) {event.preventDefault();onDismiss();} + }; + }); + React.useLayoutEffect(() => { + if (!node) return; + return fepRegister(ownerDocument, node, event => fepHandler.current?.(event)); + }, [node, ownerDocument]); +`; + return helper + source.slice(0,start) + replacement + source.slice(end); +} diff --git a/research/experiments/boundary-recovery/routing.mjs b/research/experiments/boundary-recovery/routing.mjs new file mode 100644 index 0000000..c472642 --- /dev/null +++ b/research/experiments/boundary-recovery/routing.mjs @@ -0,0 +1,46 @@ +import {build} from 'esbuild'; +import {chromium, firefox} from 'playwright'; +import {createServer} from 'node:http'; +import {once} from 'node:events'; +import {readFile, writeFile} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {fileURLToPath} from 'node:url'; +import assert from 'node:assert/strict'; +import {routeEscape} from './routing-transform.mjs'; +const dir=fileURLToPath(new URL('.',import.meta.url)); +const hash=x=>createHash('sha256').update(x).digest('hex'); +const source=await readFile(dir+'node_modules/@radix-ui/react-dismissable-layer/dist/index.mjs','utf8'); +assert.equal(hash(source),JSON.parse(await readFile(dir+'provenance.json')).inspected_files['@radix-ui/react-dismissable-layer/dist/index.mjs'].sha256); +const bundles=new Map(); +for(const mode of ['development','production']) for(const variant of ['raw','routed']) { + const b=await build({entryPoints:[dir+'routing-fixture.jsx'],bundle:true,write:false,format:'iife',define:{'process.env.NODE_ENV':JSON.stringify(mode)},plugins:variant==='raw'?[]:[{name:'experimental-escape-router',setup(b){b.onLoad({filter:/@radix-ui\/react-dismissable-layer\/dist\/index\.mjs$/},()=>({contents:routeEscape(source),loader:'js',resolveDir:dir+'node_modules/@radix-ui/react-dismissable-layer/dist'}));}}]}); + bundles.set(mode+'-'+variant,b.outputFiles[0].contents); +} +const server=createServer((req,res)=>{const u=new URL(req.url,'http://localhost');if(u.pathname==='/bundle.js'){res.setHeader('Content-Type','application/javascript');res.end(bundles.get(u.searchParams.get('key')));}else{res.setHeader('Content-Type','text/html');res.end(`Routing experiment
`);}}); +server.listen(0,'127.0.0.1');await once(server,'listening'); +const results=[]; +async function focused(page,id){await page.waitForFunction(id=>document.activeElement?.id===id,id,{timeout:1200});} +async function outer(page){await page.locator('#outer-trigger').focus();await page.keyboard.press('Enter');await focused(page,'inner-trigger');} +async function inner(page){await outer(page);await page.keyboard.press('Enter');await focused(page,'inner-close');} +const cases={ + 'ordinary':async p=>{await outer(p);await p.keyboard.press('Escape');await focused(p,'outer-trigger');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),['outer']);}, + 'nested-immediate':async p=>{await inner(p);await p.keyboard.press('Escape');await focused(p,'inner-trigger');assert.equal(await p.getByRole('dialog').count(),1);await p.keyboard.press('Escape');await focused(p,'outer-trigger');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),['inner','outer']);}, + 'cancel-inner':async p=>{await inner(p);await p.keyboard.press('Escape');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),['inner']);assert.equal(await p.getByRole('dialog', {includeHidden:true}).count(),2);await focused(p,'inner-close');}, + 'remove-trigger':async p=>{await inner(p);await p.keyboard.press('Tab');await focused(p,'remove-trigger');await p.keyboard.press('Enter');await focused(p,'outer-close');await p.keyboard.press('Escape');await focused(p,'outer-trigger');}, + 'remove-and-reopen':async p=>{await inner(p);await p.evaluate(()=>window.removeInner());await focused(p,'inner-trigger');await p.keyboard.press('Enter');await focused(p,'inner-close');await p.keyboard.press('Escape');await focused(p,'inner-trigger');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),['inner']);}, + 'ancestor-teardown':async p=>{await inner(p);await p.evaluate(()=>window.removeTask());await p.getByRole('dialog').waitFor({state:'detached'});await p.locator('#next').focus();await p.keyboard.press('Escape');assert.deepEqual(await p.evaluate(()=>window.routingObserved.escapes),[]);await focused(p,'next');} +}; +try{ + for(const [engine,launcher]of [['chromium',chromium],['firefox',firefox]]){ + const browser=await launcher.launch({headless:true,...(engine==='chromium'?{executablePath:process.env.FEP_CHROMIUM_PATH}: {})}); + try{for(const mode of ['development','production'])for(const variant of ['raw','routed'])for(const [name,run]of Object.entries(cases)){ + const page=await browser.newPage();page.setDefaultTimeout(2000);const errors=[];page.on('pageerror',e=>errors.push(e.message));const r={engine,browser:browser.version(),mode,variant,case:name,status:'pass'}; + try{await page.goto(`http://127.0.0.1:${server.address().port}/?key=${mode}-${variant}${name==='cancel-inner'?'&cancel=1':''}`);await run(page);assert.deepEqual(errors,[]);if(variant==='routed' && name==='ancestor-teardown'){const trace=await page.evaluate(()=>window.routingObserved.trace);assert.equal(trace.at(-1)?.kind,'listener-removed');assert.equal(trace.filter(x=>x.kind==='register').length,trace.filter(x=>x.kind==='unregister').length);}}catch(e){r.status='fail';r.error=e.message;} + r.observed=await page.evaluate(()=>({active:document.activeElement?.id,dialogs:document.querySelectorAll('[role=dialog]').length,...window.routingObserved}));r.browserErrors=errors;results.push(r);await page.close(); + await writeFile('/tmp/fep-routing-checkpoint.json',JSON.stringify({status:'incomplete',results},null,2)+'\n'); + }}finally{await browser.close();} + } +}finally{await new Promise(resolve=>server.close(resolve));} +const files={};for(const name of ['routing.mjs','routing-fixture.jsx','routing-transform.mjs','package.json','package-lock.json'])files[name]=hash(await readFile(dir+name)); +const summary=[];for(const engine of ['chromium','firefox'])for(const mode of ['development','production'])for(const variant of ['raw','routed']){const r=results.filter(x=>x.engine===engine&&x.mode===mode&&x.variant===variant);summary.push({engine,mode,variant,passed:r.filter(x=>x.status==='pass').length,total:r.length});} +const output=process.env.FEP_RESULT_PATH||'/tmp/fep-routing.json';await writeFile(output,JSON.stringify({date:new Date().toISOString(),node:process.version,files,sourceSha256:hash(source),transformedSha256:hash(routeEscape(source)),bundleHashes:Object.fromEntries([...bundles].map(([k,v])=>[k,hash(v)])),summary,results},null,2)+'\n');console.log(JSON.stringify({output,summary},null,2)); diff --git a/research/plan/OpenQuestions.md b/research/plan/OpenQuestions.md index e0e5122..98f6384 100644 --- a/research/plan/OpenQuestions.md +++ b/research/plan/OpenQuestions.md @@ -87,3 +87,8 @@ visible/focused interaction from ownership of its dismissal handler. A guard can prevent the wrong close while losing valid input. Q05 remains open: lifecycle and input-routing contracts may need to shape composition before focus-return rules. No new pattern or canonical dimension follows from one synthetic family. + +Q05 routing follow-up: [experimental owner dispatch](../reviews/NestedOwnershipRouting.md) +separates input delivery from return-focus policy. Successful cancellation and cleanup +matter alongside correct close targeting. The bounded mechanism comparison supports +this distinction; it does not establish a universal ownership model or close Q05. diff --git a/research/reviews/NestedDismissalIsolation.md b/research/reviews/NestedDismissalIsolation.md index 3bc0cbc..3b047f3 100644 --- a/research/reviews/NestedDismissalIsolation.md +++ b/research/reviews/NestedDismissalIsolation.md @@ -93,3 +93,11 @@ WP-0003 T01 retains a bounded residual: evaluate a lifecycle-safe ownership stra without arbitrary delay and verify ordinary/nested dismissal in both browsers before recommending it. Feature/import versus release-boundary research and navigation checks remain open; this focused investigation does not complete those tasks or M2. + +## Routing follow-up + +[Experimental routing](NestedOwnershipRouting.md) now supplies a mechanism that +passes the tested ordinary/nested/cancel/removal/reopen/teardown cases without a +settling delay. It changes the owner-selection architecture, not just the stale +handler guard. Broader lifecycle coverage is still required for a recommendation; +this does not overwrite the failed isolation or guard results above. diff --git a/research/reviews/NestedOwnershipRouting.md b/research/reviews/NestedOwnershipRouting.md new file mode 100644 index 0000000..0187a87 --- /dev/null +++ b/research/reviews/NestedOwnershipRouting.md @@ -0,0 +1,97 @@ +# Experimental nested ownership routing — 2026-09-26 + +Status: synthetic browser comparison, editorial analysis by Codex, WP-0003 T01. +Follow-up to [stale-listener isolation](NestedDismissalIsolation.md). The aim is to +preserve the intended Escape action during ownership changes, not merely prevent +a wrong close or wait an arbitrary duration. This does not deploy or recommend a +vendor patch. Installed dependencies and earlier experiments remain unchanged. + +## Proposed mechanism + +[routing-transform.mjs](../experiments/boundary-recovery/routing-transform.mjs) +replaces only the pinned dismissable-layer module's Escape handling during bundling. +A document registry holds connected owners and a single capture listener. Layout +registration/cleanup changes ownership; each Escape snapshots the current owner, +invokes its callback once, honors cancellation and does not fall through to a parent. +Removing the last owner removes the listener. The [design contract](../experiments/boundary-recovery/routing-design.md) +states what this simple mount-order registry does and does not model. + +This addresses both observed problems: the old effect-based listener handoff could +route to the parent, while the prior guard could reject the stale handler and lose +the action. The new approach changes how the handler is selected, not just whether +a selected handler is allowed to close. Focus restoration remains the responsibility +of the dialog/application; it is not implied by routing success. + +## Comparison and validity + +[Runner](../experiments/boundary-recovery/routing.mjs) compares raw and routed builds +in Chromium 151.0.7922.34 and Firefox 155.0, development and production. Six scenarios +run once per condition: ordinary close, immediate nested close, cancelled inner close, +removed invoker with explicit application return target, programmatic inner removal +and reopening, and ancestor teardown followed by Escape. The routed teardown check +also verifies balanced registrations and removal of the document listener. + +No sleep, readiness wait or retry is used. Browser focus polling synchronizes on +observable focus, not on the router's private registration state. The raw control +uses the same new fixture as the routed condition. Its controlled inner state differs +from the original isolation fixture, so counts across those experiments are not a +paired comparison. One run per scenario/condition is bounded mechanism evidence, +not a failure rate or complete regression suite. + +The initial development matrix contained a harness mistake: its cancellation check +counted only accessibility-exposed dialogs, excluding the parent hidden by the modal. +Raw observations showed two DOM dialogs and retained focus even when that assertion +failed. The corrected check includes hidden dialogs; listener cleanup is also asserted. +[Development receipt](../experiments/boundary-recovery/routing-development-results.json) +and [original runner bytes](../experiments/boundary-recovery/routing-development-runner.mjs) +are preserved. Its `files.routing.mjs` hash resolves to the archived runner name. +Do not pool those erroneous cancellation scores with corrected results. + +## Results + +| Browser | Build | Unmodified | Experimental router | +| --- | --- | --- | --- | +| Chromium | development | 4/6 | 6/6 | +| Chromium | production | 4/6 | 6/6 | +| Firefox | development | 6/6 | 6/6 | +| Firefox | production | 6/6 | 6/6 | + +The router meets 24/24 assertions; the control meets 20/24. The four control failures +are immediate nesting and removal/reopening in both Chromium builds: the outer Escape +callback fires and focus returns to the outer trigger. The routed cases deliver the +inner callback and preserve the parent. Unlike the previous isolation matrix, this +new raw production fixture does reproduce wrong-owner dismissal. This changes the +observed scope, not the historical results. + +Cancelled inner Escape remains inside the inner task. Removed-trigger return still +uses explicit application policy. Ancestor teardown leaves no router listener, and +Escape then causes no stale dismissal callback. No keypress loss was observed in +these six scenarios; other input timing patterns remain untested. + +[Corrected raw results](../experiments/boundary-recovery/routing-results.json) retain +all outcomes, browser errors, focus, dialog transitions and router dispatch/cleanup +traces. Source, transformed module, bundle and input hashes identify the exact +experimental implementation. Browser/package provenance is inherited from the +unchanged [pinned experiment](../experiments/boundary-recovery/provenance.json) and +[Firefox comparison](../experiments/boundary-recovery/isolation-provenance.json). + +## Research disposition + +This supplies a concrete candidate mechanism for the tested lifecycle. Keep its +claims narrower than “nested dialogs fixed”: insertion order is not a complete +z-order model, and multiple roots/library copies, exiting animations, shadow DOM, +iframes, repeated held keys, same-turn input bursts and competing Escape handlers +are not covered. React StrictMode, SSR and assistive-technology behavior are also +untested. No upstream message or deployment was performed. + +Refine [Focus Return](../candidates/FocusReturn.md) with an explicit input-owner +lifecycle and separate return-target policy. A dismissal callback is a request to +close; a controlled application may refuse it. Temporal ownership remains a proposed +interaction contract, not a newly admitted pattern or general library abstraction. + +The immediate research question now has an executable routing candidate and bounded +comparison. WP-0003 T01 retains any recommendation gate: test animation-preserved +exits and competing owners before considering broader reuse. Resume the other breadth +work—feature/import versus release boundaries and navigation/history/cache evidence— +without requiring this repository to maintain a production dialog fork. Sixteen +hypotheses, M1 human validation and M2 completion status remain unchanged. diff --git a/workplans/FEP-WP-0003-contrasting-schools.md b/workplans/FEP-WP-0003-contrasting-schools.md index 0ec7d63..50e4132 100644 --- a/workplans/FEP-WP-0003-contrasting-schools.md +++ b/workplans/FEP-WP-0003-contrasting-schools.md @@ -113,3 +113,20 @@ research/reviews/NestedDismissalIsolation.md. Residual under T01: assess lifecyc ownership/routing during activation and teardown before recommending a product repair. No upstream message, deployment, new inference, canonical promotion or M2 completion. Feature/release coupling and navigation evidence remain pending; T02/T03 unchanged. + +## Experimental ownership routing — 2026-09-26 + +T01 progress: in-memory layout-registered document router tested against unchanged +Radix behavior in a new controlled-dialog fixture. Six scenarios × two browsers × +two builds × two variants: routed 24/24, raw 20/24. Raw Chromium fails immediate nested +and reopen dismissal in both builds; routed delivery, cancellation, replacement focus +and cleanup pass. No sleep, private-readiness wait or retry. Initial development run +and its faulty exposed-dialog cancellation assertion retained; corrected runner includes +hidden dialogs and verifies listener removal. See research/reviews/NestedOwnershipRouting.md. + +The focused question now has an executable candidate mechanism. Broader reuse remains +conditional on animated-exit/competing-owner coverage; no production dependency patch, +upstream message, pattern promotion or M2 completion. Resume feature/import versus +release-boundary and navigation evidence next; keep the router limitation as a live +T01 follow-up rather than requiring a production fork here. Sixteen hypotheses, +T02/T03 and the independent-human gate unchanged. No model calls.