Skip to content
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

Open
madbook opened this issue Sep 27, 2021 · 11 comments
Open

[context] supporting cases where provider is defined after consumer #25

madbook opened this issue Sep 27, 2021 · 11 comments

Comments

@madbook
Copy link

madbook commented Sep 27, 2021

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:

  1. Context-based systems where the consumers don't know what module (or the name of the element) of the provider it's looking for can't import or await the definition
  2. In some cases, we specifically want to load the consumer component with higher priority.

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.

<my-form>
  <my-input name="foo"></my-input>
  <button type="submit">Submit</button>
</my-form>

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, awaiting each custom element it encounters along the way. It looks something like this:

async function findContext<T>(
  from: HTMLElement,
  isMatch: (e: Element) => e is Provider<T>
) {
  let el = from;

  while (el.parentElement) {
    el = el.parentElement;

    // We can skip any builtin elements
    if (!el.tagName.includes('-')) continue;

    const tag = el.tagName.toLowerCase();

    if (!customElements.get(tag)) {
      // non-upgraded elements might be ancestors
      await customElements.whenDefined(tag);
    }

    if (isMatch(el)) return el as Provider<T>;
  }
}

This ensures that the context provider will get hooked up to consumers even if it is upgraded later, but it's not perfect:

  • It makes the initial registration async instead of sync; this wasn't really an issue for us but it could be for some
  • It breaks if any elements sitting between the consumer and the provider never get upgraded, as it will 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.

@justinfagnani
Copy link
Member

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 whenDefined like in your code. Then it can re-fire the event to trigger existing event handling in the upgraded elements. That should remove the need for isMatch() and any custom code at the dispatch site.

@benjamind
Copy link
Collaborator

In the @lit-labs/context implementation I have defined a ContextRoot concept, this root is a listener for context-request events and context-provider events. The context-provider events are emitted by new providers added to the DOM. When a context-request is unhandled the ContextRoot will intercept the event, save a WeakRef to the originating element, and the ContextRequestEvent payload, and then wait for a matching context-provider to be emitted. When it finds a matching context-provider it will redispatch all the context-request events it gather that are potentially satisfied by that provider from the original elements the events were dispatched from. This lets us easily and with minimal performance impact allow late providers to satisfy earlier context requests.

It does however require one addition to the ContextRequest protocol, we need the providers to also emit this context-provider event when they are available to satisfy context requests.

@dgp1130
Copy link

dgp1130 commented Oct 30, 2022

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 uses the inner-component, it's definition probably looks something like:

// outer-component.ts

import './inner-component.js';

class OuterComponent extends HTMLElement { /* ... */ }
customElements.define('outer-component', OuterComponent);

Note that outer-component imports inner-component, which is a natural ordering given that outer-component effectively depends on inner-component.

The struggle from this is that it means customElements.define('inner-component', InnerComponent) will naturally precede customElements.define('outer-component', OuterComponent). This means InnerComponent is upgraded first and even its connectedCallback() is invoked synchronously, and all that happens before OuterComponent has been evaluated.

The effect of this is that InnerComponent cannot request any context from OuterComponent during connectedCallback() and needs to wait at least one microtask before doing so in order to give OuterComponent an opportunity to provide the context.

It's possible (though inconvenient) to switch the order and make OuterComponent load first, but I argue that isn't desirable in the first place. For OuterComponent to hydrate correctly, it should be able to assume that its descendants have already hydrated as well and are in a stable state. If OuterComponent hydrates first, it generally can't make any assumptions about capabilities available in InnerComponent at hydration which breaks composition and further complicates the custom element lifecycle.

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 ContextEvent callback should be invoked synchronously, but could be invoked at any time in the future or never at all with no ability to control or cancel that request. Each component would need to write callbacks very defensively to make sure they weren't invoked at an unexpected time. Also "optional" context is out the window since you can never know that a context isn't provided.

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:

State representing the pending context requests follows the following structure:

declare global {
  interface Window {
     // States lives as a global variable.
    contextInternalState?: ContextState;
  }
}

interface ContextState {
  activeRequests?: ContextRequests;
}

type ContextRequests = Map<Context<unknown>, Set<ContextListener<unknown>>>;

interface ContextListener<T> {
  element: Element;
  callback: (value: T) => void;
}

When a component wishes to be notified on a new provider, it should add itself to the activeRequests map like so:

function waitForContext<T>(context: Context<T>, element: Element, callback: (value: T) => void): void {
  window.contextInternalState ??= {};
  window.contextInternalState.activeRequests ??=
      new Map<Context<unknown>, Set<ContextListener<unknown>>>();
  const listeners = window.contextInternalState.activeRequests.get(context)
      ?? new Set<ContextListener<unknown>>();
  window.contextInternalState.activeRequests.set(context, listeners);
  listeners.add({ element, callback });
}

