From 29db4f2f85ecc02901d634527ac58652c9a72102 Mon Sep 17 00:00:00 2001 From: KirtiRamchandani Date: Mon, 25 May 2026 08:52:05 +0530 Subject: [PATCH] fix: preserve radio state after deferred upgrade --- .../src/radio-group/radio-group.base.ts | 28 ++++++- .../src/radio-group/radio-group.spec.ts | 79 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/web-components/src/radio-group/radio-group.base.ts b/packages/web-components/src/radio-group/radio-group.base.ts index 48161953041618..e3c1c2b18eb2a6 100644 --- a/packages/web-components/src/radio-group/radio-group.base.ts +++ b/packages/web-components/src/radio-group/radio-group.base.ts @@ -1,6 +1,5 @@ -import { attr, FASTElement, Observable, observable } from '@microsoft/fast-element'; +import { attr, FASTElement, Observable, observable, Updates } from '@microsoft/fast-element'; import type { Radio } from '../radio/radio.js'; -import { isRadio } from '../radio/radio.options.js'; import { RadioGroupOrientation } from './radio-group.options.js'; /** @@ -218,6 +217,14 @@ export class BaseRadioGroup extends FASTElement { @observable slottedRadios!: Radio[]; + private static isUpgradedRadio(element: Element): element is Radio { + return element.localName.endsWith('-radio') && '$fastController' in element; + } + + private getRadioDescendants(): Radio[] { + return [...this.querySelectorAll('*')].filter(BaseRadioGroup.isUpgradedRadio); + } + /** * Updates the radios collection when the slotted radios change. * @@ -225,7 +232,22 @@ export class BaseRadioGroup extends FASTElement { * @param next - the current slotted radios */ slottedRadiosChanged(prev: Radio[] | undefined, next: Radio[]): void { - this.radios = [...this.querySelectorAll('*')].filter(x => isRadio(x)) as Radio[]; + Updates.enqueue(() => { + const elements = [...this.querySelectorAll('*')]; + this.radios = this.getRadioDescendants(); + + for (const tagName of new Set( + 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(); + } + }); + } + }); } /** diff --git a/packages/web-components/src/radio-group/radio-group.spec.ts b/packages/web-components/src/radio-group/radio-group.spec.ts index 6b581d079c232b..1f5ac64465f44a 100644 --- a/packages/web-components/src/radio-group/radio-group.spec.ts +++ b/packages/web-components/src/radio-group/radio-group.spec.ts @@ -4,6 +4,8 @@ import { tagName as RadioTagName } from '../radio/radio.options.js'; import type { RadioGroup } from './radio-group.js'; import { tagName } from './radio-group.options.js'; +const sourceBaseUrl = `/@fs${new URL('../', import.meta.url).pathname}`; + test.describe('RadioGroup', () => { test.use({ tagName, @@ -250,6 +252,83 @@ test.describe('RadioGroup', () => { await expect(radios.nth(2)).toHaveJSProperty('checked', false); }); + test('should preserve checked state when radios upgrade after the group', async ({ page }) => { + await page.goto('/'); + + const result = await page.evaluate(async sourceBaseUrl => { + const id = Date.now().toString(36); + const groupTagName = `upgrade-${id}-radio-group`; + const radioTagName = `upgrade-${id}-radio`; + + const importModule = (path: string): Promise> => import(path); + const [ + radioGroupModule, + radioGroupTemplateModule, + radioGroupStylesModule, + radioModule, + radioTemplateModule, + radioStylesModule, + ] = await Promise.all([ + importModule(`${sourceBaseUrl}radio-group/radio-group.ts`), + importModule(`${sourceBaseUrl}radio-group/radio-group.template.ts`), + importModule(`${sourceBaseUrl}radio-group/radio-group.styles.ts`), + importModule(`${sourceBaseUrl}radio/radio.ts`), + importModule(`${sourceBaseUrl}radio/radio.template.ts`), + importModule(`${sourceBaseUrl}radio/radio.styles.ts`), + ]); + + const RadioGroup = radioGroupModule.RadioGroup as { + compose(options: Record): { define(registry: CustomElementRegistry): void }; + }; + const Radio = radioModule.Radio as { + compose(options: Record): { define(registry: CustomElementRegistry): void }; + }; + + document.body.innerHTML = /* html */ ` + <${groupTagName} value="bar"> + <${radioTagName} value="foo"> + <${radioTagName} value="bar"> + <${radioTagName} value="baz"> + + `; + + RadioGroup.compose({ + name: groupTagName, + template: radioGroupTemplateModule.template, + styles: radioGroupStylesModule.styles, + }).define(customElements); + + const group = document.querySelector(groupTagName); + if (!group) { + throw new Error('Expected radio group to exist.'); + } + + customElements.upgrade(group); + + Radio.compose({ + name: radioTagName, + template: radioTemplateModule.template, + styles: radioStylesModule.styles, + }).define(customElements); + + await customElements.whenDefined(groupTagName); + await customElements.whenDefined(radioTagName); + await new Promise(requestAnimationFrame); + + const checkedRadio = document.querySelector(`${radioTagName}[value="bar"]`) as HTMLElement & { + checked: boolean; + }; + + return { + checked: checkedRadio.checked, + hasOwnChecked: Object.prototype.hasOwnProperty.call(checkedRadio, 'checked'), + }; + }, sourceBaseUrl); + + expect(result.checked).toBe(true); + expect(result.hasOwnChecked).toBe(false); + }); + test('radio should remain checked after it is set to disabled and uncheck when a new radio is checked', async ({ fastPage, page,