Skip to content

feat(nuxt): delayed/lazy hydration support #26468

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

Merged
merged 341 commits into from
Feb 28, 2025
Merged

Conversation

GalacticHypernova
Copy link
Contributor

@GalacticHypernova GalacticHypernova commented Mar 24, 2024

πŸ”— Linked issue

Resolves #24242

❓ Type of change

  • πŸ‘Œ Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)

πŸ“š Description

Lazy components are great for controlling the chunk sizes in your app, but they don't always enhance runtime performance, as they still load eagerly unless conditionally rendered. In real-world applications, some pages may include a lot of content and a lot of components, and most of the time not all of them need to be interactive as soon as the page is loaded. Having them all load eagerly can negatively impact performance.

In order to optimize your app, you may want to delay the hydration of some components until they're visible, or until the browser is done with more important tasks.

Nuxt supports this using lazy (or delayed) hydration, allowing you to control when components become interactive.

Hydration Strategies

Nuxt provides a range of built-in hydration strategies. Only one strategy can be used per lazy component.

::warning
Currently Nuxt's built-in lazy hydration only works in single-file components (SFCs), and requires you to define the prop in the template (rather than spreading an object of props via v-bind).
::

hydrate-on-visible

Hydrates the component when it becomes visible in the viewport.

<template>
  <div>
    <LazyMyComponent hydrate-on-visible />
  </div>
</template>

::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver" title="IntersectionObserver options"}
Read more about the options for hydrate-on-visible.
::

::note
Under the hood, this uses Vue's built-in hydrateOnVisible strategy.
::

hydrate-on-idle

Hydrates the component when the browser is idle. This is suitable if you need the component to load as soon as possible, but not block the critical rendering path.

You can also pass a number which serves as a max timeout.

<template>
  <div>
    <LazyMyComponent hydrate-on-idle />
  </div>
</template>

::note
Under the hood, this uses Vue's built-in hydrateOnIdle strategy.
::

hydrate-on-interaction

Hydrates the component after a specified interaction (e.g., click, mouseover).

<template>
  <div>
    <LazyMyComponent hydrate-on-interaction="mouseover" />
  </div>
</template>

If you do not pass an event or list of events, it defaults to hydrating on pointerenter and focus.

::note
Under the hood, this uses Vue's built-in hydrateOnInteraction strategy.
::

hydrate-on-media-query

Hydrates the component when the window matches a media query.

<template>
  <div>
    <LazyMyComponent hydrate-on-media-query="(max-width: 768px)" />
  </div>
</template>

::note
Under the hood, this uses Vue's built-in hydrateOnMediaQuery strategy.
::

hydrate-after

Hydrates the component after a specified delay (in milliseconds).

<template>
  <div>
    <LazyMyComponent :hydrate-after="2000" />
  </div>
</template>

hydrate-when

Hydrates the component based on a boolean condition.

<template>
  <div>
    <LazyMyComponent :hydrate-when="isReady" />
  </div>
</template>
<script setup lang="ts">
const isReady = ref(false)
function myFunction() {
  // trigger custom hydration strategy...
  isReady.value = true
}
</script>

hydrate-never

Never hydrates the component.

<template>
  <div>
    <LazyMyComponent hydrate-never />
  </div>
</template>

Listening to Hydration Events

All delayed hydration components emit a @hydrated event when they are hydrated.

<template>
  <div>
    <LazyMyComponent hydrate-on-visible @hydrated="onHydrated" />
  </div>
</template>

<script setup lang="ts">
function onHydrate() {
  console.log("Component has been hydrated!")
}
</script>

Copy link

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@GalacticHypernova GalacticHypernova marked this pull request as draft March 24, 2024 17:34
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (9)
docs/2.guide/2.directory-structure/1.components.md (2)

128-129: Consider making the wording more concise.

The phrase "a lot of content and a lot of components" can sound repetitive. Try rephrasing to reduce wordiness.

🧰 Tools
πŸͺ› LanguageTool

[style] ~128-~128: The phrase β€˜a lot of’ might be wordy and overused. Consider using an alternative.
Context: ... pages may include a lot of content and a lot of components, and most of the time not al...

(A_LOT_OF)


[style] ~129-~129: Consider a shorter alternative to avoid wordiness.
Context: ...rly can negatively impact performance. In order to optimize your app, you may want to dela...

(IN_ORDER_TO_PREMIUM)


512-514: Add a comma for clarity.

Consider adding a comma before β€œand you can’t” to separate the clauses and enhance readability.

- * You can't access the 'island context' from the rest of your app and you can't access ...
+ * You can't access the 'island context' from the rest of your app, and you can't access ...
🧰 Tools
πŸͺ› LanguageTool

[uncategorized] ~514-~514: Use a comma before β€˜and’ if it connects two independent clauses (unless they are closely connected and short).
Context: ...sland context' from the rest of your app and you can't access the context of the res...

(COMMA_COMPOUND_SENTENCE)

packages/nuxt/test/component-loader.test.ts (2)

91-124: Multiple lazy hydration strategies are well tested.

The tests cover various strategies effectively. Consider including a conflicting strategy scenario to confirm fallback or prioritisation behaviour.


126-172: Add resource cleanup for Rollup bundles.

Wrap Rollup usage in a try/finally block to ensure bundles are closed, preventing potential memory leaks during repeated tests.

 async function transform (code: string, filename: string) {
+  let bundle
   try {
-    const bundle = await rollup({
+    bundle = await rollup({
       input: filename,
       plugins: [
         // ...
       ]
     })
     const { output: [chunk] } = await bundle.generate({})
     return chunk.code.trim()
+  } finally {
+    if (bundle) {
+      await bundle.close()
+    }
   }
 }
packages/nuxt/src/components/plugins/loader.ts (2)

24-24: Regex complexity caution.

This expanded regex handles multiple modifiers. Consider extracting some handling into helper functions or composable logic for better maintainability.


86-137: Consider splitting out each hydration strategy.

The switch-case approach is straightforward but large. Extracting each strategy into a helper method would aid readability and simplify future expansions.

packages/nuxt/src/components/runtime/lazy-hydrated-component.ts (3)

15-15: Consider handling potential undefined values more explicitly.

The non-null assertion operators (!) suggest that ssrContext or modules might be undefined in some cases, which could lead to runtime errors.

- ssrContext!.modules!.delete(id)
+ if (ssrContext?.modules) {
+   ssrContext.modules.delete(id)
+ }

31-38: Type casting approach could be improved for better clarity.

The type casting using as unknown as may be necessary due to TypeScript limitations, but it reduces type safety.

Consider adding a clear comment explaining the reason for this type casting approach, or if possible, use a more type-safe approach:

  hydrateOnVisible: {
-    type: [Object, Boolean] as unknown as () => true | IntersectionObserverInit,
+    // Cast needed to support both boolean and IntersectionObserverInit types
+    type: [Object, Boolean] as unknown as () => true | IntersectionObserverInit,
    required: false,
  },

105-114: Add a comment to explain the never-hydrate strategy.

The hydrateNever function is a simple empty function, which effectively prevents hydration. A comment explaining this would help other developers understand the intention.

- const hydrateNever = () => {}
+ // This function purposely does nothing, effectively preventing the component from ever hydrating
+ const hydrateNever = () => {}
πŸ“œ Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 3349bf0 and 81a549b.

πŸ“’ Files selected for processing (4)
  • docs/2.guide/2.directory-structure/1.components.md (2 hunks)
  • packages/nuxt/src/components/plugins/loader.ts (3 hunks)
  • packages/nuxt/src/components/runtime/lazy-hydrated-component.ts (1 hunks)
  • packages/nuxt/test/component-loader.test.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
packages/nuxt/src/components/plugins/loader.ts (1)
Learnt from: GalacticHypernova
PR: nuxt/nuxt#26468
File: packages/nuxt/src/components/plugins/loader.ts:24-24
Timestamp: 2024-11-12T07:37:17.193Z
Learning: In `packages/nuxt/src/components/plugins/loader.ts`, the references to `resolve` and `distDir` are legacy code from before Nuxt used the new unplugin VFS and will be removed.
πŸͺ› LanguageTool
docs/2.guide/2.directory-structure/1.components.md

[style] ~128-~128: The phrase β€˜a lot of’ might be wordy and overused. Consider using an alternative.
Context: ... pages may include a lot of content and a lot of components, and most of the time not al...

(A_LOT_OF)


[style] ~129-~129: Consider a shorter alternative to avoid wordiness.
Context: ...rly can negatively impact performance. In order to optimize your app, you may want to dela...

(IN_ORDER_TO_PREMIUM)


[uncategorized] ~137-~137: Loose punctuation mark.
Context: ...rategy can be used per lazy component. ::warning Currently Nuxt's built-in lazy ...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~139-~139: Loose punctuation mark.
Context: ...with direct imports from #components. :: #### hydrate-on-visible Hydrates t...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~153-~153: Loose punctuation mark.
Context: ...on-visible /> ``` ::read-more{to="https://developer.mozilla...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~155-~155: Loose punctuation mark.
Context: ...t the options for hydrate-on-visible. :: ::note Under the hood, this uses Vue'...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~157-~157: Loose punctuation mark.
Context: ...e options for hydrate-on-visible. :: ::note Under the hood, this uses Vue's bu...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~159-~159: Loose punctuation mark.
Context: ...ponents/async.html#hydrate-on-visible). :: #### hydrate-on-idle Hydrates the ...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~175-~175: Loose punctuation mark.
Context: ...te-on-idle /> ``` ::note Under the hood, this uses Vue's bu...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~177-~177: Loose punctuation mark.
Context: ...components/async.html#hydrate-on-idle). :: #### hydrate-on-interaction Hydrat...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~193-~193: Loose punctuation mark.
Context: ...drating on pointerenter and focus. ::note Under the hood, this uses Vue's bu...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~195-~195: Loose punctuation mark.
Context: ...nts/async.html#hydrate-on-interaction). :: #### hydrate-on-media-query Hydrat...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~209-~209: Loose punctuation mark.
Context: ...h: 768px)" /> ``` ::note Under the hood, this uses Vue's bu...

(UNLIKELY_OPENING_PUNCTUATION)


[uncategorized] ~211-~211: Loose punctuation mark.
Context: ...nts/async.html#hydrate-on-media-query). :: #### hydrate-after Hydrates the co...

(UNLIKELY_OPENING_PUNCTUATION)


[style] ~288-~288: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...amount of time. * hydrate-on-idle is for components that can be hydrated whe...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)


