Test lifecycle-aware Escape ownership routing without settling delays

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
This commit is contained in:
tegwick 2026-09-26 22:58:07 +02:00
parent 85ae4f62af
commit 29ccad7002
16 changed files with 4617 additions and 0 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

File diff suppressed because it is too large Load diff

View file

@ -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(`<!doctype html><html lang="en"><title>Routing experiment</title><body><div id="root"></div><script src="/bundle.js?key=${u.searchParams.get('key')}"></script></body></html>`);}});
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));

View file

@ -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 <main><button id="next" ref={next}>Next record</button>
{mounted && <Dialog.Root open={outer} onOpenChange={change('outer',setOuter)}>
<Dialog.Trigger id="outer-trigger">Open outer</Dialog.Trigger>
<Dialog.Portal><Dialog.Content aria-describedby={undefined} onEscapeKeyDown={() => window.routingObserved.escapes.push('outer')}>
<Dialog.Title>Outer task</Dialog.Title>
<Dialog.Root open={inner} onOpenChange={change('inner',setInner)}>
{trigger && <Dialog.Trigger id="inner-trigger">Open inner</Dialog.Trigger>}
<Dialog.Portal><Dialog.Content aria-describedby={undefined}
onEscapeKeyDown={event => {window.routingObserved.escapes.push('inner');if(options.has('cancel')) event.preventDefault();}}
onCloseAutoFocus={event => {if(!trigger){event.preventDefault();document.getElementById('outer-close')?.focus();}}}>
<Dialog.Title>Inner task</Dialog.Title>
<Dialog.Close id="inner-close">Close inner</Dialog.Close>
<button id="remove-trigger" onClick={() => {setTrigger(false);setInner(false);}}>Remove trigger and finish</button>
</Dialog.Content></Dialog.Portal>
</Dialog.Root>
<Dialog.Close id="outer-close">Close outer</Dialog.Close>
</Dialog.Content></Dialog.Portal>
</Dialog.Root>}
</main>;
}
createRoot(document.getElementById('root')).render(<App />);

File diff suppressed because it is too large Load diff

View file

@ -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);
}

View file

@ -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(`<!doctype html><html lang="en"><title>Routing experiment</title><body><div id="root"></div><script src="/bundle.js?key=${u.searchParams.get('key')}"></script></body></html>`);}});
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));

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.