diff --git a/src/internals/controllers/aria-projection.spec.ts b/src/internals/controllers/aria-projection.spec.ts index 8366ad915..eeedd9cee 100644 --- a/src/internals/controllers/aria-projection.spec.ts +++ b/src/internals/controllers/aria-projection.spec.ts @@ -11,6 +11,7 @@ import { LitElement, render } from 'lit'; import sinon from 'sinon'; import IgcCheckboxComponent from '../../components/checkbox/checkbox.js'; import IgcInputComponent from '../../components/input/input.js'; +import IgcSelectComponent from '../../components/select/select.js'; import { defineComponents } from '../definitions/defineComponents.js'; import { type ARIABindings, @@ -124,7 +125,7 @@ describe('ARIA projection', () => { }); describe('trackLabels', () => { - before(() => defineComponents(IgcCheckboxComponent)); + before(() => defineComponents(IgcCheckboxComponent, IgcSelectComponent)); it('reads the labels of a control once per update', async () => { const root = await fixture( @@ -146,6 +147,29 @@ describe('ARIA projection', () => { labels.get.restore(); }); + + it('reads the labels of a projecting host once per update', async () => { + const root = await fixture( + html`
+ + +
` + ); + const select = root.querySelector(IgcSelectComponent.tagName)!; + const labels = sinon.spy(ElementInternals.prototype, 'labels', ['get']); + + // An update of the select alone: the inner input keeps an id and reads its own labels. + select.requestUpdate(); + await elementUpdated(select); + labels.get.restore(); + + expect(labels.get.callCount).to.equal(1); + expect( + select.renderRoot + .querySelector(IgcInputComponent.tagName)! + .renderRoot.querySelector('input')!.ariaLabelledByElements + ).to.eql([root.querySelector('label')]); + }); }); describe('ariaBindings part type', () => { diff --git a/src/internals/controllers/aria-projection.ts b/src/internals/controllers/aria-projection.ts index 59ace1ee6..f3421bdd4 100644 --- a/src/internals/controllers/aria-projection.ts +++ b/src/internals/controllers/aria-projection.ts @@ -442,6 +442,7 @@ class AriaProjectorController implements ReactiveController { private readonly _host: ControllerHost; private readonly _config: AriaProjectorConfig; private _retryScheduled = false; + private _naming?: ResolvedNaming; constructor(host: ControllerHost, config: AriaProjectorConfig) { this._host = host; @@ -451,13 +452,13 @@ class AriaProjectorController implements ReactiveController { /** Computes the state, with the name, the helper text and the description of the host. */ private _state(): ProjectedARIA { - const { state, naming = true, fallbackLabel } = this._config; + const { state, naming = true } = this._config; const projected = state(); const host = this._host; const helper = naming && host.renderRoot.querySelector('igc-validator'); return { - ...(naming && resolveNaming(host, hasLabel(host), fallbackLabel?.())), + ...this._naming, ...projected, describedBy: joinRelations( helper ? [helper] : null, @@ -467,6 +468,17 @@ class AriaProjectorController implements ReactiveController { }; } + /** @internal */ + public hostUpdate(): void { + const { naming = true, fallbackLabel } = this._config; + const host = this._host; + + // The labels cache of the internals controller ends at `hostUpdated`. + this._naming = naming + ? resolveNaming(host, hasLabel(host), fallbackLabel?.()) + : undefined; + } + /** @internal */ public hostUpdated(): void { const element = this._config.target(); diff --git a/src/internals/controllers/async-consumer.spec.ts b/src/internals/controllers/async-consumer.spec.ts new file mode 100644 index 000000000..e0518e6f4 --- /dev/null +++ b/src/internals/controllers/async-consumer.spec.ts @@ -0,0 +1,54 @@ +import { ContextProvider, createContext } from '@lit/context'; +import { defineCE, elementUpdated, expect, fixture } from '@open-wc/testing'; +import { LitElement } from 'lit'; +import sinon from 'sinon'; +import { addAsyncContextConsumer } from './async-consumer.js'; + +const testContext = createContext(Symbol('async-consumer-test')); + +type Consumer = LitElement & { received: string[] }; + +describe('Async context consumer', () => { + const consumerTag = defineCE( + class extends LitElement { + public received: string[] = []; + + constructor() { + super(); + addAsyncContextConsumer(this, testContext, (value) => + this.received.push(value) + ); + } + } + ); + + afterEach(() => sinon.restore()); + + it('answers the waiting consumers of a provider that the browser defines later', async () => { + const providerTag = 'igc-async-consumer-test-provider'; + const consumer = `<${consumerTag}>`; + const listen = sinon.spy(document, 'addEventListener'); + const container = await fixture( + `
<${providerTag}>${consumer.repeat(3)}
` + ); + const consumers = [...container.querySelectorAll(consumerTag)]; + await Promise.all(consumers.map(elementUpdated)); + + customElements.define( + providerTag, + class extends LitElement { + public provider = new ContextProvider(this, { + context: testContext, + initialValue: 'value', + }); + } + ); + + for (const each of consumers) { + expect(each.received).to.eql(['value']); + } + + // An earlier spec can add the shared listener first. + expect(listen.withArgs('context-provider').callCount).to.be.at.most(1); + }); +}); diff --git a/src/internals/controllers/async-consumer.ts b/src/internals/controllers/async-consumer.ts index 0097b68fa..a42473877 100644 --- a/src/internals/controllers/async-consumer.ts +++ b/src/internals/controllers/async-consumer.ts @@ -4,13 +4,36 @@ import type { ReactiveController, ReactiveControllerHost, } from 'lit'; -import { createAbortHandle } from '../abort-handler.js'; +import { getOrInsertComputed } from '../utils/objects.js'; type AsyncContextOptions> = { context: T; callback?: (value: ContextType, dispose?: () => void) => void; }; +type Retry = (context: unknown) => void; + +/** The unanswered consumers of each document. */ +const waiting = new WeakMap>(); + +/** + * A late provider announces itself, so the unanswered consumers ask again. + * One listener per document: Chromium scans all listeners on each add and + * remove. + */ +function retryWaiting(event: Event): void { + const { context } = event as Event & { context?: unknown }; + + for (const retry of waiting.get(event.currentTarget as Document) ?? []) { + retry(context); + } +} + +function createWaiting(document: Document): Set { + document.addEventListener('context-provider', retryWaiting); + return new Set(); +} + /* blazorSuppress */ class AsyncContextConsumer< T extends Context, @@ -19,9 +42,11 @@ class AsyncContextConsumer< protected _host: Host; protected _options: AsyncContextOptions; protected _consumer?: ContextConsumer; - /** Whether a provider answered the request of the current connection. */ - private _answered = false; - private readonly _abort = createAbortHandle(); + /** + * The set of the document at connect time. A move to another document + * changes `ownerDocument` before the disconnect. + */ + private _waiting?: Set; constructor(host: Host, options: AsyncContextOptions) { this._host = host; @@ -34,14 +59,8 @@ class AsyncContextConsumer< return this._consumer?.value; } - /** - * A provider that connects after the host, such as one that the browser - * defines later, announces itself. An unanswered request then goes again. - */ - private readonly _handleProvider = (event: Event): void => { - const { context } = event as Event & { context?: unknown }; - - if (!this._answered && context === this._options.context) { + private readonly _retry: Retry = (context) => { + if (context === this._options.context) { this._consumer?.hostConnected(); } }; @@ -49,12 +68,12 @@ class AsyncContextConsumer< // The consumer survives a disconnect, and a reconnect can land during the // await, so the guard runs on both sides of it. public async hostConnected(): Promise { - this._answered = false; - this._host.ownerDocument.addEventListener( - 'context-provider', - this._handleProvider, - { signal: this._abort.signal } + this._waiting = getOrInsertComputed( + waiting, + this._host.ownerDocument, + createWaiting ); + this._waiting.add(this._retry); if (this._consumer) { return; @@ -65,7 +84,7 @@ class AsyncContextConsumer< this._consumer ??= new ContextConsumer(this._host, { context: this._options.context, callback: (value, dispose) => { - this._answered = true; + this._waiting?.delete(this._retry); this._options.callback?.(value, dispose); }, subscribe: true, @@ -73,7 +92,7 @@ class AsyncContextConsumer< } public hostDisconnected(): void { - this._abort.abort(); + this._waiting?.delete(this._retry); } } diff --git a/src/internals/controllers/id-resolver.ts b/src/internals/controllers/id-resolver.ts index 080cf79a8..0f24ab996 100644 --- a/src/internals/controllers/id-resolver.ts +++ b/src/internals/controllers/id-resolver.ts @@ -2,14 +2,13 @@ import { isServer, type LitElement, type ReactiveController } from 'lit'; import { isDocument, isElement } from '../utils/dom.js'; import { getOrInsertComputed } from '../utils/objects.js'; -const ID_REF_EMITTERS = new WeakMap(); -const ID_REF_EVENT = 'id-refs-change'; +const ID_REF_OBSERVERS = new WeakMap(); -function getEmitter(root: Node): IdRefChangeEmitter { +function getObserver(root: Node): IdRefObserver { return getOrInsertComputed( - ID_REF_EMITTERS, + ID_REF_OBSERVERS, root, - () => new IdRefChangeEmitter(root) + () => new IdRefObserver(root) ); } @@ -24,10 +23,7 @@ function collectIds(nodes: NodeList, affected: Set): void { } } -function refObserverCallback( - mutations: MutationRecord[], - emitter: IdRefChangeEmitter -): void { +function changedIds(mutations: MutationRecord[]): Set { const affected = new Set(); for (const mutation of mutations) { @@ -43,34 +39,38 @@ function refObserverCallback( } } - if (affected.size > 0) { - emitter.dispatchEvent(new CustomEvent(ID_REF_EVENT, { detail: affected })); - } + return affected; } /** - * Sends an event when an ID in a root node changes. Its consumers share one - * reference-counted observer. + * Tells the resolvers of a root node which ids changed. They share one + * observer. Not an `EventTarget`: Chromium scans all listeners on each add + * and remove. */ -class IdRefChangeEmitter extends EventTarget { +class IdRefObserver { private readonly _observer?: MutationObserver; private readonly _root: Node; - private _refCount = 0; + private readonly _resolvers = new Set(); constructor(root: Node) { - super(); this._root = root; if (!isServer) { - this._observer = new MutationObserver((mutations) => - refObserverCallback(mutations, this) - ); + this._observer = new MutationObserver((mutations) => { + const ids = changedIds(mutations); + + if (ids.size > 0) { + for (const resolver of this._resolvers) { + resolver.idsChanged(ids); + } + } + }); } } - /** Starts the mutation observer on the first call. */ - public retain(): void { - if (this._refCount++ === 0) { + /** Adds a resolver, and starts the observer for the first one. */ + public add(resolver: IdRefResolverController): void { + if (this._resolvers.size === 0) { const root = isDocument(this._root) ? this._root.body : this._root; this._observer?.observe(root, { attributeFilter: ['id'], @@ -79,11 +79,13 @@ class IdRefChangeEmitter extends EventTarget { childList: true, }); } + + this._resolvers.add(resolver); } - /** Stops the observer when the reference count reaches zero. */ - public release(): void { - if (this._refCount > 0 && --this._refCount === 0) { + /** Removes a resolver, and stops the observer after the last one. */ + public delete(resolver: IdRefResolverController): void { + if (this._resolvers.delete(resolver) && this._resolvers.size === 0) { this._observer?.disconnect(); } } @@ -96,8 +98,8 @@ class IdRefResolverController implements ReactiveController { /** Whether the consumer asked for the observation. */ private _active = false; - /** The emitter of the current root node, non-null only while observing. */ - private _emitter: IdRefChangeEmitter | null = null; + /** The shared observer of the current root node, non-null only while observing. */ + private _shared: IdRefObserver | null = null; constructor(host: LitElement, callback: (ids: Set) => unknown) { this._host = host; @@ -106,27 +108,20 @@ class IdRefResolverController implements ReactiveController { } private _observe(): void { - if (this._emitter) { - return; + if (!this._shared) { + this._shared = getObserver(this._host.getRootNode()); + this._shared.add(this); } - - const root = this._host.getRootNode(); - this._emitter = getEmitter(root); - this._emitter.retain(); - this._emitter.addEventListener(ID_REF_EVENT, this); } private _unobserve(): void { - if (this._emitter) { - this._emitter.removeEventListener(ID_REF_EVENT, this); - this._emitter.release(); - this._emitter = null; - } + this._shared?.delete(this); + this._shared = null; } /** @internal */ - public handleEvent(event: Event): void { - this._callback.call(this._host, (event as CustomEvent>).detail); + public idsChanged(ids: Set): void { + this._callback.call(this._host, ids); } /** @internal */