Isolate nested dismissal ownership across browsers and builds

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
This commit is contained in:
tegwick 2026-09-26 22:35:12 +02:00
parent 3eecd74b0d
commit 85ae4f62af
12 changed files with 7090 additions and 0 deletions

View file

@ -352,6 +352,9 @@ research/experiments/boundary-recovery/README.md
research/experiments/boundary-recovery/development-results.json
research/experiments/boundary-recovery/experiment.mjs
research/experiments/boundary-recovery/fixture.jsx
research/experiments/boundary-recovery/isolation-provenance.json
research/experiments/boundary-recovery/isolation-results.json
research/experiments/boundary-recovery/isolation.mjs
research/experiments/boundary-recovery/package-lock.json
research/experiments/boundary-recovery/package.json
research/experiments/boundary-recovery/provenance.json
@ -379,6 +382,7 @@ research/reviews/LocalFirstEvidenceExpansion.md
research/reviews/MatrixRecoveryExperiment.md
research/reviews/NavigationCoverage.md
research/reviews/NavigationRenderingEvidence.md
research/reviews/NestedDismissalIsolation.md
research/reviews/ObsidianConflictScreen.json
research/reviews/PatternBreadthExpansion.md
research/reviews/RecoveryReviewMetrics.json

View file

@ -52,3 +52,7 @@ previous counts above describe earlier slices. No additional runtime or model ru
Latest execution: [rendering/focus boundary experiment](reviews/BoundaryRecoveryExperiment.md)
uses pinned React/Radix packages in Chromium. It records seven stable case observations
and a timing-sensitive nested-dialog failure; no production or accessibility certification.
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.

View file

@ -51,3 +51,14 @@ run, while a 100 ms settling variant passed. Neither focus arrival nor dialog vi
alone proves dismissal ownership has stabilized. The delay is an experimental variable,
not a recommended fix. Root cause and accessibility conformance remain unverified;
retain hypothesis maturity and test lifecycle ownership before a stronger claim.
## Nested ownership isolation — 2026-09-26
[Follow-up matrix](../reviews/NestedDismissalIsolation.md) captures an interval where
the inner layer is registered and focused but the outer Escape listener still handles
input. This explains the recorded wrong-owner dismissal in the instrumented fixture.
Development/production and Chromium/Firefox comparisons retain all 48 trial results.
A current-owner guard can lose the keypress; private listener-readiness waiting passes
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.

View file

@ -122,3 +122,8 @@ Content API and keyboard interaction; living page accessed 2026-09-26, revision
unknown. Displayed documentation version differs from executed package; runtime
claims use the pinned archive, not an assumption that the page exactly matches it.
[Results and limits](../reviews/BoundaryRecoveryExperiment.md).
SRC-0029 follow-up: [nested dismissal isolation](../reviews/NestedDismissalIsolation.md)
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.

View file

@ -61,3 +61,28 @@ or full accessibility conformance. X03 handles a response error explicitly; it d
not test unhandled promises, timeouts, mutation idempotency or remote rollback.
Nested dismissal remains unresolved. Dependency/version compatibility and fixture
lifecycle effects must be separated before attributing a library defect.
## Follow-up: nested ownership isolation
[Review](../../reviews/NestedDismissalIsolation.md) and [48-trial receipt](isolation-results.json)
compare development/production, Chromium/Firefox and four explicit variants. The
original experiment and receipts above remain unchanged. Follow-up reports 43/48
obligations met, including failures; it is a research runner, not a green CI gate.
```bash
PLAYWRIGHT_BROWSERS_PATH=/tmp/fep-playwright-browsers node node_modules/playwright/cli.js install firefox
PLAYWRIGHT_BROWSERS_PATH=/tmp/fep-playwright-browsers FEP_CHROMIUM_PATH=/path/to/chrome node isolation.mjs
```
Runs three trials per browser/build/variant, without automatic retries. Default
output is `/tmp/fep-isolation.json`; `FEP_RESULT_PATH` overrides it. A separate
`/tmp/fep-isolation-checkpoint.json` preserves completed trials if a later launch
fails. Launch failures abort; completed matrices retain assertion failures in JSON.
A sandbox prevented Firefox accessing its temporary profile here; the recorded
completed matrix ran outside that sandbox. Firefox build and limitations are in
[isolation-provenance.json](isolation-provenance.json).
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.

View file

