Skip to content
This repository is currently being migrated. It's locked while the migration is in progress.

va-modal: fix aria-hidden race condition from rAF timing#2045

Draft
acrollet wants to merge 1 commit into
mainfrom
fix/va-modal-aria-hidden-raf-race
Draft

va-modal: fix aria-hidden race condition from rAF timing#2045
acrollet wants to merge 1 commit into
mainfrom
fix/va-modal-aria-hidden-raf-race

Conversation

@acrollet
Copy link
Copy Markdown
Contributor

Chromatic

https://fix-va-modal-aria-hidden-raf-race--65a6e2ed2314f7b8f98609d8.chromatic.com

Summary

Fixed orphaned aria-hidden attributes after modal close. Cancels pending requestAnimationFrame in teardownModal to prevent a race condition where setupModal fires after teardown, permanently leaving aria-hidden="true" / data-aria-hidden="true" on DOM elements.

Description

When React rapidly toggles visible=true then visible=false across two render passes (e.g., opening a modal with a checkbox click then immediately pressing Escape), componentDidUpdate schedules setupModal via requestAnimationFrame on the first pass, then teardownModal runs synchronously on the second pass while the rAF is still pending.

The stale rAF then fires setupModal after teardown, calling applyAriaHidden() which resets this.undoAriaHidden = [] and pushes new undo functions from hideOthers(). Since teardownModal already ran, nobody will ever invoke those undo functions — leaving aria-hidden="true" and data-aria-hidden="true" permanently on DOM elements outside the modal.

This causes aria-hidden-focus axe violations on focusable elements like va-link, va-accordion-item, va-text-input, etc.

The fix:

  • Store the requestAnimationFrame ID in setupModalRafId
  • Cancel it in teardownModal() using cancelAnimationFrame()
  • Also cancel in disconnectedCallback() for safety

Why this is React-specific: The React binding (@stencil/react-output-target) uses attachProps which sets node.visible = value as a property (synchronous), triggering Stencil's @Watch immediately. Two rapid React renders produce two separate Stencil render cycles with the rAF from the first still pending when the second fires teardown. Vanilla JS attribute changes get batched by Stencil into a single render.

Root cause timeline:

Related tickets and links

This fixes aria-hidden-focus axe violations seen in vets-website CI:

  • Webpack build (PR #43349, main branch): Cypress E2E runner 4 — secure-messaging specs
  • Esbuild build (PR #42806): appeals and secure-messaging specs

Screenshots

N/A — no visual changes.

Testing and review

  • All 80 existing va-modal e2e test suites pass (1137 tests)
  • New e2e test verifies no orphaned aria-hidden attributes remain after open/close cycle
  • The exact rAF race timing cannot be reproduced in Stencil e2e tests (see test comment) — the fix is verified by code inspection and the production axe violations it resolves

Approvals

Applicable checklist: Component Fix

  • The PR has the patch label
  • Any new properties, custom events, or utility functions have e2e and/or unit tests
  • Any markup changes are evaluated for impact on vets-website — this fix resolves existing CI failures
  • Any Chromatic UI snapshot changes have been reviewed and approved by a designer if necessary
  • Engineering has approved the PR

When React rapidly toggles visible=true then visible=false across two
render passes, componentDidUpdate schedules setupModal via rAF on the
first pass, then teardownModal runs synchronously on the second pass
while the rAF is still pending. The stale rAF fires setupModal after
teardown, calling applyAriaHidden() which permanently orphans
aria-hidden="true" and data-aria-hidden="true" on DOM elements,
causing aria-hidden-focus axe violations.

Fix: store the rAF ID and cancel it in teardownModal and
disconnectedCallback so a deferred setupModal never fires after
teardown.

Add e2e test verifying no orphaned aria-hidden attributes remain after
an open/close cycle.
@acrollet acrollet added the patch Patch change in semantic versioning label Mar 20, 2026
@acrollet acrollet requested a review from Copilot March 20, 2026 19:49
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a timing race in va-modal where a pending requestAnimationFrame-scheduled setupModal() could run after teardownModal(), leaving orphaned aria-hidden / data-aria-hidden attributes on non-modal content (notably reproducible via rapid React prop toggles).

Changes:

  • Track the requestAnimationFrame id used to defer setupModal() and cancel it during teardown/disconnect.
  • Add an e2e regression test asserting aria-hidden cleanup after a modal open/close cycle.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/web-components/src/components/va-modal/va-modal.tsx Stores and cancels the pending rAF for setupModal() to prevent post-teardown aria-hidden application.
packages/web-components/src/components/va-modal/test/va-modal.e2e.ts Adds an e2e test to verify aria-hidden / data-aria-hidden are cleaned up after closing.

Comment on lines 179 to 194
@@ -187,13 +190,17 @@ export class VaModal {

this.isVisibleDirty = false;
if (this.isVisible()) {
requestAnimationFrame(() => this.setupModal());
this.setupModalRafId = requestAnimationFrame(() => this.setupModal());
} else {
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setupModalRafId is intended to represent a pending rAF callback, but it’s never cleared when the rAF actually runs. Consider clearing setupModalRafId inside the rAF callback (before calling setupModal) so later teardown/disconnect logic reflects the real state and avoids canceling an already-fired frame ID.

Copilot uses AI. Check for mistakes.
Comment on lines +200 to +203
if (this.setupModalRafId) {
cancelAnimationFrame(this.setupModalRafId);
this.setupModalRafId = undefined;
}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disconnectedCallback() now duplicates the rAF-cancellation logic that already exists in teardownModal(). To avoid divergence over time, consider extracting a small helper (e.g., cancelSetupModalRaf()) or relying solely on teardownModal() to perform the cancellation.

Suggested change
if (this.setupModalRafId) {
cancelAnimationFrame(this.setupModalRafId);
this.setupModalRafId = undefined;
}

Copilot uses AI. Check for mistakes.
Comment on lines +832 to +837
// Additionally verify no elements in the document have orphaned attributes
const orphanedCount = await page.evaluate(() => {
return document.querySelectorAll(
'[aria-hidden="true"], [data-aria-hidden="true"]',
).length;
});
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says this check verifies no elements in the document have orphaned aria-hidden/data-aria-hidden, but document.querySelectorAll(...) won’t traverse into shadow roots. If the intent is to cover shadow DOM cases too, update the evaluation to walk shadow roots; otherwise consider tightening the wording so the test doesn’t imply broader coverage than it has.

Copilot uses AI. Check for mistakes.
@jamigibbs jamigibbs added the run chromatic workflow Allows a PR to run the chromatic workflow to deploy Chromatic without "ready for review" status. label Mar 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch Patch change in semantic versioning run chromatic workflow Allows a PR to run the chromatic workflow to deploy Chromatic without "ready for review" status.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants