-
Notifications
You must be signed in to change notification settings - Fork 12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[context] supporting cases where provider is defined after consumer #25
Comments
I think that this use case is probably best served by a generic buffering provider that listens for context events at the top of the tree - so it only gets them if they're otherwise unhandled - then waits for some elements to be defined, either by configuration or with a walk down the event path waiting on |
In the @lit-labs/context implementation I have defined a It does however require one addition to the |
I wanted to add one more motivating use case from element hydration. Consider two elements composing each other: <outer-component>
<template shadowroot="open">
<inner-component></inner-component>
</template>
</outer-component> Because the // outer-component.ts
import './inner-component.js';
class OuterComponent extends HTMLElement { /* ... */ }
customElements.define('outer-component', OuterComponent); Note that The struggle from this is that it means The effect of this is that It's possible (though inconvenient) to switch the order and make All this is to say that I think it would be really valuable to have some means of registering a context request and then having it fulfilled by a component registered afterwards, and it makes hydration work much more smoothly with this context proposal. As to the proposed alternative: My concern is that it assumes there is some global context handler which is responsible for catching and re-dispatching context requests. It also assumes this handler is loaded before all components on the page to receive any too-early context requests. How would you actually design components to be compatible with this approach? It seems unreasonable to me that a These nuances are especially tricky when writing a custom element library which needs to play well with other libraries and without application knowledge. As an alternative proposal, I was thinking we need some mechanism for context requests to register that they would like to notified when context is ready, and then providers need to send that notification when they start providing. The challenge here is that it requires some state to be maintained between the context request and the context provision. We could specify that state and its layout as a global or other shared reference, then require that any new providers check and invoke that state. Some rough wording for this might look like:
This isn't ideal as the implementation gets non-trivial (though not much more complicated than it already is IMHO). This repo could definitely provide a reference implementation for this and possibly publish it. It sucks to have to expose this much internal state, but I think it can at least work without having some kind of global event handler and still provide the flexibility necessary for components to intelligently use it. I didn't use any weak references here because the provider needs to be able to get access to the requesting element without prior knowledge to check if it is a descendant. I think a I do have an approximate implementation of this proposal here, though this was written as a standalone library without interoperability as a concern. So the state is just a This proposal feels ugly even to me, but I think it at least solves the problem at hand without requiring a singular global handler which I don't think is feasible in the community protocol context. |
All the details around the struggle with the context community protocol have been shared in [this related issue](webcomponents-cg/community-protocols#25 (comment)).
@dgp1130 getting hydration to go top-down is the main purpose of the proposed Defer Hydration Protocol. In that proposal, any element with a Top-level elements can be rendered without an initial |
@justinfagnani, I've experimented with that proposal as well, however I don't think it's the right solution to this problem. When two nested components are hydrating, the internal state of the inner component is abstracted away from the outer component, both in design and practically via tools like declarative shadow DOM. One component should not reach into the internals of another, meaning that any state in the inner component can only be accessed if it hydrates first and then passes that state to the outer component (via a property, attribute, event, etc.) As a result it is often necessary for an inner component to hydrate before the outer component can hydrate. This led me to the conclusion that inner components should hydrate first, with Requiring an consumer component to defer hydration seems like an unnecessary trade-off to receive context at hydration time. It's also circular given that an outer component may want to read hydrated state from a subcomponent in order to provide context for other subcomponents. While you could manage The approach I've taken so far is that any hydrating component first hydrates its descendants (by removing any |
@dgp1130 Yes, the outer component should not be trying access internal data of the child components, but this wouldn't normally happen anyway as the tree structure and rendering order should match what client-side rendering would have produced, and that's the parents rendering before the children. With imperatively shadow roots it's not possible for the children to be created before the parent. I don't see why children hydrating first is good. If a child needs to provide data to a parent it should so so via an event or a callback property. For events, you need to be able to set up event listeners before possible event dispatchers, which requires the parents upgrading first. For callbacks it could work either way, but works quite nicely if the parent assigns the callback before the child hydrates and calls it. The reason that I think requiring children to wait for their parents to be ready is the right approach for context is that it's also the right approach for any event-based protocol. I'd rather have a general solution than carve out something specifically for context. |
I agree that events in particular are the precedent for sending data up the DOM tree. As a matter of encapsulation, child components generally should not have knowledge of their parents, and events are a great way of making that work. The mechanism I landed on was the child element simply exposing a public property read by the parent element. This requires the child to hydrate and initialize first, while the parent reads this property during the parent hydration. It might be less idiomatic than events, but I do think it's at least a reasonable approach. My concern with parents hydrating first is twofold:
class MyComponent extends HTMLElement {
connectedCallback(): void {
// Emit data for parent component. Parent won't
// receive this by default, but it can if I'm l
// prerendered with `defer-hydration` and then
// removed by the parent component so it has time
// to set up listeners to receive this data.
this.dispatchEvent(new Event(/* ... */));
}
} Basically I'm concerned that if we rely on events to send data out of a child component, this does not work by default and it requires |
This does happen naturally in client-side rendering, which we're trying to make SSR coherent with. In fact, there's no other (straightforward, at least) way for it to work - since the parent creates the child, the parent must exist first, and therefore the parent has the chance to set up event listeners, etc. Yes, the child must opt-in to defer-hydration, but they must also opt-in to SSR and hydration in the first place, so don't think this is a huge lift. And again, this is an issue to be resolved with any event-based patterns. It should be solved broadly. |
I'm curious how you arrived at the approach that because CSR works top-down, SSR hydration should also work top-down? I can see a certain amount of logic in that, but I don't think I'm fully understanding how or why it's beneficial for those to align? I could see an equally compelling argument that since hydration is fundamentally initializing JS state from HTML content (as opposed to rendering DOM from existing JS state), it could make just as much sense to run in reverse and go bottom-up. The one concrete point I can think of is that streaming HTML also goes top-down which would imply that a parent element could upgrade and hydrate before all its children are even parsed, which is a whole different class of hydration problems to deal with. I looked into this a bit a while ago, but figured that since
I think my point is more that the child must expect to be deferred and the parent needs to actually specify
Are there any such proposals or discussions which you think could lead to a more general solution? If you don't think this is a problem which should be solved by the context community protocol, then where do you think it should be solved? |
There are a few reasons that I believe top-down is a required initialization/hydration order.
Yes, and the
This is exactly what the |
(Apologies for the delay, had a bit of a busy week.)
Agreed this is convenient and ideal for event listeners. We don't get this naturally with a bottom-up approach, and that is definitely unfortunate. I'm hoping to expand this protocol to support context in a bottom-up hydration approach. If we come to the conclusion that hydration should always be top-down then I think that motivation goes away, though I'm not yet convinced of that.
I think this makes sense only if you think of hydration as passing data top-down. In many frameworks (I'm not familiar with Lit's SSR implementation) top-level props are passed as serialized JSON and then the application is re-rendered top-down. In such a situation, I think going top-down makes a lot of sense because the source of truth is at the top of the DOM hierarchy in the form of the serialized props. The form of hydration I'm specifically experimenting with is hydrating from content in the actual rendered HTML. For example, I would like to write: <my-counter>
<template shadowroot="open">
<div>The initial count is <span>5</span>.</div>
</template>
</my-counter> /** Hydrates the current count and exposes it as a public property. */
class MyCounter extends HTMLElement {
public count!: number;
// How this is called is left as an exercise to the reader.
onHydrate(): void {
// Read the count from the existing span.
this.count = Number(this.shadowRoot!.querySelector('span')!.textContent);
}
}
customElements.define('my-counter', MyCounter); The <my-wrapped-counter>
<template shadowroot="open">
<!-- <my-counter /> rendered here. -->
<div>The current count is <span>-</span>.</div>
<button>Increment</button>
</template>
</my-wrapped-counter> class MyWrappedCounter extends HTMLElement {
private count!: number;
// How this is called is left as an exercise to the reader.
onHydration(): void {
// Read count from the inner counter's property. This *requires* inner counter to hydrate first.
const innerCounter = this.shadowRoot!.querySelector('my-counter')!;
this.count = innerCounter.count;
// Hydrate this component's rendered DOM with state from children.
this.shadowRoot!.querySelector('span')!.textContent = this.count;
// Bind event listeners and update over time.
this.shadowRoot!.querySelector('button')!.addEventListener('click', () => {
this.count++;
this.shadowRoot!.querySelector('span')!.textContent = this.count;
});
}
}
customElements.define('my-wrapped-counter', MyWrappedCounter); In this example, the natural and desired ordering is to hydrate bottom-up and the parent's hydration logic is able to leverage and compose an already fully hydrated inner component. I do admit there are times where having the parent go first can be useful (binding event listeners and passing down props like you mention). Which leads into the next point:
I agree this is a special class of component. If you have a parent component which dynamically hydrates child components under custom conditions, then I think it totally fair to require child components have
Interesting, sounds like Lit basically changes the default from what the web actually has as the default (not saying that's wrong, just an observation). My particular use case very deliberately separates the client and server implementation, where your server just renders some HTML, exactly how it does that is a completely unrelated implementation detail. The server could use any language, any framework, or even intermix different components. There's no special knowledge built-in to the server, even about which elements are actually web components (aside from guessing that everything with a Afterwards, my component library in the client makes it easy to hydrate rendered content from the DOM intro JS state. Since my approach is completely client-side only, I don't have a hook to change the default I've linked to it a couple times, but the component library I'm working on is https://github.com/dgp1130/hydrator/. I tried not be too explicit about it before just because it isn't really documented yet, but I just added a README with more of the context and motivation for the project, so maybe that helps a bit. In fairness, setting
I do agree that if you use This is getting a bit off topic from context and focusing more on hydration, so I apologize if this is a digression, hopefully this is still a useful discussion for everyone else. Definitely appreciate your insight @justinfagnani and I'm always interested to hear how Lit tackles these kinds of problems. |
Hey all, first time posting here. I'm very happy to find the context proposal here as it's a pattern I've needed several times since we've started working on a web component library. I wanted to raise an issue we've run into using events to implement a context API just as a data point to consider.
When we first ran into the need for this, we started off with a very similar approach; events with callbacks dispatched by consumers. This generally works really well, but it has one requirement that ended up biting us in a couple situations: provider components generally must be defined before consumer components. If consumer components are defined first, it's possible for events dispatched by the consumers to bubble up through the provider component before it's upgraded.
Many times this isn't a problem; you can have your consumer component import the provider component (or
await customElements.whenDefined(...)
, but we have a couple of cases where it became an issue:The second case there is a little less obvious, so here's an example from our component library; we have a custom form component (e.g.
my-form
) that is essentially a<form>
element with a bunch of additional features. One of the things it does is allows other custom elements the ability to hook into form submission and validation via a context API, so you can build custom inputs and various other features that hook into form state.In this scenario, the issue for us was performance optimization: our form component is fairly heavy, but it doesn't have any styles or markup (so it doesn't affect paint at all). Conversely, the components that consume its context API tend to be smaller, but do tend to impact layout/paint. If we're trying to optimize for first paint, it makes sense to load the form component itself after these child components, but doing so breaks the event-based context registration.
The solution we came up with was to replace the event dispatch with a utility that asynchronously crawls up the DOM tree from a consumer,
await
ing each custom element it encounters along the way. It looks something like this:This ensures that the context provider will get hooked up to consumers even if it is upgraded later, but it's not perfect:
await
indefinitely on each undefined custom element it encounters while crawling up the tree.This solution is working for us for now, but I'd love to settle on something a bit more inline with what other folks are doing if it can work for these sorts of use cases.
The text was updated successfully, but these errors were encountered: