Wire Adapters

Summary

The data service defines two categories of invariants: Fundamental and Component Integration. Imperative access for DML and non-DML operations is assumed. Using the callable's identity as the identifier of the wire adapter (the pluggable mechanism of the data service) is assumed.

This RFC defines a protocol for wire adapters so new wire adapters may be authored and consumed by components without coordination of the framework.

Basic example

A component demonstrating consumption of the todo wire adapter and then requesting a refresh of the data stream.

import { LightningElement, wire } from 'lwc';
import { getTodo, refreshTodo } from 'todo-wire-adapter';
export default class TodoViewer extends LightningElement {
    @api id;

    // Wire identifier is the imported callable getTodo
    @wire(getTodo, { id: '$id' })
    wiredTodo;

    imperativeExample() {
        getTodo({id: 1})
            .then(todo => { ... });
    }

    refreshExample() {
        this.showSpinner(true);
        // Request refresh using the value emitted from the wire.
        refreshTodo(this.wiredTodo)
            .then(() => this.showSpinner(false));
    }
}

An implementation of the todo wire adapter that uses observables for a stream of values.

import { register, ValueChangedEvent } from 'wire-service';

// Imperative access.
export function getTodo(config) {
    return getObservable(config)
        .map(makeReadOnlyMembrane)
        .toPromise();
}

// Declarative access: register a wire adapter factory for  @wire(getTodo).
register(getTodo, function getTodoWireAdapterFactory(eventTarget) {
    let subscription;
    let config;

    // Invoked when config is updated.
    eventTarget.addListener('config', (newConfig) => {
        // Capture config for use during subscription.
        config = newConfig;
    });

    // Invoked when component connected.
    eventTarget.addListener('connected', () => {
        // Subscribe to stream.
        subscription = getObservable(config)
            .map(makeReadOnlyMembrane)
            .map(captureWiredValueToConfig.bind(config))
            .subscribe({
                next: (data) => wiredEventTarget.dispatchEvent(new ValueChangedEvent({ data, error: undefined })),
                error: (error) => wiredEventTarget.dispatchEvent(new ValueChangedEvent({ data: undefined, error }))
            });
    })

        // Invoked when component disconnected.
    eventTarget.addListener('disconnected', () => {
        // Release all resources.
        subscription.unsubscribe();
        releaseConfig(config);
    });
});

// Component-importable refresh capability. Returns Promise<any> that
// resolves when all wires are updated.
export function refreshTodo(wiredValue) {
    return getConfig(wiredValue).map(getTodo);
}

Motivation

The data service defines a declarative mechanism for a component to express its data requirements: the @wire decorator. Imperative access (discussed elsewhere) enables components to orchestrate control flow which is generally required for DML operations.

The protocol for wire adapters is not formalized so new adapters require coordination with the framework and knowledge of the wire service implementation.

Supporting refresh of values emitted by the wire service (wired values) is not possible because wire adapters can't associate the wired value and the originating wire adapter configuration.

Goals of this proposal

Proposal

A wire adapter provisions data to a wired property or method using an Event Target. A factory function is registered for declarative @wire use by a component.

// Events the wire adapter can dispatch to provision a value to the wired property or method
interface ValueChangedEvent {
    value: any;
    new(value: any) : ValueChangedEvent;
}

// Event types the wire adapter may listen for
type eventType = 'config' | 'connect' | 'disconnect';

interface ConfigListenerArgument {
    [key: string]: any;
}
type Listener = (config?: ConfigListenerArgument) => void;

interface WireEventTarget extends EventTarget {
    dispatchEvent(event: ValueChangedEvent): boolean;
    addEventListener(type: eventType, listener: Listener): void;
    removeEventListener(type: eventType, listener: Listener): void;
}

// Registers a wire adapter factory for an imperative accessor
register(adapterId: Function|Symbol, wireAdapterFactory: (eventTarget: WireEventTarget) => void): undefined;

In the component's wiring lifecycle, the wire service invokes the wireAdapterFactory function to configure an instance of the wire adapter for each @wire instance (which is per component instance).

eventTarget is an implementation of Event Target that supports listeners for the following events:

The wire service remains responsible for resolving the configuration object. eventTarget delivers a config event when the resolved configuration changes. The value of the configuration is specific to the wire adapter. The wire adapter must treat the object as immutable.

The wire adapter is responsible for provisioning values by dispatching a ValueChangedEvent to the event target. ValueChangedEvent's constructor accepts a single argument: the value to provision. There is no limitation to the shape or contents of the value to provision. The event target handles property assignment or method invocation based on the target of the @wire.

The wire adapter is responsible for maintaining any context it requires. For example, tracking the values it provisions and the originating resolved configuration is shown in the basic example.

Imperative

Imperative access to data is unchanged. The wire adapter module must export a callable. The callable's arguments should (not must) match those of the wire adapter's configuration. The return value is adapter specific; it need not be a promise.

Refresh

Wire adapters may optionally implement a refresh mechanism by defining an importable callable with the signature below. The function receives one argument: the wired value emitted by a @wire.

type refresh: (wiredValue: any) => promise<any>

The callable receives one argument, wiredValue, which is the value emitted from the wire adapter (the parameter to the ValueChangedEvent constructor).

The callable must return a promise<any> that resolves after the corresponding @wire is updated (assuming it updates). The value resolved is adapter specific.

Advantages

Disadvantages

Extended Proposal

There are known use cases where adapters will use DOM Events to retrieve data from the DOM hierarchy. This is not possible because wire adapters are not provided access to the host element as an EventTarget. This section proposes a solution: the EventTarget provided to the wire adapter factory bridges dispatched events to the host element.

Extended basic example

The component code changes slightly.

import { LightningElement, wire } from 'lwc';
import { getTodo, refreshTodo } from 'todo-wire-adapter';
export default class TodoViewer extends LightningElement {
    @api id;

    @wire(getTodo, { id: '$id' })
    wiredTodo;

    imperativeExample() {
        // Difference: pass this
        getTodo(this, {id: 1})
            .then(todo => { ... });
    }

    refreshExample() {
        this.showSpinner(true);
        refreshTodo(this.wiredTodo)
            .then(() => this.showSpinner(false));
    }
}

This implementation of the todo wire adapter uses DOM Events to retrieve the data from a parent element.

import { register, ValueChangedEvent } from 'wire-service';

// Difference: receive an eventTarget, use DOM Events to fetch the observable
function getObservable(eventTarget, config) {
    let observable;
    const event = new CustomEvent('getTodo', {
        bubbles: true,
        cancelable: true,
        composed: true,
        detail: {
            config,
            callback: o => { observable = o; }
        }
    });
    eventTarget.dispatchEvent(event);
    return observable;
}

// Wire adapter id isn't a callable because it doesn't support imperative invocation
export const getTodo = Symbol('getTodo');

register(getTodo, function getTodoWireAdapterFactory(eventTarget) {
    let subscription;
    let config;

    eventTarget.addListener('config', (newConfig) => {
        config = newConfig;
    });

    eventTarget.addListener('connected', () => {
        // Difference: pass eventTarget
        subscription = getObservable(eventTarget, config)
            .map(makeReadOnlyMembrane)
            // Difference: capture eventTarget
            .map(captureWiredValueToEventTargetAndConfig.bind(eventTarget, config))
            .subscribe({
                next: (data) => wiredEventTarget.dispatchEvent(new ValueChangedEvent(data)),
                error: (error) => wiredEventTarget.dispatchEvent(new ValueChangedEvent(error))
            });
    })

    eventTarget.addListener('disconnected', () => {
        subscription.unsubscribe();
        // Difference: release eventTarget
        releaseEventTargetAndConfig(config);
    });
});

export function refreshTodo(wiredValue) {
    // Difference: retrieve eventTarget and config
    return getEventTargetAndConfig(wiredValue).map(getTodo);
}

The scope of changes is minimal: the event target re-dispatches events other than ValueChangedEvent to the host element.

Rejected Proposals

Proposal 1: Promise-based

Writing a new wire adapter should be as easy as implementing a module that exports a callback with a simple protocol:

export function getFoo(config) {
    // return `Promise<foo>`
}

Advantages

Disadvantages

Proposal 2: Observables-based

This proposal is similar to Proposal 1 except that it uses observable instead of promises. Note: this proposal does not specify the observable protocol, that can be specified somewhere else.

Writing a new wire adapter should be as easy as implementing a module that exports a callback with a well defined protocol for observables:

export function getFoo(config) {
    // return an observable that emits `foo`
}

Advantages

Disadvantages

Proposal 3: Thenable observable

This proposal is similar to Proposal 2 except that it combines observable and thenable structures into a single object.

The adapter must return a thenable that might or might not implement the observable protocol. This means that imperative calls to that adapter will return something that is compatible with the promise protocol, which is easy to use, while the @wire decorator and advanced users can rely on the observable protocol to get a stream of data.

This variation of proposal 2 addresses several disadvantages by enabling usage of the standard promise protocol.

This variation introduces two main problems:

Proposal 4: Public thenable and private observable

This proposal is a combination of proposals 1 and 2:

Rationale for the imperative behavior:

Rationale for @wire behavior:

The wire adapter:

Addressing issues from proposal 3

Proposal 5: callbacks

This proposal differs from the primary proposal only in the ergonomics exposed to the wire adapter developer:

register(getType, function wireAdapter(targetSetter) {
    return {
        updatedCallback: (config) => {
        },
        connectedCallback: () => {
        },
        disconnectedCallback: () => {
        }
    };
});

Disadvantages

undefined