Skip to content

fix(web-components): preserve radio state after deferred upgrade#36243

Open
KirtiRamchandani wants to merge 1 commit into
microsoft:masterfrom
KirtiRamchandani:fix/radio-group-upgrade-shadowing
Open

fix(web-components): preserve radio state after deferred upgrade#36243
KirtiRamchandani wants to merge 1 commit into
microsoft:masterfrom
KirtiRamchandani:fix/radio-group-upgrade-shadowing

Conversation

@KirtiRamchandani
Copy link
Copy Markdown

Previous Behavior

When a fluent-radio-group upgraded before its slotted fluent-radio children, the group collected tag-name matches before the radio elements had upgraded. radiosChanged then wrote checked, name, and disabled directly onto bare HTMLElement instances, creating own properties that shadowed the eventual radio accessors.

New Behavior

RadioGroup now defers descendant collection, only writes to radio elements after they have upgraded, and refreshes the collection once matching pending radio tags finish defining. This preserves the radio checked accessor so state, ARIA, and form participation stay in sync.

A regression test composes temporary radio/radio-group tags and forces the group to upgrade before the radios, verifying the selected radio is checked without an own checked shadow property.

Tests run

  • corepack yarn nx run tokens:build
  • corepack yarn e2e src/radio-group/radio-group.spec.ts --project=chromium -g "preserve checked state"
  • corepack yarn e2e src/radio-group/radio-group.spec.ts --project=chromium --workers=1 (passed with one harness setup retry)
  • corepack yarn type-check from packages/web-components
  • corepack yarn eslint src/radio-group/radio-group.base.ts src/radio-group/radio-group.spec.ts from packages/web-components
  • corepack yarn prettier --check packages/web-components/src/radio-group/radio-group.base.ts packages/web-components/src/radio-group/radio-group.spec.ts
  • corepack yarn check:change
  • git diff --check

Related Issue(s)

@KirtiRamchandani KirtiRamchandani requested a review from a team as a code owner May 25, 2026 03:23
@chrisdholt chrisdholt requested a review from radium-v May 25, 2026 17:31
@chrisdholt
Copy link
Copy Markdown
Member

Tagging @radium-v as a required reviewer.

Copy link
Copy Markdown
Contributor

@radium-v radium-v left a comment

Choose a reason for hiding this comment

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

While I think this is pointing out a few important scenarios (tag names can be a fragile identifier; ancestral relationships between components all have additional structural concerns), I'd prefer if we could address the issue all-up. We have similar problems with Accordion/AccordionItem, Dropdown/Listbox/DropdownOption, MenuButton/Menu/MenuList/MenuItem, Tree/TreeItem, and others.

await expect(radios.nth(2)).toHaveJSProperty('checked', false);
});

test('should preserve checked state when radios upgrade after the group', async ({ page }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test needs to be written using fastPage, but may need to be its own test fixture entrypoint. Doing it this way may allow us to reuse this for other components that have similar ancestor/descendant patterns.

Comment on lines +220 to +222
private static isUpgradedRadio(element: Element): element is Radio {
return element.localName.endsWith('-radio') && '$fastController' in element;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

radio.options.ts already provides an isRadio predicate function that handles the tagname checking, and the $fastController check can be done in-place.

this.radios = [...this.querySelectorAll('*')].filter(x => isRadio(x)) as Radio[];
Updates.enqueue(() => {
const elements = [...this.querySelectorAll('*')];
this.radios = this.getRadioDescendants();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why get elements first and then get radio descendants separately? They both end up querying for all elements.

const elements = [...this.querySelectorAll('*')];
this.radios = this.getRadioDescendants();

for (const tagName of new Set(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a weird pattern since Set provides iterator methods

Comment on lines +240 to +249
elements
.filter(x => x.localName.endsWith('-radio') && !BaseRadioGroup.isUpgradedRadio(x))
.map(x => x.localName),
)) {
customElements.whenDefined(tagName).then(() => {
if (this.isConnected) {
this.radios = this.getRadioDescendants();
}
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's potential for a lot of repetitive and unnecessary DOM querying going on here

import type { RadioGroup } from './radio-group.js';
import { tagName } from './radio-group.options.js';

const sourceBaseUrl = `/@fs${new URL('../', import.meta.url).pathname}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd rather we didn't rely on internal vite-specific constructs, or try to work outside of the bounds of the environment. I see custom pathing like this as a fragile workaround for something that can probably be handled in a better and more purposeful way. For instance, the new test can likely be its own test entrypoint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Radio Group Pre-Upgrade Property Shadowing Breaks checked State

3 participants