Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/internals/controllers/aria-projection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>(
Expand All @@ -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<HTMLElement>(
html`<div>
<label for="control">External</label>
<igc-select id="control"></igc-select>
</div>`
);
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', () => {
Expand Down
16 changes: 14 additions & 2 deletions src/internals/controllers/aria-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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();
Expand Down
54 changes: 54 additions & 0 deletions src/internals/controllers/async-consumer.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>(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}></${consumerTag}>`;
const listen = sinon.spy(document, 'addEventListener');
const container = await fixture<HTMLElement>(
`<div><${providerTag}>${consumer.repeat(3)}</${providerTag}></div>`
);
const consumers = [...container.querySelectorAll<Consumer>(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);
});
});
57 changes: 38 additions & 19 deletions src/internals/controllers/async-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,36 @@ import type {
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import { createAbortHandle } from '../abort-handler.js';
import { getOrInsertComputed } from '../utils/objects.js';

type AsyncContextOptions<T extends Context<unknown, unknown>> = {
context: T;
callback?: (value: ContextType<T>, dispose?: () => void) => void;
};

type Retry = (context: unknown) => void;

/** The unanswered consumers of each document. */
const waiting = new WeakMap<Document, Set<Retry>>();

/**
* 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<Retry> {
document.addEventListener('context-provider', retryWaiting);
return new Set();
}

/* blazorSuppress */
class AsyncContextConsumer<
T extends Context<unknown, unknown>,
Expand All @@ -19,9 +42,11 @@ class AsyncContextConsumer<
protected _host: Host;
protected _options: AsyncContextOptions<T>;
protected _consumer?: ContextConsumer<T, Host>;
/** 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<Retry>;

constructor(host: Host, options: AsyncContextOptions<T>) {
this._host = host;
Expand All @@ -34,27 +59,21 @@ 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();
}
};

// 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<void> {
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;
Expand All @@ -65,15 +84,15 @@ 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,
});
}

public hostDisconnected(): void {
this._abort.abort();
this._waiting?.delete(this._retry);
}
}

Expand Down
79 changes: 37 additions & 42 deletions src/internals/controllers/id-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node, IdRefChangeEmitter>();
const ID_REF_EVENT = 'id-refs-change';
const ID_REF_OBSERVERS = new WeakMap<Node, IdRefObserver>();

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)
);
}

Expand All @@ -24,10 +23,7 @@ function collectIds(nodes: NodeList, affected: Set<string>): void {
}
}

function refObserverCallback(
mutations: MutationRecord[],
emitter: IdRefChangeEmitter
): void {
function changedIds(mutations: MutationRecord[]): Set<string> {
const affected = new Set<string>();

for (const mutation of mutations) {
Expand All @@ -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<IdRefResolverController>();

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'],
Expand All @@ -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();
}
}
Expand All @@ -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<string>) => unknown) {
this._host = host;
Expand All @@ -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<Set<string>>).detail);
public idsChanged(ids: Set<string>): void {
this._callback.call(this._host, ids);
}

/** @internal */
Expand Down
Loading