ProjectEvergreen / greenwood

Greenwood is your full-stack workbench for the web, focused on supporting modern web standards and development to help you create your next project.
https://www.greenwoodjs.io
MIT License
94 stars 9 forks source link

Resumability #1207

Closed dgp1130 closed 2 months ago

dgp1130 commented 4 months ago

Type of Change

Feature

Summary

Could Greenwood support resumability?

Details

I was recently looking into resumability in the context of HydroActive, but ultimately decided it wasn't really a good fit, as it requires a certain level of client/server integration HydroActive is too decoupled to achieve. However, as I started to think about how it could work for custom elements with defer-hydration support, I suspect Greenwood might be a better fit for this idea. It might be a little early for this discussion as I don't think you've looked into hydration much so far, but I think it's a cool idea and I wanted to write it down. Apologies for the length of this issue. 😅

TBH, I'm still not sure I'm totally following all the concepts of resumability and what it's key requirements are, but the main feature I think it provides is deferring download of event handlers until those events trigger. Is it possible to achieve this with custom elements in Greenwood?

I think the answer might be yes. We need a few things to do this:

  1. A mapping of component tag name to the URL of the JavaScript file which defines that component.
    • Ex. "my-component can be defined by importing ./src/components/my-component.js."
  2. A mapping of elements and event names to the custom element which will respond to that event.
    • Ex. "When the click event triggers on button#my-button, please load my-component#1234 which will respond to that event."
  3. A global event handler to catch all events through bubbling.
    • Ex. "When any click event occurs on the page..."
  4. A mechanism for dynamically loading the component which will handle any specific event captured by the global handler.
    • Ex. "Load any components which cares about click events from button#my-button."

Let's break down each one.

Component definition map

The first problem is to map component tag names to the URLs which can define them. I think this can be solved with WCC or a separate build-time tool. WCC can identify all the custom elements in a compilation and link to the path which will load them at runtime. In a bundled application, each component would need to be an entry point and this would need to link to the chunk which defines the component (./chunk-abc123.js). This should be able to generate a map of custom elements to a JavaScript file you can import which will define that element.

{
  "my-component": "./src/components/my-component.js"
}

The challenge here is that I think this needs to be done at build time and integrate with any bundler (pass each component as entry points and track the output mapping). That means that WCC needs to work statically on the application source code, which I don't think is how it works today. I suspect this might be as simple as grepping for customElements.define('my-component', /* ... */); and then mapping my-component to the file you find that define in. However there might be edge cases to deal with here. Maybe it makes sense for a different build-time tool to do this?

Component event map

The second problem is to map the pair of event target (HTMLButtonElement) and event name (click) to the custom element (MyComponent) which will handle that event. This can be addressed through a monkey-patch of the server runtime. We can define a patch of EventTarget.prototype.addEventListener which is functionally a no-op (because most events can't really trigger in an SSR context) but which records all calls to it. This record is serialized into the output HTML by tracking the custom element which is currently rendering, and the event it wanted to listen to.

From the user's perspective, they might write this component:

class MyComponent extends HTMLElement {
  connectedCallback(): void {
    this.innerHTML = `
     <div>The count is: <span>5</span>.</div>
     <button>Increment</button>
    `;

    this.querySelector('button')!.addEventListener('click', () => { /* ... */ });
  }
}

What this says is that "the currently rendering MyComponent element cares about the click event of this HTMLButtonElement". Greenwood would track this at runtime and render out the content:

<!-- `data-greenwood-id` a unique identifier of this element. -->
<!-- `data-greenwood-specifier` is the specifier which can import the definition of this element. -->
<!-- Optionally could consider adding `defer-hydration` by default? -->
<my-component data-greenwood-id="1234" data-greenwood-specifier="./src/components/my-component.js" defer-hydration>
  <div>The count is: <span>5</span>.</div>

  <!-- Encodes which element is listening to which event. If multiple events, separate by comma. -->
  <button data-greenwood-handlers="1234:click">Increment</button>
</my-component>

This can be implemented through a monkey patch of EventTarget.prototype.addEventListener which looks like this:

EventTarget.prototype.addEventListener = function (this: EventTarget, event: string): void {
  if (!(this instanceof Element)) return; // Ignore events on non-elements.

  // Get the host element which is adding this listener.
  const host = getCurrentlyRenderingElement();
  const element = this;

  // Serialize an ID of the host registering this listener and the event it is listening for.
  element.setAttribute('data-greenwood-handler', `${getOrGenerateId(host)}:${event}`);
};

// Whenever an element renders via WCC, add it to this stack and remove when it's done.
const renderingElementStack: Element[] = [];
function getCurrentlyRenderingElement(): Element {
  if (renderingElementStack.length === 0) throw new Error('...');
  return renderingElementStack.at(-1);
}

let currentId = 0;
function getOrGenerateId(el: Element): number {
  // If we already have an ID for this element, use it.
  const existingId = el.getAttribute('data-greenwood-id');
  if (existingId !== undefined) return existingId;

  // Generate new IDs by just counting.
  const newId = currentId;
  currentId++;

  // Add the ID to the host element.
  el.setAttribute('data-greenwood-id', newId);

  return newId;
}

This solves the second problem by serializing the information of which components are interested in handling events from which elements. data-greenwood-specifier comes from the mapping we generated in step 1 by looking up the my-component tag name of the currently rendering element.

Global event handler

The third problem is much more straightforward, we need to listen for any event which might require a component to be downloaded.

<script>
const events = [ 'click' ]; // Generated by list of all tracked events.
for (const eventName of events) {
  document.body.addEventListener(event, (event) => globalHandler(event, eventName));
}
</script>

This script needs to be synchronous so we don't miss events triggered by the user before the global event handler is loaded. Don't worry, it will be a small script and shouldn't have a significant performance impact.

Dynamically load components

Fourth, we need to implement this global handler such that it identifies the component which should have received the event, dynamically loads that component, and then replays the event.

interface Handler {
  hostId: number;
  eventName: string;
}

function globalHandler(evt: Event, eventName: string): void {
  // Find all the handlers on the event target.
  const handlersAttr = (evt.target as Element).getAttributeName('data-greenwood-handlers')!;
  const handlers: Handler[] = handlersAttr.split(',').map((handler) => {
    const [ hostId, eventName ] = handler.split(':');
    return { hostId, eventName } satisfies Handler;
  });

  // Look for a handler which matches this event.
  const activatedHandler = handlers.find((handler) => eventName === handler.eventName);
  if (!activatedHandler) return; // Event did not match a handler.

  // Find the element with the ID which matches this handler.
  const host = document.querySelector(`[data-greenwoord-id]="${activatedHandler.id}"`);
  if (!host) throw new Error('...');

  // Get the specifier for loading that component.
  const specifier = host.getAttribute('data-greenwood-specifier');
  if (!specifier) throw new Error('...');

  (async () => {
    // Dynamically load the specifier. This should define the host element which cares about this event.
    await import(specifier);

    // Maybe validate that the element was upgraded as expected?

    // Hydrate the element if it wasn't already hydrated.
    host.removeAttribute('defer-hydration');

    // Replay the event since the component missed it.
    evt.target.dispatchEvent(evt);
  })();
}

This would trigger the addEventListener callback and increment the count on MyComponent!

I think this would roughly work. It would download the correct component, define it, hydrate the element, and then replay the event and respond to it. Yet all of that work is done lazily. Only the global event handler needs to be loaded eagerly, and that doesn't depend on any component JavaScript, so it should be a fairly constant size, no matter how large the application actually is.

With defer-hydration you get the additional benefit that if my-component catches an event, is downloaded, and then hydrated, other my-component elements can still be deferred and only lazily hydrated by the global event handler.

The biggest challenge that I've skirted over here is mapping the event handler to the host which registered it. EventTarget.prototype.addEventListener doesn't actually have enough information to do that. It doesn't know which component created the event handler. The best approach I can think of is to track the currently rendering component stack (something Greenwood might already do?). If we have a stack of components which are actively rendering, then we know the last component in the stack has control right now, so an addEventListener call should be associated with that component.

I'm not sure that's a great model and it would probably fall apparent for async operations. IIRC, Lit batches DOM updates and applies them asynchronously. If that includes adding event listeners, then I'm not sure how you'd associate the addEventListener call with the component which Greenwoord rendered in a different async stack frame. You'd need something like async context or Zone.js to do that. Maybe AsyncLocalStorage would be a way to do this today in Node, but I suspect other cloud provider runtimes don't support this particular feature.

I'm taking a few other liberties here in the implementation which would need to be ironed out:

I'm not sure if this is technically "resumability". Probably Misko is the only one who could definitively say that. It does defer event handler loading until after the events which trigger them, which I think is the main functional requirement. Where it might fall short is that you can't load and execute individual event handlers within the same component. You can only load a single component and all of its event handlers at minimum. In a web components world, I think that's perfectly fine, but I don't know if event handler specificity is a strict requirement for resumability. Also the intent of "resuming execution by not repeating any work the server already did" isn't exactly followed since the component will need to re-render itself (unless it support some kind of hydration/resumability mechanism itself). As such, this is maybe more "resumable loading" rather than "resuming execution".

Anyways, I think this could be a cool feature which could potentially work with any web component implementation and also benefit from the client/server integration which Greenwood provides. Hope this is an interesting idea to explore once you start diving into hydration!

thescientist13 commented 2 months ago

Thanks for raising this @dgp1130 though I think for now this may better fit into the Discussion format, to help thread out the various topics and proposals, but also since I think it will take a little time for the project to get to this point timeline wise. My only familiarity / naiveté on this topic from an implementation perspective comes is my awareness that from a userland perspective developer would need to de-mark the serialization points using something special like $component to define their component definitions, events, etc.

I do have some work on started on using an optional JSX based render function so maybe within those boundaries we could extend the semantics a bit to try things out, but I think this JSX feature (and myself lol) need to mature a bit on these more advanced loading strategies. 😅


Would definitely be open to seeing if there's any viability in making this a WCCG community protocol as well. Some initial conversations in this sort of advanced rendering / hydrating / loading have popped up in this issue - https://github.com/webcomponents-cg/community-protocols/issues/30. (apologies if I may have alreay shared this one with you before)