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

[defer-hydration] Async hydration #48

Open
dgp1130 opened this issue Nov 9, 2023 · 3 comments
Open

[defer-hydration] Async hydration #48

dgp1130 opened this issue Nov 9, 2023 · 3 comments

Comments

@dgp1130
Copy link
Contributor

dgp1130 commented Nov 9, 2023

Since the defer-hydration proposal seems to be moving forward I'd like to start one point of discussion: Is hydration a fundamentally synchronous or asynchronous process? I don't know of a strict, formal definition of "hydration" which can answer this question, but the proposal currently uses the following definition:

In server-side rendered (SSR) applications, the process of a component running code to re-associate its template with the server-rendered DOM is called "hydration".

This issue mainly boils down to answering the question: Is the process of re-associating a template with server-rendered DOM always synchronous?

Use case

I think there is a case to be made for async hydration. Consider a component which needs to load its own data asynchronously before it is fully functional. For example, consider a component which shows a user with a large number of friends. We might not want to list out every friend in the initial HTML, because some users can have thousands of friends. Instead, we might choose to lazy load this list of friends and render it when it becomes available (possibly with streaming or other cool tricks). Full example on Stacklibtz.

<my-user user-id="1234">
  <div>Name: <span class="name">Devel</span></div>
  <div>Friends list: <span class="loading">Loading...</span></div>
  <ul class="friends"></ul>
</my-user>
class MyUser extends HTMLElement {
  private user?: User;

  connectedCallback(): void {
    if (!this.isHydrated) {
      this.hydrate();
      this.isHydrated = true;
    }
  }

  private isHydrated = false;
  private async hydrate(): Promise<void> {
    const userId = Number(this.getAttribute('user-id')!);
    this.user = await fetchUser(userId);

    const friendsList = this.querySelector('.friends');
    for (const friend of this.user.friends ?? []) {
      const friendListItem = document.createElement('li');
      friendListItem.textContent = friend.name;
      friendsList.append(friendListItem);
    }

    this.querySelector('.loading').remove();
  }
}

customElements.define('my-user', MyUser);

interface User {
  name: string;
  friends?: User[];
}

Ok, so we defined our own hydrate method and made it async. Web components are free to define their implementations and this is fine on its own. It's basically just a "slow" hydration. The problem comes when we try to expose this async data such as a getFriends method.

class MyUser extends HTMLElement {
  private user?: User;

  getFriends(): User[] {
    return this.user!.friends ?? [];
  }
}

This might seem like a simple addition, but it completely changes the lifecycle of this component as we now have a timing bug. This code assumes hydrate() has fully completed its async work before getFriends is called. However, the promise which awaits this data (the return value of hydrate()) is not accessible in a generic manner. For example, if we tried to hydrate and use this component according to the defer-hydration specification, it would look like:

const userComponent = document.querySelector('my-user');
userComponent.removeAttribute('defer-hydration'); // Trigger hydration.
console.log(userComponent.getFriends()); // ERROR! We don't know any friends yet!

We're forced into some uncomfortable design decisions. I can see a few potential solutions to this component which don't involve modifying the defer-hydration proposal:

Wait via a my-user-specific API

One approach is to have my-user define its own API users should use to know when it is done hydrating asynchronously:

const userComponent = document.querySelector('my-user');
userComponent.removeAttribute('defer-hydration'); // Trigger hydration.
await userComponent.doneLoadingUser;
console.log(userComponent.getFriends()); // Works!

Downsides:

  • Every component will define this API a little differently.
  • The code which hydrates the component (removeAttribute('defer-hydration')) may be very far away from the code which calls getFriends() and may not know it is looking at a my-user component or that doneLoadingUser exists.

Implicitly hydrate in getFriends

Another approach is for getFriends to automatically hydrate before returning:

const userComponent = document.querySelector('my-user');
const friends = await userComponent.getFriends(); // Implicitly hydrates.
console.log(friends); // Works!

Downsides:

  • Every method needs to implicitly check and initialize the component automatically.
  • This "colors" every method to be async, even when it doesn't actually do any async work beyond hydration.
  • It's not obvious that getFriends will hydrate the component or apply any associated side effects (trigger network requests, add event listeners, modify the component UI, etc.).
  • Component needs to remember to run this.removeAttribute('defer-hydration') or it could misrepresent its current hydration status.
    • Question: Does this happen when the user calls getFriends or when the returned Promise resolves? Is the component "hydrated" when it starts hydrating or when it's done hydrating?
      • The "obvious" answer to me is that it's hydrated when it's done hydrating, however that goes against what happens when defer-hydration is manually removed by a parent component. In that scenario, defer-hydration is removed at the start of hydration, but calling getFriends would remove it at the end of hydration.

Both of these approaches effectively treat hydration as the synchronous process of reading the DOM state (the user-id attribute in this case) and providing a separate "initialization" process for consumers to know when the component is initialized and ready. Since initialization is a different, out-of-scope process from hydration, component consumers cannot make any generic inferences about how initialization will work.

