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

feat: lazy hydration strategies for async components #11458

Merged
merged 12 commits into from
Jul 31, 2024
Merged

Conversation

yyx990803
Copy link
Member

@yyx990803 yyx990803 commented Jul 30, 2024

Note: API has been adjusted in #11530

Async components can now control when they are hydrated by providing a hydration strategy.

  • Vue provides a number of built-in hydration strategies. These built-in strategies need to be individually imported so they can be tree-shaken if not used.

  • The design is intentionally low-level for flexibility. Compiler syntax sugar can potentially be built on top of this in the future either in core or in higher level solutions (e.g. Nuxt).

  • Why is it coupled to async components? Mostly because async components are already natural boundaries for async operations inside a render tree. Async sub trees require special handling in regards of reactivity tracking and suspense resolution. By coupling async hydration to async components we can support the feature without introducing extra complexities when it interacts with those other areas of concerns.

    The result of this coupling is that one can't have a component that is eagerly loaded but lazily hydrated - which seems illogical to begin with.

Hydrate on Idle

Hydrates via requestIdleCallback:

import { defineAsyncComponent, hydrateOnIdle } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnIdle(/* optionally pass a max timeout */)
})

Hydrate on Visible

Hydrate when element(s) become visible via IntersectionObserver.

import { defineAsyncComponent, hydrateOnVisible } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnVisible()
})

Can optionally pass in an options object value for the observer:

hydrateOnVisible({ rootMargin: '100px' })

Hydrate on Media Query

Hydrates when the specified media query matches.

import { defineAsyncComponent, hydrateOnMediaQuery } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnMediaQuery('(max-width:500px)')
})

Hydrate on Interaction

Hydrates when specified event(s) are triggered on the component element(s). The event that triggered the hydration will also be replayed once hydration is complete.

import { defineAsyncComponent, hydrateOnInteraction } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnInteraction('click')
})

Can also be a list of multiple event types:

hydrateOnInteraction(['wheel', 'mouseover'])

Custom Strategy

import { defineAsyncComponent, type HydrationStrategy } from 'vue'

const myStrategy: HydrationStrategy = (hydrate, forEachElement) => {
  // forEachElement is a helper to iterate through all the root elememts
  // in the component's non-hydrated DOM, since the root can be a fragment
  // instead of a single element
  forEachElement(el => {
    // ...
  })
  // call `hydrate` when ready
  hydrate()
  return () => {
    // return a teardown function if needed
  }
}

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: myStrategy
})

Copy link

github-actions bot commented Jul 30, 2024

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 95.2 kB (+5.34 kB) 36.3 kB (+1.87 kB) 32.7 kB (+1.68 kB)
vue.global.prod.js 153 kB (+6.3 kB) 56.1 kB (+2.12 kB) 49.9 kB (+1.89 kB)

Usages

Name Size Gzip Brotli
createApp 53.1 kB (+3.44 kB) 20.6 kB (+1.15 kB) 18.8 kB (+1.01 kB)
createSSRApp 57 kB (+3.82 kB) 22.2 kB (+1.29 kB) 20.2 kB (+1.15 kB)
defineCustomElement 55.3 kB (+3.44 kB) 21.4 kB (+1.18 kB) 19.5 kB (+1.02 kB)
overall 66.6 kB (+3.49 kB) 25.6 kB (+1.16 kB) 23.4 kB (+1.08 kB)

@@ -118,6 +121,17 @@ export function defineAsyncComponent<

__asyncLoader: load,

__asyncHydrate(el, instance, hydrate) {
const doHydrate = hydrateStrategy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to handle exceptions since user can pass a custom strategy? If an exception occurs in hydrateStrategy, fall back to hydrate.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the behavior of falling back to hydrate should be optional, because if users want to use a custom hydration strategy, it often means that they have to implement a complete hydrate function themselves, so exception handling should also be one of the things that users need to consider.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Although it should be noted that even if sync errors are handled, we can't catch all possible async errors in custom strategies, so there will still be a possibility for the hydration to never happen. In other words, error handling here can only mitigate errors to a certain extent - it's ultimately the user's responsibility to provide a working strategy.

Copy link
Contributor

@skirtles-code skirtles-code left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should strategies be provided with a way to do tear down logic? e.g. If an async component is removed before it is hydrated, the strategy may need to remove listeners on document, or watchers on a store.

@sadeghbarati
Copy link

@yyx990803
Copy link
Member Author

@skirtles-code

Should strategies be provided with a way to do tear down logic? e.g. If an async component is removed before it is hydrated, the strategy may need to remove listeners on document, or watchers on a store.

Good point, I should also add a test case for a non-hydrated tree to be removed before ever being hydrated.

@yyx990803 yyx990803 merged commit d14a11c into minor Jul 31, 2024
11 checks passed
@yyx990803 yyx990803 deleted the lazy-hydration branch July 31, 2024 04:14
@danielroe danielroe mentioned this pull request Jul 31, 2024
12 tasks
@GalacticHypernova
Copy link
Contributor

Can Vue methods like watch/ref be used inside custom hydration strategies?

@edison1105
Copy link
Member

@GalacticHypernova
Could you please provide a minimal reproduction to show your usage scenario?

@GalacticHypernova
Copy link
Contributor

GalacticHypernova commented Aug 26, 2024

Hey @edison1105 ,
I'm working on condition-based delayed hydration (a "hydrate if/when something is true" scenario) for general purpose use, which I would need to watch for changes in the hydration strategy to then call hydrate. I have this code to demonstrate:

// componentLoader is the dynamic import, attrs.hydrate is for the component to render
defineComponent({
    inheritAttrs: false,
    setup (_, { attrs }) {
      if (import.meta.server) {
        return () => h(defineAsyncComponent(componentLoader), attrs)
      }
      const shouldHydrate = ref(!!(attrs.hydrate ?? true))
      if (shouldHydrate.value) {
        return () => h(defineAsyncComponent(componentLoader), attrs)
      }

      const strategy: HydrationStrategy = (hydrate) => {
        const unwatch = watch(shouldHydrate, () => hydrate(), { once: true })
        return () => unwatch()
      }
      return () => h(defineAsyncComponent({loader: componentLoader, hydrate: strategy})
    },
  })

@edison1105
Copy link
Member

@GalacticHypernova
I think this usage is fine

@GalacticHypernova
Copy link
Contributor

Alright thank you!

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

Successfully merging this pull request may close these issues.

6 participants