@ -0,0 +1,17 @@
{
"date": "2026-09-26",
"firefox": {
"playwright_build": "1543",
"executable_sha256": "062272ac4d851eade5b75db2dc7c14569d96cb3ca38f949337cdc1b74367e35b",
"version": "155.0"
},
"chromium": "Same executable as provenance.json; explicit override",
"instrumentation": "In-memory esbuild transformation; installed package files unmodified",
"initial_attempt": "Sandboxed run reached Firefox launch, which failed to access its temporary profile; no completed matrix receipt. Re-executed outside sandbox.",
"limitations": [
"Fixed condition order, three trials each, no population failure-rate inference",
"Tracing and before-event snapshot can change scheduling; raw is an unmodified-library control",
"ready uses private listener instrumentation, not a public product readiness API",
"guarded is an experimental mutation, not vendor behavior or a validated fix"
]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
// Follow-up only: original fixture, runner and result receipts remain unchanged.
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';
const dir = fileURLToPath(new URL('.', import.meta.url));
const hash = data => createHash('sha256').update(data).digest('hex');
const repetitions = 3;
const layerPath = dir + 'node_modules/@radix-ui/react-dismissable-layer/dist/index.mjs';
const layerSource = await readFile(layerPath, 'utf8');
const expectedLayerHash = JSON.parse(await readFile(dir + 'provenance.json')).inspected_files['@radix-ui/react-dismissable-layer/dist/index.mjs'].sha256;
if (hash(layerSource) !== expectedLayerHash) throw new Error('Inspected dependency changed');
const bundles = new Map();
function instrument(source, guard) {
const replace = (from, to) => {
if (source.split(from).length !== 2) throw new Error('Ambiguous instrumentation locator: ' + from);
source = source.replace(from, to);
};
replace('const isHighestLayer = node ? index === layers.length - 1 : false;', `const isHighestLayer = node ? index === layers.length - 1 : false;
const trace = (kind) => window.layerTrace.push({kind, t: performance.now(), node: node?.getAttribute('aria-labelledby'), active: document.activeElement?.id, layers: [...context.layers].map(n => n.getAttribute('aria-labelledby'))});`);
replace(' onEscapeKeyDown?.(event);', ` trace('escape');
${guard ? "if ([...context.layers].at(-1) !== node) { trace('guard-rejected'); return; }" : ''}
onEscapeKeyDown?.(event);`);
replace(' ownerDocument.addEventListener("keydown", handleKeyDown, { capture: true });', ' trace("listen");\n ownerDocument.addEventListener("keydown", handleKeyDown, { capture: true });');
replace(' return () => ownerDocument.removeEventListener("keydown", handleKeyDown, { capture: true });', ' return () => { trace("unlisten"); ownerDocument.removeEventListener("keydown", handleKeyDown, { capture: true }); };');
replace(' context.layers.add(node);', ' context.layers.add(node); trace("layer-add");');
replace(' context.layers.delete(node);', ' trace("layer-remove"); context.layers.delete(node);');
return source;
}
for (const mode of ['development', 'production']) {
for (const variant of ['raw', 'traced', 'ready', 'guarded']) {
const b = await build({entryPoints: [dir + 'fixture.jsx'], bundle: true, write: false,
define: {'process.env.NODE_ENV': JSON.stringify(mode)}, format: 'iife',
plugins: variant === 'raw' ? [] : [{name: 'layer-observation', setup(build) {
build.onLoad({filter: /@radix-ui\/react-dismissable-layer\/dist\/index\.mjs$/}, () => ({contents: instrument(layerSource, variant === 'guarded'), 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>Nested isolation</title><body><div id="root"></div><script>window.layerTrace=[];</script><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 = [];
try {
for (const [engine, launcher] of [['chromium', chromium], ['firefox', firefox]]) {
const browser = await launcher.launch({headless: true, ...(engine === 'chromium' && process.env.FEP_CHROMIUM_PATH ? {executablePath: process.env.FEP_CHROMIUM_PATH} : {})});
try {
for (const mode of ['development', 'production']) for (const variant of ['raw', 'traced', 'ready', 'guarded']) for (let trial = 1; trial <= repetitions; trial++) {
const page = await browser.newPage();page.setDefaultTimeout(2000);
const r = {engine, browser: browser.version(), mode, variant, trial, status: 'pass'};
try {
await page.goto(`http://127.0.0.1:${server.address().port}/?mode=focus&key=${mode}-${variant}`);
await page.locator('#invoker').focus();await page.keyboard.press('Enter');
await page.waitForFunction(() => document.activeElement?.id === 'remove');
await page.keyboard.press('Tab');await page.waitForFunction(() => document.activeElement?.id === 'nested-invoker');
await page.keyboard.press('Enter');await page.waitForFunction(() => document.activeElement?.id === 'nested-close');
if (variant === 'ready') await page.waitForFunction(() => {
const inner = document.getElementById('nested-close').closest('[role=dialog]').getAttribute('aria-labelledby');
const listeners = new Set();for (const e of window.layerTrace) {if (e.kind === 'listen') listeners.add(e.node);if(e.kind === 'unlisten') listeners.delete(e.node);}
return listeners.size === 1 && listeners.has(inner);
});
r.before = await page.evaluate(() => ({active: document.activeElement.id, dialogs: [...document.querySelectorAll('[role=dialog]')].map(n => ({id: n.getAttribute('aria-labelledby'), title: document.getElementById(n.getAttribute('aria-labelledby'))?.textContent}))}));
await page.keyboard.press('Escape');
await page.waitForFunction(() => document.activeElement?.id === 'nested-invoker' && document.querySelectorAll('[role=dialog]').length === 1, null, {timeout: 1500});
await page.keyboard.press('Escape');
await page.waitForFunction(() => document.activeElement?.id === 'invoker', null, {timeout: 1500});
} catch (e) {r.status = 'fail';r.error = e.message;}
r.after = await page.evaluate(() => ({active: document.activeElement?.id, dialogs: document.querySelectorAll('[role=dialog]').length, transitions: window.observed.dialogTransitions, trace: window.layerTrace}));
results.push(r); await page.close();
await writeFile('/tmp/fep-isolation-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 ['isolation.mjs','fixture.jsx','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','traced','ready','guarded']) {
const group=results.filter(r => r.engine===engine && r.mode===mode && r.variant===variant);
summary.push({engine,mode,variant,passed:group.filter(r=>r.status==='pass').length,total:group.length});
}
const output = process.env.FEP_RESULT_PATH || '/tmp/fep-isolation.json';
await writeFile(output, JSON.stringify({date:new Date().toISOString(), node:process.version, repetitions, files, layerSourceSha256:hash(layerSource), bundleHashes:Object.fromEntries([...bundles].map(([k,v])=>[k,hash(v)])), summary,results},null,2)+'\n');
console.log(JSON.stringify({output,summary},null,2));
// Findings are retained, including failed obligations; not a CI acceptance gate.

View file

@ -79,3 +79,11 @@ Q06 separates module interfaces, client activation, fault containment and indepe
release contracts. Q05 asks when modal focus/lifecycle requirements must shape
composition. Q03 still lacks verified recurrence for these complete contracts;
Q04 still lacks demonstrated decision benefit. Synthetic probes do not close cases.
## Q05 temporal interaction ownership — 2026-09-26
[Nested dismissal isolation](../reviews/NestedDismissalIsolation.md) distinguishes
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.

View file

@ -58,3 +58,10 @@ build plus another browser before assigning cause. Preserve the immediate path a
a regression probe; test explicit readiness/ownership rather than adding a delay.
Screen-reader behavior remains untested. Feature/release coupling inspection and
navigation/history/cache checks remain separate pending work.
## Follow-up disposition
[Nested isolation](NestedDismissalIsolation.md) now captures the stale-listener
interval and compares builds/browsers. The mechanism is located in an instrumented
fixture; a complete product remedy remains open. This supersedes the earlier lack
of timing diagnosis, while preserving the original receipts and their limitations.

View file

@ -0,0 +1,95 @@
# Nested dismissal isolation — 2026-09-26
Status: controlled follow-up to [boundary recovery](BoundaryRecoveryExperiment.md),
Codex editorial analysis. Same pinned React/Radix fixture, now compared across
Chromium 151.0.7922.34 and Firefox 155.0, development and production builds.
No package upgrade, deployment, model call or accessibility certification.
## Design and provenance
[isolation.mjs](../experiments/boundary-recovery/isolation.mjs) bundles the unchanged
fixture with four variants, three fresh-page trials each, in fixed order. The
[48-trial receipt](../experiments/boundary-recovery/isolation-results.json) retains
successes, failures, focus snapshots, open/close transitions and layer event traces.
It hashes runner, fixture, lockfile, inspected dependency source and every bundle.
[Additional provenance](../experiments/boundary-recovery/isolation-provenance.json)
records Firefox build/executable hash and the initial sandbox launch failure.
- **raw:** unmodified dependencies, no layer tracing, immediate browser Escape
after focus arrives and a before-event snapshot is captured.
- **traced:** in-memory instrumentation logs layer registration, listener changes
and Escape handling; no intended change to vendor control flow.
- **ready:** same instrumentation; additionally waits until only the inner layer's
Escape listener is installed. This uses private instrumentation, not a public API.
- **guarded:** traced build adds a current-top-layer check at event handling time;
stale listeners return without dismissing. This is experimental modified behavior.
All variants retain keyboard Enter/Tab/Escape interaction and assert inner return
followed by outer return. No fixed settling sleep or hidden retry. Extra snapshots,
tracing and readiness observation can change scheduling. Results are exploratory
counts, not failure rates. Fixed ordering and three trials do not establish browser
or build superiority. Production mode changes React build selection, not the fixture
into a production application. The historical eight-case runner/results are unchanged.
## Results
Pass means both expected return targets were reached in order.
| Browser | Build | raw | traced | ready | guarded |
| --- | --- | --- | --- | --- | --- |
| Chromium | development | 2/3 | 2/3 | 3/3 | 3/3 |
| Chromium | production | 3/3 | 1/3 | 3/3 | 2/3 |
| Firefox | development | 3/3 | 3/3 | 3/3 | 3/3 |
| Firefox | production | 3/3 | 3/3 | 3/3 | 3/3 |
43/48 obligations met. Keep conditions separate: a readiness wait changes the
precondition, and the guard changes the implementation. An unmodified production
failure was not observed in these three Chromium trials; failures with tracing
show the timing problem is not restricted to a development bundle, but instrumentation
may influence whether its window is hit. Firefox's passes do not prove immunity.
## Captured mechanism
Chromium/development/traced trial 1 records this order:
1. Outer Escape listener is installed.
2. Inner layer is registered; focus reaches its close button.
3. Escape invokes the **outer** listener while the registered layer stack already
ends in the inner layer.
4. The outer listener is removed and the inner listener installed afterward.
5. Outer-close transition removes both dialogs; focus returns to the outer invoker.
This directly locates a stale Escape-owner interval in the instrumented fixture.
The shipped dismissable-layer module selects its listener using render-time
`isHighestLayer` and effect registration; event-time handling does not recheck the
current layer stack. Source hash matches the earlier inspected package. This
explains the captured wrong-owner dismissal; it is not a universal diagnosis of
all focus bugs or a claim about every Radix version/application.
Chromium/production/guarded trial 1 rejects the stale outer handler while focus is
inside the inner dialog. The inner listener is installed later. Both dialogs remain
open: the guard preserves the outer task but loses this Escape action. A check that
prevents a wrong transition is insufficient when it cannot deliver the valid action.
The readiness variant passes 12/12 by waiting for observed ownership before Escape.
It supports distinguishing focus arrival from event-owner readiness. It does not
establish a product solution: users cannot query this private trace, and a product
must define how input during the ownership transition is accepted, routed or deferred.
## Pattern disposition and next work
Refine [Focus Return](../candidates/FocusReturn.md): test both **which task may close**
and **where focus returns**, including input during activation and teardown. Neither
visibility nor focus alone proves the dismissal handler belongs to the active task.
Avoid arbitrary sleeps and avoid treating ignored input as successful recovery.
Keep hypothesis maturity; this is still one synthetic implementation family.
No new pattern is needed yet. The broader question is whether temporal ownership
belongs in the containing modal/interaction contract. A production repair would need
stable ownership/routing plus tests for opening, nesting, removal and teardown;
this study has not supplied or deployed such a repair. No upstream issue was sent.
WP-0003 T01 retains a bounded residual: evaluate a lifecycle-safe ownership strategy
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.

View file

@ -97,3 +97,19 @@ browser, verify readiness/ownership without arbitrary delay. Do not mark its ass
resolved from the settled run. Feature/release inspection and navigation checks remain
pending. T02/T03 unchanged, sixteen hypotheses, no independent human validation or
M1/M2 completion. See research/reviews/BoundaryRecoveryExperiment.md; no model calls.
## Nested dismissal isolated across browsers/builds — 2026-09-26
T01 progress: unchanged pinned fixture compared across Chromium 151.0.7922.34 and
Firefox 155.0, development/production, raw/traced/readiness/experimental-guard variants,
three trials each. 43/48 obligations met; all outcomes retained in isolation-results.json.
Trace captures inner layer registration/focus before Escape listener ownership transfers:
stale outer handler can close both tasks. Production traced failures reproduce the
mechanism; unmodified production trials passed. Firefox passed 24/24, not immunity.
Private listener-readiness condition passes 12/12; guard loses input in one production
trial and is not a fix. Original fixture/receipts unchanged. See
research/reviews/NestedDismissalIsolation.md. Residual under T01: assess lifecycle-safe
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.