Matsuuu / web-component-devtools

Web Component DevTools is a Browser Extension enhancing the development experience of Web Component developers
https://matsuuu.github.io/web-component-devtools/
MIT License
131 stars 3 forks source link

[Feature Request]: Visualize hydration state #67

Open dgp1130 opened 11 months ago

dgp1130 commented 11 months ago

It would be very cool for WCDT to visualize which components on the page were hydrated and which ones were not, perhaps by putting colored borders around custom elements in the page, with different colors for different hydration states. This would help developers understand when and where their page is hydrating and give better insights into page performance. It can also help devs fix bugs caused by components not hydrating, since it would help easily identify which components failed to hydrate as expect. An extra feature could be denoting (whether in color or another means) why a component has not hydrated. Is it because the component is not defined, or because hydration was deferred?

This is most useful for pages using an "islands" architecture, where there are multiple components which can independently hydrate based on their own defined conditions.

Unfortunately there is no browser spec for what "hydration" means or how to identify hydrated and dehydrated components. However, there is a proposed community protocol which specifies defer-hydration as an attribute which is used to prevent hydration on specific components. I think the right behavior here is to defined "hydrated" elements as those which are 1) defined and 2) are missing defer-hydration. By contrast, "dehydrated" elements are either 1) not defined or 2) have defer-hydration set as an attribute.

Based on this, the feature can likely be implemented by injecting two styles into the page:

/* Highlight hydrated elements in red. */
:host-context(.wcdt-show-hydration-state) :defined:not([defer-hydration]) {
  border: solid 1px red !important;
}

/** Highlight dehydrated elements in green. */
:host-context(.wcdt-show-hydration-state) :is(:not(:defined), [defer-hydration]) {
  border: solid 1px green !important;
}

Which color should indicate which state is a UX choice I don't feel that strongly about. I'm personally of the opinion that every hydrated element has a negative cost to the page and we want to defer or avoid hydrating as many components as possible for as long as possible, and therefore hydrated components should be red to signify that. I get that others will likely disagree with that assessment. However I also believe code diffs should have added code in red and removed code in green, so clearly I'm insane.

Unfortunately this CSS is a bit oversimplified because:

  1. Shadow DOM exists, so these styles need to be in every shadow root.
  2. Pretty much every element on the page will match :not(:defined), meaning they will all be highlighted green, which would be far too noisy. To my knowledge, there is no selector which restricts itself to custom elements such that you could highlight my-component but not highlight div. There are some potential workarounds, but they are not pretty.
  3. If a component has defer-hydration removed and hydrates itself, then has defer-hydration added back, the styles will show the component as being dehydrated, even though it actually did hydrate. Adding defer-hydration does not "unhydrate" an element, so technically this is a stateful change. This can probably be overlooked for an MVP implementation, but it would be great if there's a way to handle that.

An alternative approach might be to track this with JavaScript. There are three ways the page can observe a component's hydration state change:

  1. A component is added to the DOM already in a hydrated or dehydrated state. We can observe this with a MutationObserver with subtree: true.
  2. A component is defined when defer-hydration is not set. We can observe this with customElements.whenDefined.
  3. A component has defer-hydration removed while it is defined. We can observe this with a MutationObserver with attributes: true.

We can watch for new elements and state changes to automatically update their styles.

function isCustomElement(el: Element): boolean {
  return el.tagName.includes('-');
}

function isHydrated(el: Element): boolean {
  const isDefined = customElements.get(el.tagName.toLowerCase());
  const isDeferred = el.getAttribute('defer-hydration');
  return isDefined && !isDeferred;
}

function styleComponent(el: Element): void {
  if (isHydrated(el)) {
    el.classList.remove('wcdt-dehydrated');
    el.classList.add('wcdt-hydrated');
  } else {
    el.classList.remove('wcdt-hydrated'); // Technically a change from hydrated -> dehydrated should never happen.
    el.classList.add('wcdt-dehydrated');
  }
}

new MutationObserver((records) => {
  for (const record of records) {
    // Check components newly added to the DOM.
    for (const node of record.addedNodes) {
      if (!isCustomElement(node)) continue; // Ignore non-custom elements.

      // Style newly discovered component.
      styleComponent(node);

      // If it is currently not defined, wait for it to be defined and restyle.
      const isDefined = customElements.get(el.tagName.toLowerCase());
      if (!isDefined) {
        customElements.whenDefined(el.tagName.toLowerCase()).then(() => { styleComponent(el); });
      }
    }

    // Check components with `defer-hydration` updates.
    if (!isCustomElement(record.target)) continue; // Ignore non-custom elements.
    if (record.attributeName !== 'defer-hydration') continue; // Ignore other attributes.
    styleComponent(record.target);
  }
}).observe(document.documentElement, {
  attributes: true,
  childList: true,
  subtree: true,
});

I haven't tested this and it isn't a perfect script, but hopefully good enough. I'm not sure how MutationObserver plays with shadow DOM, but if it doesn't pierce that boundary then you'll still have that problem. The CSS for .wcdt-dehydrated and .wcdt-hydrated also needs to be applied to all shadow roots, which can be tricky. Maybe a constructable style sheet which gets added to all shadow roots? Not sure if an attached shadow root can be observed (maybe patch Element.prototype.attachShadow?). This approach at least avoids the custom element selector problem. You'll also need to either inject this script before HTML is parsed or scan the document immediately on load for components which are appended prior to the .observe() call.

I made a Stackblitz demoing all the cases I could think of. This should serve as a good test for the feature to make sure it paints the borders correctly.

https://stackblitz.com/edit/typescript-yryatj?file=index.ts,index.html

Matsuuu commented 11 months ago

Thank you so much @dgp1130 !

Such a thorough explanation with examples will surely get me started with this. There are some parts that will be reworked a bit under the surface and with those I'll keep this in mind.

I'll come back to you if I find something I need more examples on. Thanks!

matthewp commented 11 months ago

Note that this is a Lit-only feature. If you're going to do this I would make sure that it's a Lit project and not a generic WC one.

dgp1130 commented 11 months ago

Why is this Lit only? defet-hydration is a proposed community protocol. Any components/frameworks which implement that protocol should be compatible with this feature.

https://github.com/webcomponents-cg/community-protocols/pull/15