webcomponents-cg / community-protocols

Cross-component coordination protocols
179 stars 12 forks source link

[progressive hydration] self hydrating custom elements #33

Open thescientist13 opened 2 years ago

thescientist13 commented 2 years ago

Overview

As an alternative / complementary approach to #30 , I had been thinking about what it could look like if instead of the framework / runtime being the handler of the hydration, syntax, DSL or to avoid being an opinionated wrapper around Intersection / Mutations observers.

<my-element hydrate="xxx"></my-element>

What if custom elements had the opportunity to self define their own hydration logic? The premise is that a custom element would define a static __hydrate__ method (or whatever) that could be used to encapsulate its own hydration, loading, etc logic, and then the SSR framework mechanism (e.g. community protocol) would just need to extract this logic and inject that it into the runtime.

Example

Given this sample component

const template = document.createElement('template');

template.innerHTML = `
  <style>
    h6 {
      color: red;
      font-size: 25px;
    }

    h6.hydrated {
      animation-duration: 3s;
      animation-name: slidein;
    }

    @keyframes slidein {
      from {
        margin-left: 100%;
        width: 300%;
      }

      to {
        font-size: 25px;
      }
    }
  </style>

  <h6>This is a test</h6>
`;

class TestComponent extends HTMLElement {
  connectedCallback() {
    if (!this.shadowRoot) {
      this.attachShadow({ mode: 'open' });
      this.shadowRoot.appendChild(template.content.cloneNode(true));
    } else {
      const header = this.shadowRoot.querySelector('h6');

      header.style.color = this.getAttribute('color');
      header.classList.add('hydrated');
    }
  }

  // the fun stuff happens here :)
  static __hydrate__() {
    alert('special __hydrate__ function from TestComponent :)');
    window.addEventListener('load', () => {
      const options = {
        root: null,
        rootMargin: '20px',
        threshold: 1.0
      };

      const callback = (entries, observer) => {
        entries.forEach(entry => {
          if(!initialized && entry.isIntersecting) {
            import(new URL('./www/components/test.js', import.meta.url));
          }
        });
      };

      const observer = new IntersectionObserver(callback, options);
      const target = document.querySelector('wcc-test');

      observer.observe(target);
    })
  }
}

export { TestComponent }

customElements.define('wcc-test', TestComponent)

What's nice is that anything could go here since you have full access to the browser, like for IntersectionObserver, MutationObserver, addEventListener, etc. Plus, the runtime overhead is entirely sized by the user, so no extra JS gets shipped except for what the user themselves chooses to include.

So for this scenario, you could just use it as

<wcc-test color="green"></wcc-test>

and in action, it would look like this

https://user-images.githubusercontent.com/895923/166452727-8df22b47-6e34-4398-ad69-5fbc6f24653c.mov

Observations

So looking to the above recording, we can observe that we get an alert when the hydration logic runs, even though test.js has not loaded. when we scroll down to the intersecting point, test.js loads the custom element, which then initiates the color change and CSS animation.

I think what’s neat is that at a top level, you could still set attributes on static HTML, maybe to preload some data or state, if you’re already running a single pass over the HTML. So could make for a really nice combination of techniques and potentially open the door up to more complex strategies like partial hydration, or resumability, which is even nicer when you think about that you could include a <script type="application/json"> inside a Shadow DOM... 🤔

Feedback

Some good call outs so far to investigate:

  1. [ ] Attach / override a __hydrate__ method to a custom element's base class
  2. [ ] Understand the cost of Intersection / Mutation Observers (to help inform best practices, usage recommendations)
daKmoR commented 2 years ago

interesting approach - that certainly gives absolute freedom on how every component wants to be hydrated...

this flexibility is however also what I would be afraid off

what happens

hmmm I think that is more an outside concern... as a web component author I do not really need to know if I am loaded directly or only on mobile... and I certainly don't wanna write that code...

but yeah getting that freedom if needed would be nice 🤔

thescientist13 commented 2 years ago

Thanks for taking time to review! 👋

If you have 10 different components doing slightly different intersection observers (will there be many intersection observers instead of one?)

10 instances of the same custom element, or 10 unique custom elements all defining a __hydrate__ function?

If the former, then the static method is intentional to imply there would only be one IntersectionObserver for the entire custom element definition and all of its instances. I guess this would be the "soonest wins" strategy cited here. For the latter, I'm not sure how that would be different from setting it via HTML attributes + framework, or even manually wired up by the user.

That said, I'm not really sure the cost of setting up an IntersectionObserver, so that is definitely a good call out, but I think whether the author defines it or the framework defines it, the overhead would be the same right?

The advantage for this approach to the user is that this would only ship the 2,3,10 lines of code in that __hydrate__ handler. I don't have to have the framework guessing / wrapping things like event handlers and IntersectionObservers for me, and bringing all the extra code I might not need.

if I want to change how a components loads and it's not implemented by the components what could you do to solve it?

I'm not sure if it's possible, but I think maybe just decorating the base class in userland on the server could work?

import { SomeComponent } from '@some/component';

SomeComponent.__hydrate__ = () => {
  // do stuff
}

Would every component you use need to use the "same/similar" hydrate function that gets imported from somewhere central? maybe by the base library? lit could distribute a version?

Each custom element definition that would want to participate in this protocol would define its own static __hydate__ method on the HTMLElement class for its specific custom element definition.

I don't think I would want components sharing the logic of a single IntersectionObserver predicate as each custom element is likely going to want to control its own hydration behavior, but certainly nothing would stop you if that is the effect you wanted to achieve.

hmmm I think that is more an outside concern... as a web component author I do not really need to know if I am loaded directly or only on mobile... and I certainly don't wanna write that code...

Yeah, that's a good point. As a a library author, I wouldn't always know the context my component is being used in. Since custom elements are just classes, I suppose perhaps the user of the WC could just set that themselves , like in the example above re: defining a __hdyrate__ function if it wasn't present on the base class.


but yeah getting that freedom if needed would be nice

Indeed! I think at least at this stage one thing to keep in mind is, I'm not sure any of these strategies are clear cut winners at all (yet). I think the general consensus is that hydration as a technique can be improved to better take advantage of the initial server work, but how it will play out in practice and what that looks like, will take some more time. And likely, it could be a combination of strategies, and likely different depending on the scenario.

I think the best thing that can happen is to crowd-source the exploration and discovery space as much as possible to get lots of examples and possibilities out there, test them in apps, then hopefully whittle it down to an accepted community protocol(s). 🤞

matthewp commented 1 year ago

I'm a little confused by the example implementation. If the code is run in the browser and a <wcc-test> exists on the page, the connectedCallback is going to be called. The only way to delay hydration is by not loading its CE implementation.

thescientist13 commented 1 year ago

Yeah, for this kind of implementation, the SSR framework would need to statically analyze / compile the component definition and extract the logic from it, and add it to the initial HTML for the page, in an inline <script> tag.