[uncategorized] ~514-~514: Use a comma before β€˜and’ if it connects two independent clauses (unless they are closely connected and short).
Context: ...sland context' from the rest of your app and you can't access the context of the res...

(COMMA_COMPOUND_SENTENCE)

⏰ Context from checks skipped due to timeout of 90000ms (3)
  • GitHub Check: codeql (javascript-typescript)
  • GitHub Check: build
  • GitHub Check: code
πŸ”‡ Additional comments (11)
docs/2.guide/2.directory-structure/1.components.md (1)

290-290: Clear best practice note.

Advising developers to avoid hydrate-never on interactive components is helpful and shows good emphasis on user experience.

packages/nuxt/test/component-loader.test.ts (2)

12-25: Good test coverage for standard component resolution.

These assertions confirm both normal and lazy components are resolved and transformed correctly.


59-89: JSX test ensures broader coverage.

Validating component resolution in a JSX context safeguards compatibility with different template syntaxes.

packages/nuxt/src/components/plugins/loader.ts (2)

16-18: Validate or default the new options in LoaderOptions.

srcDir and clientDelayedComponentRuntime are essential for the lazy hydration workflow. Make sure they're provided or fallback accordingly if omitted.


50-53: Fallback logic is a neat safety net.

The step-by-step resolution for normal components and modifiers helps preserve backward compatibility. Just watch for naming collisions.

packages/nuxt/src/components/runtime/lazy-hydrated-component.ts (6)

5-28: Well-designed foundation for lazy hydration components.

This defineLazyComponent higher-order function provides an excellent abstraction for creating different types of lazy-loaded components with various hydration strategies. The function correctly handles SSR by removing the component from the SSR context to avoid generating prefetch/preload tags, and it properly encapsulates the component loading logic.

I particularly like the onVnodeMounted event emitter, which will be useful for developers needing to respond to hydration events.


47-49: Good handling of immediate hydration case.

The conditional expression handles the special case of immediate hydration when hydrateOnIdle is 0, which is an important optimization.


52-63: Excellent use of TypeScript's satisfies operator.

The usage of satisfies provides good type checking whilst preserving the literal type of defaultInteractionEvents. This is a modern TypeScript pattern that ensures the events are compatible with HTMLElementEventMap.


75-88: The watch cleanup is well-implemented.

The implementation correctly returns a cleanup function that unsubscribes from the watcher when the component is unmounted, preventing memory leaks.


90-103: Well-structured timeout handling.

The implementation correctly sets up and clears the timeout, preventing memory leaks. The special case handling for immediate hydration is also appropriately implemented.


1-114: Overall, excellent implementation of lazy hydration strategies.

This file provides a comprehensive set of lazy hydration strategies that align well with the PR objective to improve performance. The code is well-structured, follows modern Vue and TypeScript practices, and handles important considerations like SSR and cleanup. The emitted 'hydrated' event provides good developer feedback.

Consider adding error handling for async component loading and documenting the type casting approach for better maintainability.

Copy link

codspeed-hq bot commented Feb 28, 2025

CodSpeed Performance Report

Merging #26468 will not alter performance

Comparing GalacticHypernova:patch-21 (81a549b) with main (7e2f96d)

Summary

βœ… 10 untouched benchmarks

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (7)
test/basic.test.ts (4)

2863-2895: Consider using explicit wait conditions to reduce flakiness
The test employs a sizable timeout and several waitFor... calls. In slower CI environments, this approach might still lead to sporadic failures. Using more precise conditions or repeated polling for specific DOM states can help mitigate test timing issues.


2922-2933: Refine timing-based tests to prevent false positives
The test sets a time-based hydration trigger and checks it in a single scenario. In resource-constrained or busy environments, timing inaccuracies could cause sporadic test results. Adding more robust checks (e.g., verifying that the timeout actually expired before hydration) would increase test reliability.


2934-2952: Minimise loop iteration for faster test execution
The loop increments the counter from 0 to 10, which may be unnecessarily high. Reducing the iteration count or using a dedicated assertion for final state can shorten test duration and reduce potential flakiness while still ensuring correctness and reactivity validation.


2953-2966: Consider testing consecutive hydration events
Currently, the test verifies a single hydration event by checking console logs for 'Component hydrated'. You might extend this test to cover rapid consecutive triggers or complex user interactions, ensuring the component remains stable during multiple hydration phases.

packages/nuxt/src/components/runtime/lazy-hydrated-component.ts (3)

19-23: Validate nested async components to avoid unnecessary overhead
Using two layers of defineAsyncComponent may cause additional overhead or complexity if the loader is requested too soon or multiple times. Ensure that child components are not redundantly fetching resources or introducing performance bottlenecks.


82-87: Avoid repeated re-hydration triggers
When hydrateWhen becomes true, the watcher immediately calls hydrate(). If hydrateWhen toggles multiple times, there is a risk of repeated re-hydration unless carefully handled. You might add a check within the watcher to prevent multiple invocations if toggled again.


107-115: Consider warning for conflicting hydration settings
When a developer sets hydrateNever to true, other hydration props (such as hydrateOnIdle) become moot. Emitting a compile-time or runtime warning could help developers detect conflicting options more readily.

πŸ“œ Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 81a549b and 3769a33.

πŸ“’ Files selected for processing (2)
  • packages/nuxt/src/components/runtime/lazy-hydrated-component.ts (1 hunks)
  • test/basic.test.ts (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (4)
  • GitHub Check: codeql (javascript-typescript)
  • GitHub Check: build
  • GitHub Check: Socket Security: Pull Request Alerts
  • GitHub Check: code
πŸ”‡ Additional comments (5)
test/basic.test.ts (2)

2896-2912: Verify coverage for multiple interactive events
You hover and then click to test delayed hydration triggers. It could be beneficial to cover multi-event scenarios (e.g., a double-click or a focus-then-click) to ensure consistency with more complex interaction patterns.


2913-2921: Well-structured coverage for name-based hydration overrides
The test confirms that no default hydration delay is enforced when the component name matches a recognised modifier. This logic appears comprehensive. Good job!

packages/nuxt/src/components/runtime/lazy-hydrated-component.ts (3)

14-16: Guard against possible null or undefined ssrContext
The code uses non-null assertions (!) on ssrContext and its modules property. Consider optional chaining or safety checks to handle potential edge cases in development or unusual build contexts.


30-50: Confirm clarity in idle fallback logic
The code checks for props.hydrateOnIdle === 0 to switch to immediate hydration, which is ingenious. However, it may be worth documenting this behaviour more explicitly in the code comments to prevent confusion, as zero can be mistaken for a valid idle delay.


100-102: Good practice clearing the timeout upon unmount
Your implementation properly uses return () => clearTimeout(id) to prevent memory leaks or unexpected triggers should the component unmount prematurely. This is a robust approach.

Copy link

codspeed-hq bot commented Feb 28, 2025

CodSpeed Performance Report

Merging #26468 will not alter performance

Comparing GalacticHypernova:patch-21 (3769a33) with main (7e2f96d)

Summary

βœ… 10 untouched benchmarks

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
test/basic.test.ts (1)

2923-2933: πŸ› οΈ Refactor suggestion

Time-based hydration test doesn't verify eventual hydration.

The test only verifies the initial unhydrated state, but doesn't check that the component actually gets hydrated after the specified time delay.

 await page.locator('[data-testid=hydrate-after]', { hasText: unhydratedText }).waitFor({ state: 'visible' })
 await page.locator('[data-testid=hydrate-after]', { hasText: hydratedText }).waitFor({ state: 'hidden' })
+
+ // Wait for hydration to occur after the specified time
+ await page.waitForFunction(() => {
+   return document.querySelector('[data-testid=hydrate-after]')?.textContent?.includes('This is mounted.') === true
+ }, { timeout: 2500 })
🧹 Nitpick comments (2)
test/basic.test.ts (2)

2864-2895: Test is robust, but could include more specific state assertions.

The test covers different hydration strategies comprehensively, but could be more explicit about expected states. Consider adding assertions that verify state changes directly rather than just presence/absence of text.

- await page.locator('data-testid=hydrate-on-visible', { hasText: hydratedText }).waitFor()
+ await page.locator('data-testid=hydrate-on-visible', { hasText: hydratedText }).waitFor()
+ expect(await page.locator('data-testid=hydrate-on-visible').getAttribute('data-hydrated')).toBe('true')

2954-2968: Test could use more reliable event detection.

The test relies on scanning console logs for hydration events, which works but could be more reliable with more direct event detection.

 const { page, consoleLogs } = await renderPage('/lazy-import-components/model-event')

-const initialLogs = consoleLogs.filter(log => log.type === 'log' && log.text === 'Component hydrated')
-expect(initialLogs.length).toBe(0)
+// Setup event listener before triggering hydration
+const hydrationPromise = page.evaluate(() => {
+  return new Promise(resolve => {
+    window.addEventListener('@hydrated', () => resolve(true), { once: true })
+  })
+})

 await page.getByTestId('count').click()

-// Wait for all pending micro ticks to be cleared in case hydration hasn't finished yet.
-await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10)))
-const hydrationLogs = consoleLogs.filter(log => log.type === 'log' && log.text === 'Component hydrated')
-expect(hydrationLogs.length).toBeGreaterThan(0)
+// Wait for the hydration event with a reasonable timeout
+await expect(hydrationPromise).resolves.toBe(true)
πŸ“œ Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 3769a33 and 3e70ed9.

πŸ“’ Files selected for processing (1)
  • test/basic.test.ts (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (3)
  • GitHub Check: codeql (javascript-typescript)
  • GitHub Check: build
  • GitHub Check: code
πŸ”‡ Additional comments (3)
test/basic.test.ts (3)

2896-2912: Test for custom hydration triggers is well structured.

This test clearly validates that custom triggers override defaults with the correct sequence of interactions and assertions.


2914-2921: Good edge case testing for name-sensitive components.

This test properly verifies that components with names that could be confused with hydration modifiers are not automatically affected by delayed hydration.


2935-2952: Thorough reactivity testing.

The test effectively validates that model binding and reactivity are preserved in lazily hydrated components through multiple interactions.

Copy link

codspeed-hq bot commented Feb 28, 2025

CodSpeed Performance Report

Merging #26468 will not alter performance

Comparing GalacticHypernova:patch-21 (3e70ed9) with main (7e2f96d)

Summary

βœ… 10 untouched benchmarks

Copy link

codspeed-hq bot commented Feb 28, 2025

CodSpeed Performance Report

Merging #26468 will not alter performance

Comparing GalacticHypernova:patch-21 (ebc9a7a) with main (5ad1e20)

Summary

βœ… 10 untouched benchmarks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Lazy Hydration in Nuxt Core
10 participants