Async data takes a lot of forms. One can imagine a framework which identifies large component trees and pushes some hydration data out of the initial page response to reduce the initial download time. Then on hydration, components may fetch the data they need to hydrate in order to make themselves interactive. I'm not aware of any framework which quite does this (I don't think Qwik or Wiz work this way), but it is an interesting avenue which could be explored in the future and would be incompatible with defer-hydration as currently specified.

Straw-proposal

Just to put out one potential proposal which could address this use case in the community protocol, we could define a whenHydrated property on async hydration components (mirroring customElements.whenDefined). This property would be assigned to a Promise which, when resolved, indicates that the component is hydrated. In practice this would look like:

class MyUser extends HTMLElement {
  public whenHydrated?: Promise<void>;
  private hydrate(): void {
    this.whenHydrated= (async () => {
      const userId = Number(this.getAttribute('user-id')!);
      this.user = await fetchUser(userId);

      const friendsList = this.querySelector('.friends');
      for (const friend of this.user.friends ?? []) {
        const friendListItem = document.createElement('li');
        friendListItem.textContent = friend.name;
        friendsList.append(friendListItem);
      }

      this.querySelector('.loading').remove();
    })();
  }
}

Then, when hydrating a component we can generically check if async work needs to be done.

const userComponent = document.querySelector('my-user');
userComponent.removeAttribute('defer-hydration'); // Trigger hydration.

// Wait for hydration to complete. If there is no `whenHydrated` set, then it must be able to synchronously hydrate.
if (userComponent.whenHydrated) await userComponent.whenHydrated;

console.log(userComponent.getFriends()); // Works!

This proposal supports async hydration in a generic and interoperable manner.

Discussion

To be clear, I'm not necessarily trying to argue defer-hydration absolutely should support async hydration. I'm not fully convinced this is a good idea either, but I do think it's something worth discussing at minimum.

Hydration vs. Initialization

As I've hinted a bit earlier, I suspect the concept of "async hydration" is somewhat intermingling two independent concerns: hydration and initialization. An alternative definition of "hydration" can more narrowly specify the concept along the lines of "Reading initial component state from prerendered DOM". Based on this definition, the my-user component described above does more than just hydrate itself. Number(this.getAttribute('user-id')!) is the only real "hydration" the component performs. Everything else is completely unrelated initialization work which applies both in CSR and SSR use cases. Fetching data from the user ID and updating the DOM can be considered "initialization" rather than "hydration".

If we accept this more narrow definition of "hydration" and call initialization an independent problem which is out of scope of defer-hydration, then there's no bug here and the proposal doesn't need to change at all. Understanding when an object is initialized has been a problem for as long as we've had objects after all.

OTOH, we could define "hydration" along the lines of "Making the component interactive to the user", I think it is entirely fair to expect some components will require asynchronous work before they can support interactivity. Here's another Stackblitz of a somewhat contrived use case which requires a network request of initialization data before buttons can be enabled. Calling such a component "hydrated" synchronously after defer-hydration is removed would be misleading because the component is still in no way interactive and has not presented any visual or behavioral change to the user.

If we accept the separation of concerns between hydration and initialization, then defer-hydration becomes a much less powerful proposal. If "hydrated" does not imply "initialized" then it is hard to generically do anything with a component.

const myElement = document.querySelector('.some-element');
if (myElement.hasAttribute('defer-hydration')) myElement.removeAttribute('defer-hydration');

// Do... something... with `myElement`?
// Can't really do anything because we have no guarantee that it will work, even if it's hydrated.
myElement.doSomething(); // Could fail purely because initialization hasn't completed yet.

// Is a cast even valid? We have no reason to believe `myUser.getFriends()` would work here,
// so why should we type it in a way which implies it would work?
const myUser = myElement as MyUser;

I think my initial interpretation of defer-hydration was that it could serve as a signal that a component was initialized and fully functional. It's entirely possible that interpretation was incorrect, but I do still think that's usually true, and it provides a lot of power when working with components in a generic fashion. I suspect making defer-hydration support async use cases could further enable hydration to serve as an initialization signal if we think that is the right approach to explore.

Again, I'm not totally sold on the idea of "async hydration" either. I just think its neat.

Meme of Marge Simpson holding up a potato labeled "Async-hydrating components" and saying "I just think they're neat".

@justinfagnani
Copy link
Member

I don't think the use case you're taking about or the example your posted is really hydration in the sense that I know it. You essentially have a component that's already "hydrated" or just doesn't need hydration.

One case where defer-hydration would be critical is if instead of fetching data based on a user-id attribute, it fetched based on some non-serializable property set by the parent during the parent's hydration. Then you would want to wait until you had the data from the parent to begin the fetch so that you potentially didn't fetch twice and throw away one result. The component could wait to fetch if defer-hydration is present and fetch when it's removed. This would probably look like it deferring it's normal connected or attribute-changed logic.