When a component wishes to cancel a context request, it can remove the context from the map.

function cancelContextRequest<T>(context: Context<T>, element: Element, callback: (value: T) => void): void {
  const ctxRequests = window.contextInternalState?.activeRequests?.get(context)
      ?? new Set<ContextListener<unknown>>();
  const request = Array.from(ctxRequests.values())
      // We need to check for both element and callback because a single element could have multiple active
      // context requests (even to the same context) with different callbacks.
      .find(({ element: el, callback: cb }) => el === element && cb === callback);
  if (request) ctxRequests.delete(request);
}

When an element wishes to provide a new context it must check active requests and invoke them.

function provide<T>(context: Context<T>, element: Element, value: T): void {
  // Set up event handler to answer future context requests...

  // Answer any active requests.
  const ctxRequests = window.contextInternalState?.activeRequests?.get(context)
      ?? new Set<ContextListener<unknown>>();
  for (const request of ctxRequests) {
    if (element.contains(request.element)) {
      ctxRequests.delete(request);

      try {
        request.callback.call(undefined, value); // `.call()` to avoid leaking `this`.
      } catch (err) {
        // Log errors, but don't interrupt answering other context requests.
        console.error(err);
      }
    }
  }
}

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 WeakRef could be workable, but I'm not sure if it's strictly necessary assuming components clean up after themselves correctly. I think disconnectedCallback() would be an appropriate place to revoke any active context requests, though there may be use cases which benefit from receiving context while disconnected from the DOM (two components within the disconnected fragment could communicate after all).

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 const Map in the top-level file scope rather than an exposed global variable.

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.

