Wire reform

This RFC describes the way to decouple the wire service from LWC entirely, and implement reactive tracking for wired configuration and wired methods.

Motivations

There is a dual-dependency between @lwc/engine and @lwc/wire-service, even though neither of those two packages are importing each other, it is the responsibility of the adapter author to connect them via registerWireService(register) where registerWireService() is provided by @lwc/wire-service and register() is provided by @lwc/engine. This is, by itself, complex and confusing. Additionally, there is another register() method from @lwc/wire-service that is used by authors to link their wire adapters with their adapter ID (identity). This process also poses a limitation, and unnecessary dependency, making adapters to tied to LWC.

Additionally, there are various situations where the wired field or method is not behaving correctly, the most notable example is when a configuration value that uses a member expression might not trigger the update on the config. (e.g. wire(foo, { x: '$foo.bar' }) data, if foo.bar changes, config is not updated). This is because the wire decorator is not relying on the reactivity system used by LWC, and instead it relies on getter and setters that are slower, intrusive, complex and do not cover the whole spectrum of mutations that could occur on a component.

Finally, keeping the wire service tied to LWC means that when needed, wire adapters will not be very useful beyond LWC, when in reality they are not tied to the component system.

Goals

The primary goal of this RFC is to decouple the wire service from LWC and the LWC Wire Decorator implementation.

As a secondary goal, to embrace reactivity for the configuration payload and the wired method in LWC.

A third goal is to support the provision of wire adapters via wire service on any object, whether it is LWC component or not.

No-goals

Proposal

This reform is focused on the refactor of the wire decorator code, and the wire service code. As part of the separation process, there are certain responsibilities that must be well defined:

Responsibilities of the wire decorator

Responsibilities of the wire service

Implementation Details

Backwards Compatibility Notes:

Wire Adapter Protocol

The formalization of the wire adapter protocol is important because that enables the interoperability aspect of this feature. The adapter's code should not be aware of the component system, or even the application framework. It only cares about very specific hints to produce a stream of data. The following describes the proposed protocol:

interface WireAdapter {
    update(config: ConfigValue, context?: ContextValue);
    connect();
    disconnect();
}
interface WireAdapterConstructor {
    new (callback: DataCallback): WireAdapter;
    configSchema?: Record<string, WireAdapterSchemaValue>;
    contextSchema?: Record<string, WireAdapterSchemaValue>;
}
type DataCallback = (value: any) => void;
type ConfigValue = Record<string, any>;
type ContextValue = Record<string, any>;
type WireAdapterSchemaValue = 'optional' | 'required';

Notes:

Semantic changes for @wire decorator IDL

There exist a few restrictions and ambiguities with the IDL for the config object in @wire decorator declarations. This section will describe the changed semantics. Most use cases of @wire are unaffected.

Context Provider for Wire Adapters

For LWC, we can introduce a new API that allows the creation of a Contextualizer, which is a function that can be used to install a Context Provider on any EventTarget. This Contextualizer has very specific semantics, and allows LWC engine to do the bridging between ContextProvider and ContextConsumer (Wire Adapters used via @wire decorator when defining contextSchema as a static field on the adapter).

When installing a Contextualizer in an EventTarget, you can provide a set of options that will allow pushing context values to each individual ContextConsumer via a very simple API. Lets see an example:

import { createContextProvider } from 'lwc';
import { MyAdapter } from 'my/adapter';
// creating a new contextualizer for `MyAdapter`
const contextualizer = createContextProvider(MyAdapter);

// finding the element to be used as the provider
const elm = document.querySelector('container');
// installing contextualizer on `elm`
contextualizer(elm, {
    consumerConnectedCallback(consumer) {
        consumer.provide({ x: 1 });
    },
});

The example above guarantees that any component connected under elm's subtree, and wired to MyAdapter will receive a context of { x: 1 } in the adapter via the Adapter's update() API.

The following is the specification of the Contextualizer:

interface ContextConsumer {
    provide(newContext: ContextValue): void;
}
interface ContextProviderOptions {
    consumerConnectedCallback: (consumer: ContextConsumer) => void;
    consumerDisconnectedCallback?: (consumer: ContextConsumer) => void;
}
type Contextualizer = (elm: EventTarget, options: ContextProviderOptions) => void;

Invariants:

Notes:

Backwards Compatibility

This RFC does introduce minor (or minimal) breaking changes:

Forward Compatible Changes

Proposed Restrictions for Lightning Platform

Interop

If you have an adapter, you should be able to use it with any component system, not just LWC. This is an example of how to use this with React:

// shared adapter
import { MyWireAdapter } from 'some-module';

class Foo extends React.Component {
    constructor(props) {
        super(props);
        // The wire adapter instance is bound to the host object via the callback for data
        this.adapter = new MyWireAdapter((data) => {
            this.setState(() => {
                // stream of data from wire adapter to be used to update the component's state
                return data;
            });
        });
        // calling for the initial update of the config since componentDidUpdate() is not
        // invoked for the first time, but all props are ready.
        this.adapter.update({ x: 1, y: this.props.valueOfY });
    }

    componentDidUpdate(prevProps) {
        if (this.props.valueOfY !== prevProps.valueOfY) {
            // recompute the config by extracting `this.props.valueOfY`
            this.adapter.update({ x: 1, y: this.props.valueOfY });
        }
    }

    componentDidMount() {
        this.adapter.connect();
    }

    componentWillUnmount() {
        this.adapter.disconnect();
    }

    render() {
        return (
            <div>{this.state.valueProducedByMyWireAdapter}</div>
        );
    }
}

Adoption strategy

Since there is a need to support callable adapters that behave differently depending on who uses that (wire adapter vs user invoking the function directly), we have added a simple mechanism to support such feature via adapter property member expression on the callable. This opens the door to transition existing adapters to the new form. E.g.: APEX adapters are all callable objects.

Additionally, those callable objects can implement forking logic based on the type of argument, if there is a desire to avoid the adapter property member expression. E.g.:

export function invokeApex(...args) {
    if (new.target) {
        // invocation via new, return a WireAdapter instance
        const [ dataCallback ] = args;
        // ...
    } else {
        // standard function call, return a Promise of some Apex controller result
        const [ apexControllerParams ] = args;
        // ...
    }
}

How we teach this

Unresolved questions

undefined