Regardless, you have a component that async fetches data, and therefore all of the APIs based on that data need to be designed with that in mind. Whether getFriends() is async, or there's a promise on the component that resolves when it's ready, etc., would be up to the component. Presumably it has to answer all those questions for client-side rendering too, so there's nothing defer-hyderation could do to universally answer them.

@dgp1130
Copy link
Contributor Author

dgp1130 commented Nov 19, 2023

I don't think the use case you're taking about or the example your posted is really hydration in the sense that I know it. You essentially have a component that's already "hydrated" or just doesn't need hydration.

I can see that await fetchUser is arguably not hydration, but do you also think Number(this.getAttribute('user-id')!) is not hydrating? The current defer-hydration proposal is kind of vague on the definition of "hydration" and I proposed a few different interpretations in my first comment. How do you define that term for the purposes of defer-hydration?

One case where defer-hydration would be critical is if instead of fetching data based on a user-id attribute, it fetched based on some non-serializable property set by the parent during the parent's hydration. Then you would want to wait until you had the data from the parent to begin the fetch so that you potentially didn't fetch twice and throw away one result. The component could wait to fetch if defer-hydration is present and fetch when it's removed. This would probably look like it deferring it's normal connected or attribute-changed logic.

Sure, if you believe that is a more compelling use case then I agree async hydration could be just as relevant there. I created a new Stackblitz which demonstrates this exact use case.

I don't personally see defer-hydration as intrinsically tied to top-down hydration or non-serializable inputs and I don't think this particular issue is all that affected by it.

Regardless, you have a component that async fetches data, and therefore all of the APIs based on that data need to be designed with that in mind. Whether getFriends() is async, or there's a promise on the component that resolves when it's ready, etc., would be up to the component. Presumably it has to answer all those questions for client-side rendering too, so there's nothing defer-hyderation could do to universally answer them.

I will point out that not all components will support client-side rendering. Server-only components which are progressively enhanced on the client that don't necessitate a re-render step are perfectly viable today. An async form of defer-hydration could be exactly what such components need to properly interoperate.

That said, I do agree client-side rendering has a very similar initialization problem, and maybe that's a signal that this is something which should be tackled independently of defer-hydration. Certainly I would want any general solution to initialization to also have a good story as relates to hydration and server-only components in particular.

@dgp1130
Copy link
Contributor Author

dgp1130 commented Mar 3, 2024

I happened to be looking through the <is-land> source code and noticed that it actually defines an async hydrate() {} function and might make a compelling use case here. The main question is: Would a component which a child <is-land> element want to be able to await this Promise? <is-land> seems to be waiting for two things:

First, it waits for its conditions to trigger the hydration of its children. Essentially if you use <is-land on:interaction> it waits for a click interaction before hydrating its children, and this promise includes that waiting.

One could argue that is-land actually hydrates synchronously and sets up event listeners. When an event is triggered and component chooses to hydrate its children is an action unrelated to <is-land> hydration. However as written in the source code, it's essentially interpreted as kind of a "slow" hydration. There's maybe a philosophical question around "Is <is-land> done hydrating when it is ready itself, or when it has hydrated all its descendants?"

Second, it has an extension which dynamic imports dependencies and initializes them. You can write:

<is-land import="./some-component.js">
  <some-component defer-hydration>
    <!-- ... -->
  </some-component>
</is-land>

It will automatically import ./some-component.js and wait for it to load.

With both of these in mind, the question becomes: Would a consumer of <is-land> want to wait for these two actions? I think the answer is yes.

Prior to is-land completing its hydration, the components underneath it are effectively inert and unusable. They may not be defined, and they may not be functional in any capacity. This could allow a parent element to observe the loading status of its children or allow multiple "islands" to communicate with each other. Being able to answer the question "Has this island hydrated yet?" seems very useful to me for coordination within a page.

The one counter argument I can think of is that it might make more sense to directly observe the element you actually want (<some-component>) rather than the <is-land> element which wraps it. That's a fair criticism, but I think that's a slightly different level of abstraction which only sometimes makes sense to use. A single <is-land> may hydrate multiple elements within it or a consumer asking "Has this island been hydrated?" may not actually know what elements are inside which are worth observing. I'm also not super clear on the best/correct way of await whenHydrated(el). I think MutationObserver could do this, but that assumes that hydration is always synchronous when defer-hydration is removed.

In terms of motivation, I literally discovered that <is-land> was async because I was writing a test which used it and tried to assert its child was hydrated synchronously after triggering its condition. I found this wasn't the case because <is-land> is operating asynchronously and there's no clear way for me to await it. Best I could do was wait one macrotask, which was sufficient for my test, but won't always work in practice. So there's at least one actual use case where some form of await island.whenHydrated; would have been useful.

I mainly just wanted to mention <is-land> as a motivating real-world use case for async hydration and how consumers could be benefitted by having the ability to await it in a way which isn't possible with defer-hydration as it is defined today. Hopefully that's a useful example to consider.

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

2 participants