Closed justinfagnani closed 3 years ago
@sorvell might be able to put up a draft PR with a few more details relatively soon
preact-custom-element
is also relying on a dispatched event to resolve context: https://github.com/preactjs/preact-custom-element/blob/master/src/index.js#L71 so definitely some prior art here. One area that I've struggled with a little bit when thinking of similar is the idea of live data without being tightly coupled to a renderer. I look forward to the PR to see how that might be addressed therein!
@Westbrook
One area that I've struggled with a little bit when thinking of similar is the idea of live data without being tightly coupled to a renderer
This should be pretty well addressed by the event carrying a callback. The provider calls the callback with data, the consumer reacts however it needs to, including triggering a render. If the consumer supports it, the callback can be called multiple times and trigger updates each time, very similar to useContext
in React.
The one thing I've wondered with this event sort of approach is timing. To use events am I correct the element would have to be connected to the DOM? Would the data be available to us at the time of connectedCallback
?
The other thing is whether slotting should have any consideration. Can light DOM children slotted under Shadow DOM Provider still get access to context.
With Solid Element I use a bit of a different approach. I do lookups up the DOM tree traversing in and out of Slots and Shadow roots. Once you are connected you are then attached to the context more or less since it's just using the DOM heirarchy. I realize events probably do the same thing without re-creating this walk but as I said I never was comfortable trusting the timing of things. It's possible things have changed from the heavily polyfilled ecosystem we had here a few years ago.
And I had a second motivation where I wanted to unify context with the non-webcomponent side which does everything before being attached (or even connected to it's parent) so I needed some creativity there.
@ryansolid
The one thing I've wondered with this event sort of approach is timing.
Events are synchronous, so the timing is suitable for data needed synchronously.
To use events am I correct the element would have to be connected to the DOM?
Events work in disconnected DOM trees, so the elements would not necessarily need to be connected to the document, though a provider would need to be an ancestor in the disconnected tree to provide anything. The question there is what signal the element would use to fire the event to request the data? connectedCallback
is probably the most natural place, so if you needed something even when disconnected you'd have to fire in the constructor, attributeChangedCallback
, etc.
Would the data be available to us at the time of connectedCallback?
Yep, if the provider is ready. One nice thing about firing the event in connectedCallback
is that if you are requesting subtree-dependent data, you can re-request if the element is moved to a new subtree.
The other thing is whether slotting should have any consideration. Can light DOM children slotted under Shadow DOM Provider still get access to context.
Event bubble up the flattened tree, so slots and slot ancestors, including the host, could act as providers to slotted children. This can enable use cases where a utility element in a shadow root, maybe like a theme provider, could provide objects to a slotted child.
I think events, their timing, and their scoping are really well suited for this problem.
When I was implementing our router I created a context API using events. I abandoned the idea in the end for other reasons, but it worked out reliably cross browsers. Lion also uses events for form registration which needs to be synchronous.
Here is one implementation https://github.com/Polymer/pwa-helpers/pull/64
unistore is waiting for the context https://github.com/developit/unistore/issues/175
While I find Context APIs handy, I've found them to not be quite enough in larger applications. In most cases, I've used a more robust Dependency Injection system. Not too long ago, I did some work to integrate a DI system I had written so that it worked on top of React's context. I'd really like to ensure that I can implement DI on top of this Context proposal in a similar way. I imagine that it would work by:
The first point should allow 3rd parties to introduce their own container into the DOM hierarchy to resolve services requested through the DI system, while the second point should allow the DI system to resolve requests by components that are based on the raw context API with no knowledge of containers.
Does that make sense?
The initial event's callback adds the need for an event dispatching mechanism. The provider must act as an observer and has to track consumers (all of the callbacks). Another idea: the provider could simply modify the context event object with the data, a reference to itself, and an event name that it will trigger on itself to update the value. The consumer could then use the data, and then addEventListener() onto the provider, and consume as normal.
@sorvell did you ever get anywhere with your draft of a proposal here? We're looking at formulating a solid "How do I make apps without the context API?" resource for engineers at Adobe moving from React to Web Component projects and would love to shape it around something that was closer to a "community protocol". I'm sure we'll need a good amount of iteration on anything we bring together here, but having something to poke holes in would be a great first step!
I have begun documenting my current approach to Context here in this repository: https://github.com/benjamind/lit-context
I've outlined my best guess at what the API should be, and also provided some implementations against lit-element
v3 style controllers to make it convenient to use.
Would love some feedback on this.
Hi,
Too bad I missed this discussion before. This is a problem that was troubling me for some time. After designing 3 ecosystems of WC I came up with a very similar approach to the presented above. There is a significant difference between what I was doing so far and what's in the proposal. I dismissed the idea of using callbacks after few months of testing in favor of using promises. Each event extends CustomEvent
and has the mandatory result
property defined on the detail object. The result
is just a promise returning a value that the context provider produces.
The context request event dispatched from the component describes what kind of context the component is expecting but I use the type
instead of the name
property. Both have pros and cons. I like using the type
property because inside the context provider I can register events for specified types ignoring all other events. It's then easier to understand the context provider is doing by just looking at events registration. It also allow to filter out easily what the provider can listen to. The cons for using this is that a large application can have hundreds of events like this. In a multi-tenant environment (different apps running in the same document) having a single event for all may be considered as a security issue. There's no was to statically analyze that this component coming from this application can request this context.
The proposal also does not specify how to pass arguments to the context provider. I assume this would be passed in the detail object. However, without standardization of this would make it only partially useful as you won't get proper types support. I fixed this by defining a number of events and these events have well defined properties. At least in the declaration file. I tried both defining properties on the event and on the detail object. Both works pretty well but from my experience it's easier to keep it all in the detail
object.
If I would to design the architecture for this today I would design a new type of event that extends CustomEvent interface. This event has the result
property on the detail object which is a promise resolved by the context provider. The promise is not set automatically when it's created but rather when the context provider handles the event. Missing value (no promise) means that the event was not handled and the component should deal with this somehow.
For an event subscription, when the context provider announces changes in the context, I use separate set of events which I call state events. The context provider dispatches an event when a value change. A component registers event listeners for a specific state change event. When change occurs it requests the changed data from the provider (I usually pass the meta data about the change to this state event containing the identifier of the object, if any, and optional change name).
This way I was able to build rather large applications that work both separately as a standalone application and in a multi-tenant environment. I hope my POV will help understand different use cases and implementations.
I dismissed the idea of using callbacks after few months of testing in favor of using promises.
@jarrodek could you give a little more information as to what you found lacking in callbacks during your test period?
For an event subscription, when the context provider announces changes in the context, I use separate set of events which I call state events. The context provider dispatches an event when a value change. A component registers event listeners for a specific state change event. When change occurs it requests the changed data from the provider (I usually pass the meta data about the change to this state event containing the identifier of the object, if any, and optional change name).
I'd love to see this fleshed out a little bit more in code. I'm not sure where the binding responsibilities and patterns for the "state events" occur. It feels like you'd be adding a good amount of decentralized responsibility to the consumer of the context, which might be beneficial for its disconnection from the context generally but feels like it might actually end up being a lot more work that just registering a callback centrally on the context provider.
@benjamind this looks really great. Can't wait to discuss tomorrow!
I'd definitely like to hear more about what failed you with regard to use of callbacks @jarrodek . One of the benefits for me of this approach is that it is very easy to reason about, and extremely simple protocol. It is also very easy to implement it synchronously, while not closing the door on asynchronous value fulfillment, which I think for many scenarios is quite valuable.
I think I can see what you are driving at in terms of state update, making it an explicit action of the component to 'get' its updated value in response to an event emitted by the state store. I suspect this is not incompatible with the API as proposed above and could be built atop this to provide a different approach to state management.
I've added a few goals/non-goals to the readme in the repo linked above. I've specifically called out wanting to keep this API as simple as possible, a variety of things could be implemented using this protocol, but I think at its core we should strive to keep this as straightforward as we can for maximum compatibility.
btw, @jarrodek there's really no reason to use CustomEvent
for these protocols. We can always define an interface that extends Event
instead.
Sorry for not responding faster.
@Westbrook @benjamind Callbacks a generally OK but as it tuns out they need additional logic around them. While with what I have done I can use an async function and relatively simple logic to read the state from the store, I have to do a bit more when working with callbacks. Consider this. The component requests an information from a context provider that is a IDB wrapper connected to the events system. The component renders a specific view for the data. I setup the component in the DOM with the id of the information this component should render.
Inside this component I can build a single async function that refreshes the state via dispatching the event. Something similar to the following:
set dbId(value) {
this.#id = value;
this.update();
}
async update() {
const { dbId } = this;
const e = new CustomEvent('getcustomer', {
bubbles: true,
cancelable: true,
composed: true,
detail: {
id: dbId,
}
});
this.dispatchEvent(e);
const customer = await e.detail.result;
// do stuff with the customer
}
In my ecosystem I have a library of such events with types definitions so I can simplify this even further:
set dbId(value) {
this.#id = value;
this.update();
}
async update() {
const { dbId } = this;
const customer = await AppEvents.Customer.get(this, id);
// do stuff with the customer
}
With callbacks I can achieve the same goal but I would need at least two functions
update() {
const e = new ContextEvent('getcustomer', this.customerCallback.bind(this));
this.dispatchEvent(e);
}
customerCallback(customer) {
// do stuff with the customer
}
The effect is the same but I believe the ergonomics of this may be optimized.
@justinfagnani I completely agree. At some point I started experimenting with having own events extending the Event
interface and adding own properties to it. Lot of work but end effect is that I have typed events (via t.ds files) that are easy to use. However, seeing that standardized would be so much better that spending hours defining own events.
As for an example. This is the library I am using in one of my OSS apps: https://github.com/advanced-rest-client/arc-events/tree/stage/src/telemetry
In the Events.js
I define custom events the application is using. This is an overkill the way I did it, but it's more of a proof of concept at this point.
Then in the context provider I execute an action. It's not really retuning a value (these are reporting events) but it could.
Anyway, I just wanted to give you a different POV on this. Hope this helps.
I just use similar pattern in one of our projects. But I am actually attaching resolve and reject from a promise
to the event detail. Just discovered this thread though.
/**
* Request data using an event
* @param {?String} method - Data request method
* @param {?String} name - Data source name
* @param {?Object} params - Request parameters
* @returns Promise
*/
requestData(method, name, params) {
const promise = new Promise((resolve, reject) => {
this.dispatchEvent(
new CustomEvent(`somename`, {
detail: {
name: name,
method: method,
params: params,
resolve: resolve,
reject: reject,
},
bubbles: true,
composed: true,
})
);
});
return promise;
}
Promises seems to be more flexible comparing with callbacks.
I've been interested in an approach like this since watching Justin's talk on DI via events some years back. Really love the way things are shaping up
One of the ideas I was eventually wanting to try out was to dispatch an event allowing a request for multiple values at once. Modifying Ben's example a bit..
this.dispatchEvent(
new ContextEvent({
'cool-thing': {
callback: coolThing => {
this.myCoolThing = coolThing; // do something with value
},
},
'another-thing': {
callback: anotherThing => {
this.anotherThing = anotherThing;
},
},
})
);
Possibly a preoptimization, I'm not really sure how costly lots of events in connectedCallback might be...
One potential problem is if two separate ancestors are responsible for providing a given piece of the puzzle. I'm not sure if the community protocol has an opinion around a provider using e.stopPropagation()
if you actually run the callback š¤. I suppose that would generally be a consideration w/ an event requesting just a single value. Multi-values would certainly add some complexity to the situation
@MikeVaz I initially went down a similar path, but I realized one restriction of the promise attached to event model is that the promise can only be resolved once. For cases like wanting to request a theme from higher up the hierarchy and be able to respond to external theme changes this doesn't work so well. I also toyed with just attaching the payload to the detail in the event with the emitter reading it back off after firing the event. This has the same problem of only being usable once. We want I think to go for the 'most capable approach' with this API so it feels like allowing multiple delivery of requested data is valuable.
@robrez this approach also came up in internal discussions at Adobe. I like this extension a lot and will likely include this in the proposal PR I hope to put up later this week/early next. I also want to detail some approaches I have considered for handling context name conflicts by having context providers which handle aliasing contexts, as well as the possibility of caching context providers that was discussed at the last meeting on this topic.
I hope to get the proposal PR up more formally end of this week to detail the main proposal and outline some of these extensions so we can have something to more formally review and hone. Apologies for not getting to it sooner.
but I realized one restriction of the promise attached to event model is that the promise can only be resolved once. For cases like wanting to request a theme from higher up the hierarchy and be able to respond to external theme changes this doesn't work so well.
You kind of want to have a single source of truth at the Datasource / ContextProvider level. Where we resolve the promise. Why would we want to resolve it twice? In our implementation we also add a method
parameter which can be used to detect if you need to change something rather than just requesting data.
If you only resolve the requested data once, how would an element become aware of a change in the context value?
In the case of a theme provided from somewhere further up in the tree, if the theme is changed, how would the component be notified? With single resolution there's no way, unless we pass something instead of the value which in turn provides the value on a subscription basis. This would then require more book-keeping on the part of the consumer to properly cleanup these subscriptions with the lifecycle of the element.
I feel like we're better off keeping this API as open as possible, rather than being pre-emptively restrictive and thus putting more burden on users of the API to define further specifications for how to interact with context values which change.
If we compare the proposal here with the React Context API that too allows multiple resolution of the value:
All consumers that are descendants of a Provider will re-render whenever the Providerās value prop changes.
I believe this ability has value, it means that component authors won't necessarily have to build up more subscriptions and do more handling for simple cases where we have a context value that is changing through the lifetime of the requesting component.
I understand traditionally in a Dependency Injection framework some have a single resolution principle, but this is not a DI framework. You could build one atop this protocol, but that is not the main goal here, this is really about allowing shortcuts for data delivery to eliminate prop-drilling.
Reading through the draft proposal again, particularly the once
option
It reminded me of addEventListener(..., ..., { once: true })
which kinda led me down an oddball train of thought...
The provider could dispatch an event to the consumer; and the consumer could add an event listener to itself -- perhaps using the "once" option to enforce that requirement where it exists.
Seems overcomplicated and probably misses the mark on some of the goals... hesitant to even mention it but š¤·
@robrez interesting idea, I'm a little hesitant to introduce more Event traffic into the mix. They're not entirely free and have a small overhead in object creation. We could achieve the same result without the extra event perhaps, but we would be paying the event creation overhead on every value update. Its likely not a big deal though, so might be worth exploring. I would like a better mechanism for enforcing the 'once' behavior.
With the recent expansion of AbortControllers to support removing event listeners, maybe thereās something to be found in leveraging those in a once
context and possibly more generally to the overall dispose()
process.
I'm excited about this proposal!
It's critical for me in that I'm designing a solution to extract media state from a media provider (eg: <video>
), and passing said state down the DOM to multiple UI component consumers. I've worked on various solutions around this and happy to chime in.
Probably goes without saying but a standard is really needed around this, so I appreciate the effort by the community š
To summarise my thoughts: I think events and callback/s is the right way to implement this feature but it's an internal detail of the context provider/consumer discovery. It's not something that should be implemented by developers at the web component design level. I can't see the point of decoupling them at that level via events, and why anyone would want to deal with naming collisions/conventions, difficulty typing the context callback and a verbose means of wiring everything up. These are just my thoughts... feel free to let me know I'm wrong or what I'm missing.
I guess I don't understand in what "context" (pun?) we're talking about this implementation. As a standard? Feature of Lit? Best practices for library authors? Or...?
I was reading the proposal by @benjamind (thanks for your time/work thus far š ), and I was just wondering what's the point of decoupling the context consumer and provider. I understand that it achieves a lower-level implementation that we can layer on top of, but I can't seem to think of why or where it would be required. This feels more like a DI solution than a focus purely on a context solution (prop drilling).
I think your proposal actually highlights the disadvantages of decoupling well but doesn't highlight advantages really. I see this section also aims to address the concerns around a createContext
implementation in which we couple the consumer and provider:
For web components this creates a centralisation issue. If all components want is a logger context, where would the logger context object be defined? Who owns that module? Will all consumers agree to depend upon it?
It's a little hand-wavy and vague to me... I'm still not seeing an issue. I don't see any advantages in introducing a namespace for the context API which will obviously lead to collisions. In the logger example what's so hard about exporting the context from the same module where the Logger
class is declared? What's a real-world example of where consumers can't agree on depending on the same module? I'm not denying it's possible existence but just curious if it's really a problem.
In the current proposal, I believe most developers will either resort to extending this solution with a createContext
like API OR maintaining a central module of context keys.
I'm also not a big fan of the idea of playing around with events to connect element/context... feels verbose, error prone and unnecessary. Simply the uncertainty of the type of object you're going to receive is bad sign to me (due to naming collisions). We can bandaid this with naming conventions but why if we can design a solution that avoids it?
properties
are declared on the LitElement
class.HTMLElement
lifecycle and we should avoid introducing new terminology or API. consume
and provide
should receive an options object which we can use to modify behaviour (eg: requesting a context value once) OR to tap into the lifecycle (ie: for updating renderer or cleanup).Here's a sample and very rough API of what I'm thinking...
export type ContextHost = HTMLElement;
export type ContextConsumerDeclaration = Context<any> | {
context: Context<any>,
// ...options
};
export interface ContextConsumerDeclarations {
readonly [key: string]: ContextConsumerDeclaration;
}
export type ContextProviderDeclaration = Context<any> | {
context: Context<any>,
// ...options
}
export interface ContextProviderDeclarations {
readonly [key: string]: ContextProviderDeclaration;
}
export interface ContextHostConstructor {
new (...args: any[]): ContextHost;
readonly contextConsumers?: ContextConsumerDeclarations;
readonly contextProviders?: ContextProviderDeclarations;
}
export interface ContextProvider<T> {
value: T;
// Better name?
reset(): void;
}
export interface ContextConsumer<T> {
readonly value: T;
}
export interface ContextLifecycle<T> {
onConnected?(): void;
// Ability to prevent update by returning false?.
onUpdate?(newValue: T): boolean;
onUpdated?(newValue: T): void;
onDisconnected?(): void;
}
export interface ContextProviderOptions<T> extends ContextLifecycle<T> {}
export interface ContextConsumerOptions<T> extends ContextLifecycle<T> {
once?: boolean;
// Not sure but someway to transform the consumed context would be handy.
transform<R>?(newValue: T): R
}
export interface Context<T> {
initialValue: T;
provide(host: ContextHost, options?: ContextProviderOptions<T>): ContextProvider<T>;
consume(host: ContextHost, options?: ContextConsumerOptions<T>): ContextConsumer<T>;
}
function createContext<T>(initialValue: T): Context<T> {
// ...
}
// Decorators
function consumeContext<T extends Context<any>>(
context: T,
options?: ContextConsumerOptions
): PropertyDecorator {
// ...
}
function provideContext<T extends Context<any>>(
context: T,
options?: ContextProviderOptions
): PropertyDecorator {
// ...
}
// Mixin to introduce the context API support. Ideally used on your base library element.
function WithContext<T extends ContextHostConstructor>(Base: T): T {
// ...
}
const context = createContext(10);
class MyProviderElement extends WithContext(HTMLElement) {
constructor() {
super();
this.context = context.initialValue;
}
/** @type {ContextProviderDeclarations} */
static get contextProviders() {
return {
context,
// OR
context: {
context,
// ...options
}
};
}
}
class MyConsumerElement extends WithContext(HTMLElement) {
constructor() {
super();
this.context = context.initialValue;
}
/** @type {ContextConsumerDeclarations} */
static get contextConsumers() {
return {
context,
// OR
context: {
context,
// ...options
}
};
}
}
const context = createContext(10);
class MyProviderElement extends WithContext(HTMLElement) {
@provideContext(context, { /** options */ }) context = context.initialValue;
}
class MyConsumerElement extends WithContext(HTMLElement) {
@consumeContext(context, { /** options */ }) context = context.initialValue;
}
Hi, I share with you @atomico/channel
, it solves the following objectives:
import { Channel } from "@atomico/channel";
const CHANNEL = "MyChannel";
// Parent channel
const parentChannel = new Channel(document.body, CHANNEL);
class MyComponent extends HTMLElement {
constructor() {
super();
// Child channel
this.channel = new Channel(this, CHANNEL);
}
connectedcallback() {
this.channel.connected((data) => (this.textContent = JSON.stringify(data)));
}
disconnectedCallback() {
this.channel.disconnect();
}
}
// Connect the channel to the native DOM event system
parentChannel.connect();
parentChannel.cast("I'm your father");
The api is minimalist.
channel.connect()
: create connection and subscription.
channel.cast()
: allows creating a new state to inherit to children
channel.disconnect()
: clean connection and subscription
Implementation example https://webcomponents.dev/edit/9X95yAWPKW0mdAg7OYZd
@mihar-22 and @UpperCod thanks both for the detailed input!
I think perhaps we are going down similar lines here. I've had more discussion with a few folks who have suggested similar approaches and I am in general agreement that tightly binding consumer and provider is the way to go now.
Please take a look at the initial implementation of the context protocol as defined in this PR https://github.com/lit/lit/pull/1955
I do also agree a decorator implementation would be desirable and is next on my list for inclusion into the lit context PR. It is however not something we should include in the protocol spec I think since it's very specifically an implementation detail that might be best tailored by libraries specific to component implementations.
I will try and find time to revise the proposal to include these changes in direction and incorporate some of the ideas in your proposals.
@mihar-22 one of the main goals of this proposal is decoupling via events, it's a core principle. Not only might providers and consumers not be directly aware of each other (ex: loggers, theming, etc.) but with web components the components might not share any implementation. Events already provide the cross-library, cross-component communication that we need for this.
The reason why this protocol is defined in terms of events is because that's the underlying mechanism. Libraries can implement the protocol and provide whatever nice interface into the system that they want, as implemented by @benjamind in lit/lit#1955.
The interfaces you have there might be ok, but at the protocol level we need to define how consumers and providers actually communicate. That's the event protocol, and once we have this, implementations can freely use any interfaces they want, include ones like yours.
Regarding createContext
, I prefer that over string-based keys, we're having a discussion in the PR here: https://github.com/webcomponents/community-protocols/pull/10/files#r622357606
Can this be used to implement constructor dependency injection? I'm not fluent enough to read the proposal or infer from the information, but a dependency injection scenario would be interesting to me. One good article on using React State API for constructor dependency injection, via Locator pattern, is at https://blog.testdouble.com/posts/2021-03-19-react-context-for-dependency-injection-not-state/ as far as I could find.
Now with reactive controllers, this injecting a HttpClient as described in the article to a repository and that repository to reactive controllers (and handling state in SQL database in browser) would look an interesting pattern to me at least.
@veikkoeeva I don't think it could be used for 'constructor dependency injection' since construction happens in the DOM for many web component usage scenarios, however, it can indeed be used to create other dependency injection patterns. We internally at Adobe have some usages where we are doing 'property injection'. Which actually is one of the most common usages of the Context API I forsee. I very much like the pattern of components having properties which can be set directly, or which can be provided via Context events being satisfied via the protocol as this pattern seems very versatile.
Now that #10 is merged, let's close this and take discussions to individual issues. Thanks @benjamind !
/me looking where to watch the discussion online on that API. (oh, right is:issue is:open context in:title
)
Most discussion should now be happening in individual issue tickets filed against the proposal in this repo.
This way people can make PRs for changes to the proposed API and we can iterate from there. It's also fine to just open issue tickets for points of discussion if there are concerns that you don't immediately have solutions for.
Most discussion should now be happening in individual issue tickets filed against the proposal in this repo.
Yes, that's right. I was scrolling around to see currently opened tickets for Context API, and I've found is:issue is:open context in:title
And you can open new issues prefixed with [context] if you have a new topic to raise.
The Lit team has been prototyping a Context-like API based on events.
The basic idea is that components that need some contextual data will fire an event to request the data. The event will carry a callback use the pass the data to the requesting component. Components that can provide the data will respond to the event and call the callback with the data. The callback can then trigger arbitrary work in the requesting component, including a re-render.
The details needed for interop are things like the event name, callback property name and signature, and any keys that are used to identify the context and or data object.