dgp1130 added a commit to dgp1130/HydroActive that referenced this issue Oct 30, 2022
All the details around the struggle with the context community protocol have been shared in [this related issue](webcomponents-cg/community-protocols#25 (comment)).
@justinfagnani
Copy link
Member

@dgp1130 getting hydration to go top-down is the main purpose of the proposed Defer Hydration Protocol.

In that proposal, any element with a defer-hydration attribute will not perform the work it would usually do in connectedCalback(), including firing context events. When an element hydrates it removes the defer-hydration attribute on its shadow children which will cause the children to hydrate as they observe the attribute change.

Top-level elements can be rendered without an initial defer-hydration attribute so that they'll automatically hydrate and kick off their whole top-down subtree hydration.

@dgp1130
Copy link

dgp1130 commented Oct 31, 2022

@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 defer-hydration serving as a way to exclude a top-level "island" during initial loading. But once the island itself is hydrated, that entire subtree should still hydrate in bottom-up order, so each component has a consistent and usable view of it's children.

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 defer-hydration manually at the application level, it means that component libraries can't easily reason about when to hydrate and how timing should work.

The approach I've taken so far is that any hydrating component first hydrates its descendants (by removing any defer-hydration attributes), then hydrates itself. Maybe that's the wrong approach, but I do think it's at least a reasonable one. I also don't think we should be forced to trade that off in order to make context work well with hydration. Especially if this is something which can be supported in the context protocol itself (whether my proposal or the root event handler).

@justinfagnani
Copy link
Member

@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.

@dgp1130
Copy link

dgp1130 commented Oct 31, 2022

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.

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:

  1. Parents cannot make any assumptions about the child element during hydration. I argue even typing the child element as it's TypeScript type would be inaccurate because you have no idea if it is functional.
  2. If we rely on child elements emitting events, then this requires parents to hydrate first. This does not happen naturally (due to import ordering) and it requires the child to have defer-hydration set, a property which it has no control over. The child looks like:
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 defer-hydration despite the child component having no control over whether that attribute is set or removed by the parent.

@justinfagnani
Copy link
Member

@dgp1130

If we rely on child elements emitting events, then this requires parents to hydrate first. This does not happen naturally (due to import ordering)

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.

@dgp1130
Copy link

dgp1130 commented Nov 1, 2022

If we rely on child elements emitting events, then this requires parents to hydrate first. This does not happen naturally (due to import ordering)

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.

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 <script type="module" /> has an implicit defer attribute (with no opt-out I think?), it wasn't really a best practice to execute web component JS before the document was fully parsed anyways. I guess there might be some extreme cases where this is worth doing, but not something I've really considered a major use case.

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.

I think my point is more that the child must expect to be deferred and the parent needs to actually specify defer-hydration, since likely this attribute is not actually controlled by the child (probably, this likely depends on the SSR infrastructure actually rendering the element). This almost makes me think it would be desirable to always defer-hydration on everything (that supports it) except the top-level component which would be responsible for removing defer-hydration.

And again, this is an issue to be resolved with any event-based patterns. It should be solved broadly.

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?

@justinfagnani
Copy link
Member

There are a few reasons that I believe top-down is a required initialization/hydration order.

  • Setting up event listeners before dispatch: What we've talked about here.
  • Providing initial data from parents to children: If children initialize first, they won't have their properties set by parents. They will have to resort to using default initial values, which will then be overwritten when parents hydrate, which repeats for each level of the tree resulting in an unnecessary cascade of property assignments and re-renders.
  • Lazy initialization of subtrees: you need the parents to be in control of subtree upgrade/hydration by default so they can defer the entire tree. There are cases where child components will want to hydrate independently (like in Wiz) but these are a special class of components - they must have all of their data available even if their parents aren't hydrated, either by being serialized to attributes or by being provided by some out-of-tree data source.

I think my point is more that the child must expect to be deferred and the parent needs to actually specify defer-hydration, since likely this attribute is not actually controlled by the child (probably, this likely depends on the SSR infrastructure actually rendering the element).

Yes, and the @lit-labs/ssr package coordinates this by putting defer-hydration on every custom element, except (by default, it's configurable) the top level elements. This is easy enough to do by maintaining a stack of open custom elements. Then the LitElement class adds defer-hydration to its observed attributes and hydrates when it's not present. During hydration, LitElement removes the defer-hydration attribute on children. Since the top-level elements do not have a defer-hydration attribute, they hydrate automatically, kicking off the top-down order chain reaction.

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?

This is exactly what the defer-hydration protocol is for. It's a broad solution to the initialization order problem.

@dgp1130
Copy link

dgp1130 commented Nov 5, 2022

(Apologies for the delay, had a bit of a busy week.)

There are a few reasons that I believe top-down is a required initialization/hydration order.

  • Setting up event listeners before dispatch: What we've talked about here.

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.

  • Providing initial data from parents to children: If children initialize first, they won't have their properties set by parents. They will have to resort to using default initial values, which will then be overwritten when parents hydrate, which repeats for each level of the tree resulting in an unnecessary cascade of property assignments and re-renders.

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 <span>5</span> should be all the information necessary to hydrate and no JSON side-channel should be required. If we think of my-counter as hydrating from its content in this manner, then this is actually a bottom-up flow. If a wrapper component wants to compose this behavior in its own hydration logic, such as displaying a derived count which increments every time the user clicks, then it needs the inner component to hydrate first.

<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:

  • Lazy initialization of subtrees: you need the parents to be in control of subtree upgrade/hydration by default so they can defer the entire tree. There are cases where child components will want to hydrate independently (like in Wiz) but these are a special class of components - they must have all of their data available even if their parents aren't hydrated, either by being serialized to attributes or by being provided by some out-of-tree data source.

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 defer-hydration set. That seems to me like the primary motivation for defer-hydration.

defer-hydration also naturally supports passing props down the hierarchy, since the child won't activate until the parent has an opportunity to set any input property and remove defer-hydration. I do see an argument that it shouldn't be necessary to defer hydration to pass down props (an argument I'll make in a moment). I personally see that as a slightly different problem than hydration, but I'd rather not get side-tracked on that as I don't think it's relevant to the context proposal.

I think my point is more that the child must expect to be deferred and the parent needs to actually specify defer-hydration, since likely this attribute is not actually controlled by the child (probably, this likely depends on the SSR infrastructure actually rendering the element).

Yes, and the @lit-labs/ssr package coordinates this by putting defer-hydration on every custom element, except (by default, it's configurable) the top level elements. This is easy enough to do by maintaining a stack of open custom elements. Then the LitElement class adds defer-hydration to its observed attributes and hydrates when it's not present. During hydration, LitElement removes the defer-hydration attribute on children. Since the top-level elements do not have a defer-hydration attribute, they hydrate automatically, kicking off the top-down order chain reaction.

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 - is probably a custom element).

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 defer-hydration behavior on the server, so that particular solution isn't available to me. Users would have to write their server to manually specify defer-hydration for every component or implement some custom tooling to do that by 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 defer-hydration by default is more of a tooling / framework design problem. Maybe this is just a flaw in my particular approach of going client-side only. However, I do think the fact that the natural timing of the web fails to interoperate with this protocol context is a design flaw in the protocol itself. This is particularly true given that community protocols should be amenable to many different approaches and implementations of component design. Maybe we can choose to tackle that flaw with other means, but it is still an issue here. Which actually segues nicely into:

This is exactly what the defer-hydration protocol is for. It's a broad solution to the initialization order problem.

I do agree that if you use defer-hydration then context proposal as is works fine with hydration. I think my core argument is that we shouldn't have to defer hydration to use context effectively.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants