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:
parent
85ae4f62af
commit
29ccad7002
16 changed files with 4617 additions and 0 deletions
8
TREE.md
8
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
30
research/experiments/boundary-recovery/routing-design.md
Normal file
30
research/experiments/boundary-recovery/routing-design.md
Normal 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
|
|
@ -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));
|
||||
35
research/experiments/boundary-recovery/routing-fixture.jsx
Normal file
35
research/experiments/boundary-recovery/routing-fixture.jsx
Normal 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 />);
|
||||
2109
research/experiments/boundary-recovery/routing-results.json
Normal file
2109
research/experiments/boundary-recovery/routing-results.json
Normal file
File diff suppressed because it is too large
Load diff
49
research/experiments/boundary-recovery/routing-transform.mjs
Normal file
49
research/experiments/boundary-recovery/routing-transform.mjs
Normal 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);
|
||||
}
|
||||
46
research/experiments/boundary-recovery/routing.mjs
Normal file
46
research/experiments/boundary-recovery/routing.mjs
Normal 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));
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
97
research/reviews/NestedOwnershipRouting.md
Normal file
97
research/reviews/NestedOwnershipRouting.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue