This issue is fundamentally similar to #39 in that the author of that issue and I both agree that the protocol should be more event-driven, but we have very different ideas of how to go about it. In order to motivate my idea, I need to outline the problems I think it would solve. Most of this is me outlining the problems I think currently exist, and given there could exist alternative solutions to my concerns, I thought it apt to make a separate issue about the concerns and then also propose a solution.
Concerns about the current design
The callback doesn't make sense if the consumer isn't subscribing
If a consumer wants to synchronously request a context value one time, they have to do something like this:
getContextValue(context) {
let value;
this.dispatchEvent(
new ContextEvent(
context,
(providedValue) => {
value = providedValue;
},
),
);
return value;
}
We know, with knowledge of the protocol, that this works, given that a provider will immediately call the callback, but it's actually not obvious from reading the code that this works; it's actually surprising that this works.
I also think it's just awkward. I suggest that the reason it's awkward is because a callback is simply an incorrect representation of the semantics. It's a callback that will be called, synchronously, only once; I don't think it makes sense.
The biggest problem with this, and possibly the biggest problem with the whole design, is that a provider can call a consumer's callback multiple times when the consumer only expects it to be called once. The proposal directly grapples with this, introducing the idea that providers are capable of being 'bad actors' and that consumers can be 'defensive'. I feel strongly that the fact this is even possible is not a necessary trade-off, but a flaw in the design; it doesn't make any sense that this is possible.
Another problem is that it's possible for the provider to cause a memory leak by retaining a consumer's callback when the consumer did not expect it to be retained. The proposal discusses this, of course, but I think it just shouldn't be possible.
The primary advantage I can see of using the callback for this in the current design is that it means the provider provides the value to the consumer in the same way regardless of whether the consumer is subscribing, but I think there's a better solution.
The current approach to unsubscribing is odd
In order to unsubscribe at a time of its choosing, the consumer has to fish the unsubscribe
function out of the callback in the same way they have to fish the value out of the callback, which is awkward. I, again, think it's awkward because the consumer unsubscribing in this way is not a good representation of the semantics.
We want to return values to the consumer, but we also want to return an unsubscribe function to the consumer. The callback seems like a convenient way to do this, but in fact, it overloads the callback with responsibility; providing the value and providing the unsubscribe function are distinct responsibilities.
I think that consumers should unsubscribe using an AbortSignal
. This would not only be a better representation of the semantics and a better separation of responsibility, but this approach is already in use with EventTarget
and it would be good use of built-in APIsโwhich, after all, is what web components are about.
I note that it would be possible to allow this by having the consumer attach an AbortSignal
to the context-request
event within the constraints of the current design, but
- Then there would be three properties that are all tied to subscriptions, the
subscribe
flag, the callback, and the signal.
- The provider would be expected to test whether the signal is already aborted before they begin providing the context.
As an aside: does the provider pass the unsubscribe function every time they call the callback, or just the first time? The proposal doesn't actually say. If it has to be passed every time, I think that's undesirable for both the provider and the consumer.
How does a consumer know whether there exists a provider?
From what I can tell, if a consumer wants to know whether there exists a provider, the consumer needs to do something like this (which I note is basically the same as the code above):
doesProviderExist(context) {
let providerExists = false;
this.dispatchEvent(
new ContextEvent(
context,
() => {
providerExists = true;
},
),
);
return providerExists;
}
This is awkward and confusing for the same reasons as above.
The proposal does not address how a consumer can detect whether a provider exists, which, to me, seems extremely important: a consumer requests data but has no idea if they'll ever receive it. Surely the consumer would want to handle the case where they won't.
My idea
I really like the idea of common, community context protocol, and I want this initiative to succeed, but I have serious reservations with it as it is currently specified. Here is my idea for an alternative design:
The provider implements a getContext(context)
method that returns the value of the specified context in the provider, and also emits a context-change
event (that doesn't bubble) whenever the value of one of the contexts it provides changes.
When a consumer requests a context, they emit a context-request
event that bubbles up the DOM in order to 'discover' whether there exists a provider for the specified context. If a provider can provide the specified context, it stops the propagation of the event and sets itself as a property on the event (event.provider = this
). This is similar in many ways to the existing context-request
event described, but achieves something fundamentally different.
After the consumer emits the event, they can retrieve the provider from the event and handle the case where no provider exists. If the provider exists, they can call provider.getContext(context)
to retrieve the value of the context. That's it. If the consumer wants to subscribe to the context, they can call getContext()
to get the value of the context at the time of subscription, and then attach a context-change
listener to the provider to become aware of any future values. My instinct is that event should only specify the context that changed, and not its new value, because this would give the consumer more than one correct way of retrieving the value (getting it from the event or getting it from the provider directly).
I think this is an overall simpler design that also addresses the flaws in the current design. The consumer easily detects whether a provider exists, and can retrieve a context once without any unnecessary complexity or pitfalls. If a consumer optionally wants to subscribe to a context, they can do so by attaching event listener, without concerns about how the provider will call the listener, whether the provider will retain it, how they will extract values from the callback, or how they will unsubscribe. I believe that this design would address the problems with the proposed solution in #39 while ultimately addressing the concerns originally raised in the issue. It would also comprehensively solve #21, which I feel still has not been genuinely solved.
Funnily enough, the proposal actually mentions a potential "alternate API" in which consumers emit an event to discover providers, but does not contemplate any reason why this might be advantageous other than type-safety concerns, which the proposal (in my opinion) rightly dismisses. From my perspective, this is a little frustrating.
Trade-offs
One trade-off with this design is that the consumer gains a reference to the provider, thereby gaining at least some knowledge of who the provider is. However, I think this design actually decreases coupling between the provider and consumers. In the current design, providers are forced to be aware of consumers and to deal with their callbacks. In my proposed design, providers have literally no knowledge of consumers. In TypeScript, the type of the provider would be represented in such a way that the consumer could not become coupled to the provider.
Another trade-off is that providers would need to specify in their context-change
events what context changed, and consumers would need to inspect the event to verify that it's actually relevant to them. In the general case, just as most context-request
events are not relevant to any given provider, most context-change
events would not be relevant to any given consumer.
Example TypeScript
type ContextKey<Value> = (string | symbol | object) & { __context: Value };
type ContextValue<T extends ContextKey<unknown>> = T extends ContextKey<infer Value> ? Value : never;
type ContextProvider = {
getContext<T extends ContextKey<unknown>>(context: T): ContextValue<T> | undefined;
addEventListener(type: "context-change", listener: (this: ContextProvider, ev: ContextChangeEvent) => any, options?: boolean | AddEventListenerOptions): void;
removeEventListener(type: "context-change", listener: (this: ContextProvider, ev: ContextChangeEvent) => any, options?: boolean | AddEventListenerOptions): void;
};
class ContextRequestEvent<T extends ContextKey<unknown>> extends Event {
context: T
provider?: ContextProvider
constructor(context: T) {
super("context-request", {
bubbles: true,
composed: true,
});
this.context = context;
}
}
class ContextChangeEvent extends Event {
context: ContextKey<unknown>
constructor(context: ContextKey<unknown>) {
super("context-change");
this.context = context;
}
}
interface HTMLElementEventMap {
"context-request": ContextRequestEvent<ContextKey<unknown>>;
}
I've taken some liberties in how I've written this that aren't necessarily important. One issue I have with the code in the proposal is that createContext
is a JavaScript function that exists only for TypeScript purposes; it returns a nice generic type, but it doesn't actually do anything. The Context
type in the proposal requires us to specify the type of the key even though we don't actually care what its type is; we need to specify the type of the key just so that the key is assignable to the type of the context e.g. "my-context as Context<string, number>
. If we were to restrict the type of keys ahead-of-time, even with a very loose restriction like string | symbol | object
, specifying the type of the key becomes unnecessary e.g. "my-context" as ContextKey<number>
.
Closing thoughts
I don't know whether any of this alternate design is actually of any use. Given that the proposal is now candidate statusโas of, apparently, two weeks agoโit probably can't be redesigned in a meaningful way at this point, which is disappointing. I've only just discovered the WCCG and this proposal recently; if I had found this earlier, I would have participated earlier.
Regardless, I think the issues I've described are of real concern, and I would be keen to try and address these issues in any way that is feasible.
I'm very keen to hear any thoughts anyone has.