Skip to content

Commit

Permalink
feat: add SSR HTTP Client Hints support (#99)
Browse files Browse the repository at this point in the history
* feat(test): implement Client Hints for SSR

* chore: remove `Accept-CH` from `app.meta.head`

* chore: add initial support for width and height

* chore: dont watch display on client

* docs: add SSR warning about  resizing window while loading

* docs(ssr): added caveat for viewport headers

* reorder

* updates

* wip

* wip

* chore: color scheme support via cookie

* chore: update load config and theme client logic

* chore: bump to pnpm 8.7.5

* chore: add browser detection and initial client impl

* fix: remove viewport mismatch hydration warning when browser doesn't support client hints

* chore: include Edge and refactor client logic

* chore: include browser version check and tests

* chore: add reload on first request support

* tests: added safari tests in detect-browser.test.ts (#104)

* tests: added safari tests in detect-browser.test.ts (#105)

* tests: added safari tests in detect-browser.test.ts

* tests: added firefox macos tests in detect browser

* fix: cookie theme

* chore: refactor some function names and types

* chore: refactor virtual + add browser theme preference

* chore: disable `useBrowserThemeOnly` in playground

* docs: include `useBrowserThemeOnly` + cleanup

* chore(client): create theme cookie on first request using the theme before reloading

* docs: final review

* docs: .

* docs: .

* chore: extract state name to client-hints module

---------

Co-authored-by: JD Solanki <jdsolanki0001@gmail.com>
  • Loading branch information
userquin and jd-solanki committed Sep 18, 2023
1 parent 8acd88b commit aaf4226
Show file tree
Hide file tree
Showing 26 changed files with 1,350 additions and 9 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
-**Fully Tree Shakable**: by default, only the needed Vuetify components are imported
- 🛠️ **Versatile**: custom Vuetify [directives](https://vuetifyjs.com/en/getting-started/installation/#manual-steps) and [labs components](https://vuetifyjs.com/en/labs/introduction/) registration
-**Configurable Styles**: configure your variables using [Vuetify SASS Variables](https://vuetifyjs.com/en/features/sass-variables/)
- 💥 **SSR**: automatic SSR detection and configuration
- 💥 **SSR**: automatic SSR detection and configuration including [HTTP Client hints](https://developer.mozilla.org/en-US/docs/Web/HTTP/Client_hints)
- 🔩 **Nuxt Layers and Hooks**: load your Vuetify configuration using [Nuxt Layers](https://nuxt.com/docs/getting-started/layers#layers) or using a custom module via `vuetify:registerModule` [Nuxt Hook](https://nuxt.com/docs/guide/going-further/hooks#nuxt-hooks-build-time)
- 📥 **Vuetify Configuration File**: configure your Vuetify options using a custom `vuetify.config` file, no dev server restart needed
- 🔥 **Pure CSS Icons**: no more font/js icons, use the new `unocss-mdi` icon set or build your own with UnoCSS Preset Icons
Expand Down
21 changes: 21 additions & 0 deletions configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,24 @@ declare module 'virtual:vuetify-icons-configuration' {
export const isDev: boolean
export function iconsConfiguration(): IconOptions
}

declare module 'virtual:vuetify-ssr-client-hints-configuration' {
export interface SSRClientHintsConfiguration {
reloadOnFirstRequest: boolean
viewportSize: boolean
prefersColorScheme: boolean
prefersReducedMotion: boolean
clientWidth?: number
clientHeight?: number
prefersColorSchemeOptions?: {
baseUrl: string
defaultTheme: string
themeNames: string[]
cookieName: string
darkThemeName: string
lightThemeName: string
useBrowserThemeOnly: boolean
}
}
export const ssrClientHintsConfiguration: SSRClientHintsConfiguration
}
89 changes: 88 additions & 1 deletion docs/guide/server-side-rendering.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
---
outline: deep
---

# Server Side Rendering (SSR)

Vuetify Nuxt Module supports [SSR](https://nuxt.com/docs/api/configuration/nuxt-config#ssr) out of the box. It will automatically detect if you are using SSR and configure Vuetify accordingly.

The module includes support for the following [HTTP Client hints](https://developer.mozilla.org/en-US/docs/Web/HTTP/Client_hints), check [SSR HTTP Client hints](#ssr-http-client-hints) for more details:
- [Sec-CH-Prefers-Color-Scheme](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme)
- [Sec-CH-Prefers-Reduced-Motion](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Reduced-Motion)
- [Sec-CH-Viewport-Width](https://wicg.github.io/responsive-image-client-hints/#sec-ch-viewport-width)
- [Sec-CH-Viewport-Width](https://wicg.github.io/responsive-image-client-hints/#sec-ch-viewport-height)

::: warning
The [HTTP Client hints](https://developer.mozilla.org/en-US/docs/Web/HTTP/Client_hints) headers listed above are still in draft, and only Chromium based browsers support them: Chrome, Edge, Chromium and Opera.
:::

## Vuetify SASS Variables

If you are customising Vuetify SASS Variables via [configFile](https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#customising-variables) module option with SSR enabled, you have to disable `experimental.inlineSSRStyles` in your Nuxt config file, otherwise you will get an error when building your application:
Expand All @@ -27,7 +41,11 @@ export default defineNuxtConfig({

## Vuetify Themes

If you're using multiple Vuetify Themes with SSR enabled, Vuetify [useTheme](https://vuetifyjs.com/en/api/use-theme/) will not work since there is no way to know which theme to use in the server (the server will use the default theme). You will need to add some logic in the client to restore the theme after hydration.
If you're using multiple Vuetify Themes with SSR enabled, Vuetify [useTheme](https://vuetifyjs.com/en/api/use-theme/) will not work since there is no way to know which theme to use in the server (the server will use the default theme).

This module provides support to restore the theme using `prefers-color-scheme`, check [Sec-CH-Prefers-Color-Scheme](#sec-ch-prefers-color-scheme) for more details.

Alternatively, you will need to add some logic in the client to restore the theme after hydration.

For example, if you want to use `dark` and `light` Vuetify Themes restoring the initial value using `prefers-color-scheme` and `localStorage`, you can use [useDark](https://vueuse.org/core/useDark/) and [useToogle](https://vueuse.org/shared/useToggle/) composables from VueUse in the following way:
```ts
Expand Down Expand Up @@ -68,3 +86,72 @@ const { isDark } = useCustomTheme()
## Vuetify Display

If you're using Vuetify [useDisplay](https://vuetifyjs.com/en/api/use-display/) composable with SSR enabled, there is only one way for the server to get the client's width and height (still in draft): use the [Sec-CH-Viewport-Width](https://wicg.github.io/responsive-image-client-hints/#sec-ch-viewport-width) and [Sec-CH-Viewport-Height](https://wicg.github.io/responsive-image-client-hints/#sec-ch-viewport-height) headers respectively, will not work for the initial request.

Check [SSR HTTP Client hints](#ssr-http-client-hints) for more details.

## SSR HTTP Client hints

You can enable SSR **HTTP Client hints** using the `ssrClientHints` module option:
- `viewportSize`: enable `Sec-CH-Viewport-Width` and `Sec-CH-Viewport-Height` headers (defaults to `false`)?
- `prefersColorScheme`: enable `Sec-CH-Prefers-Color-Scheme` header (defaults to `false`)? Check [Sec-CH-Prefers-Color-Scheme](#sec-ch-prefers-color-scheme) for more details
- `prefersReducedMotion`: `Sec-CH-Prefers-Reduced-Motion` header (defaults to `false`)?

If you enable `prefersReducedMotion` option, you should handle it with a Nuxt plugin registering the `vuetify:ssr-client-hints` hook.
**Your Nuxt plugin hook will be only called on the server** with the Vuetify options and the `ssrClientHints` as parameter.
Before calling your `vuetify:ssr-client-hints` hook, this module will configure `vuetifyOptions.ssr` and the `global Vuetify theme` properly when `ssrClientHints.viewportSize` and `ssrClientHints.prefersColorScheme` are enabled.

```ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('vuetify:ssr-client-hints', ({ vuetifyOptions, ssrClientHints, ssrClientHintsConfiguration }) => {
// your logic here
})
})
```

where:
- `vuetifyOptions` is the configuration for the Vuetify instance (if you need to update some option)
- `ssrClientHints` are the client hints extracted from the request headers (the full definition can be found in the [types.ts](https://github.com/userquin/vuetify-nuxt-module/blob/main/src/types.ts) in the `#app` module augmentation)
- `ssrClientHintsConfiguration` is the client hints configuration (the full definition can be found in the `virtual:vuetify-ssr-client-hints-configuration` declaration in the [configuration.ts](https://github.com/userquin/vuetify-nuxt-module/blob/main/configuration.d.ts) module)

This module will expose the `$ssrClientHints` property in the Nuxt App instance (`useNuxtApp().$ssrClientHints`) for the headers received from the client (all the properties that are not enabled in the module option will be `undefined`), check the module augmentation for `#app` in the [types.ts](https://github.com/userquin/vuetify-nuxt-module/blob/main/src/types.ts) module for the full definition.

### Reload on First Request

Browsers that support any of the **HTTP Client hints** will send them only after the first request. This module provides support to reload the page when the browser hits the server for the first time.

To enable this feature, you must configure `ssrClientHints.reloadOnFirstRequest` to `true` in the module options.

### Sec-CH-Prefers-Color-Scheme

This module provides support to access to the `prefers-color-scheme` user's preference in the server side, it will not work on first request.

To enable this feature, you must configure `ssrClientHints.prefersColorScheme` to `true` in the module options. To access the value in the server, you can use the `vuetify:ssr-client-hints` hook in your custom Nuxt plugin or using the `$ssrClientHints` property in the Nuxt App instance (`useNuxtApp().$ssrClientHints`).

:::warning
Since the headers sent by the user agent may not be accurate, from time to time your application will get some hydration mismatch warnings in the console.

If you resize the window while your application is loading then you may get a mismatch hydration warning in the console.
:::

#### Multiple Themes

This module also provides support for **multiple themes** via custom HTTP cookie.

To enable this feature, add the following module options:
- `ssrClientHints.prefersColorScheme` to `true`
- `ssrClientHints.prefersColorSchemeOptions`: can be an empty object

where `ssrClientHints.prefersColorSchemeOptions` is an object with the following properties:
- `darkThemeName`: the theme name to be used when the user's preference is `dark` (defaults to `dark`)
- `lightThemeName`: the theme name to be used when the user's preference is `light` (defaults to `light`)
- `cookieName`: the cookie name to store the theme name (defaults to `color-scheme`)
- `useBrowserThemeOnly`: this flag can be used when your application provides a custom dark and light themes, but will not provide a theme selector, i.e., the theme selector will be the one provided by the browser (defaults to `false`)

You also need to configure the default Vuetify theme in the `vuetifyOptions` module option, should be `darkThemeName` or `lightThemeName`, otherwise you will get an error.

`darkThemeName` and `lightThemeName` will be used by the module for the initial theme configuration, when the user changes the application theme (via `useNuxtApp().$vuetify.theme.global.name`), the module will update the cookie with the selected theme.

The module will add the cookie with the following properties:
- `Path` to `nuxt.options.app.baseURL` (defaults to `/`)
- `Expires` to 365 days (will be updated on every page refresh)
- `SameSite` to `Lax`
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ features:
details: Configure your variables using Vuetify SASS Variables
- icon: 💥
title: SSR
details: Automatic SSR detection and configuration
details: Automatic SSR detection and configuration including HTTP Client hints
link: /guide/server-side-rendering
linkText: Server Side Rendering
- icon: 🔩
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "vuetify-nuxt-module",
"type": "module",
"version": "0.5.9",
"packageManager": "pnpm@8.7.4",
"packageManager": "pnpm@8.7.5",
"description": "Zero-Config Nuxt Module for Vuetify",
"author": "userquin <userquin@gmail.com>",
"license": "MIT",
Expand Down
1 change: 0 additions & 1 deletion playground/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
<v-col>
<v-sheet
class="pa-4 d-flex align-center flex-column"
color="grey-lighten-3"
rounded="lg"
>
<NuxtPage />
Expand Down
11 changes: 10 additions & 1 deletion playground/nuxt.config.mts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { availableLocales, langDir } from './config/i18n'
import LayerModule from './layer-module'

// import { transformAssetUrls } from "vite-plugin-vuetify";

export default defineNuxtConfig({
Expand Down Expand Up @@ -30,6 +31,14 @@ export default defineNuxtConfig({
vuetify: {
moduleOptions: {
includeTransformAssetsUrls: true,
ssrClientHints: {
reloadOnFirstRequest: false,
prefersColorScheme: true,
prefersColorSchemeOptions: {
useBrowserThemeOnly: false,
},
viewportSize: true,
},
// styles: { configFile: '/settings.scss' },
},
},
Expand Down Expand Up @@ -132,5 +141,5 @@ export default defineNuxtConfig({
},
devtools: {
enabled: true,
}
},
})
29 changes: 29 additions & 0 deletions playground/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
// import { useLocale, useRtl } from 'vuetify'
import { ssrClientHintsConfiguration } from 'virtual:vuetify-ssr-client-hints-configuration'
const value = reactive<{
name1?: string
Expand All @@ -19,6 +20,21 @@ if (process.client) {
console.log(useNuxtApp().$vuetify.icons)
}
const ssrClientHints = useNuxtApp().$ssrClientHints
const { width, height, md } = useDisplay()
const theme = useTheme()
const enableToogleTheme = computed(() => {
if (ssrClientHintsConfiguration.prefersColorScheme && ssrClientHintsConfiguration.prefersColorSchemeOptions)
return !ssrClientHintsConfiguration.prefersColorSchemeOptions.useBrowserThemeOnly
return false
})
function toogleTheme() {
theme.global.name.value = theme.global.name.value === 'light' ? 'dark' : 'light'
}
// const rtl = ref(isRtl.value)
watch(isRtl, (x) => {
Expand All @@ -36,6 +52,19 @@ watch(current, () => {
<template>
<div>
<v-img src="~/assets/logo.svg" width="48" height="48" />
<div>
<h2>SSR Client Hints Headers:</h2>
<pre class="text-body-2">{{ ssrClientHints }}</pre>
<h2>useDisplay</h2>
<div>Resize the screen and refresh the page</div>
<pre>{{ width }} x {{ height }} (md {{ md }}?)</pre>
<div>
<h2>useTheme: {{ theme.global.name }}</h2>
<v-btn v-if="enableToogleTheme" @click="toogleTheme">
toogle theme
</v-btn>
</div>
</div>
<div>Vuetify useLocale(): {{ current }}</div>
<div>$i18n current: {{ $i18n.locale }}</div>
<div>$vuetify.locale.current: {{ $vuetify.locale.current }}</div>
Expand Down
3 changes: 3 additions & 0 deletions playground/plugins/vuetify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ export default defineNuxtPlugin((nuxtApp) => {
console.log('vuetify:plugin:hook', vuetifyOptions)
}
})
nuxtApp.hook('vuetify:ssr-client-hints', ({ ssrClientHints }) => {
console.log('vuetify:ssr-client-hints', ssrClientHints)
})
})
8 changes: 8 additions & 0 deletions playground/vuetify.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export default defineVuetifyConfiguration({
localeMessages: ['en', 'es', 'ar'], */
theme: {
defaultTheme: 'light',
themes: {
light: {
dark: false,
},
dark: {
dark: true,
},
},
},
date: {
adapter: 'luxon',
Expand Down
2 changes: 2 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ export default defineNuxtModule<ModuleOptions>({
vuetifyFilesToWatch: [],
isSSR: nuxt.options.ssr,
isDev: nuxt.options.dev,
isNuxtGenerate: nuxt.options._generate,
unocss: hasNuxtModule('@unocss/nuxt', nuxt),
i18n: hasNuxtModule('@nuxtjs/i18n', nuxt),
icons: undefined!,
ssrClientHints: undefined!,
componentsPromise: undefined!,
labComponentsPromise: undefined!,
}
Expand Down
21 changes: 21 additions & 0 deletions src/runtime/plugins/client-hints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface ClientHintRequestFeatures {
firstRequest: boolean
prefersColorSchemeAvailable: boolean
prefersReducedMotionAvailable: boolean
viewportHeightAvailable: boolean
viewportWidthAvailable: boolean
}
export interface ClientHintsRequest extends ClientHintRequestFeatures {
prefersColorScheme?: 'dark' | 'light' | 'no-preference'
prefersReducedMotion?: 'no-preference' | 'reduce'
viewportHeight?: number
viewportWidth?: number
colorSchemeFromCookie?: string
colorSchemeCookie?: string
}

export interface SSRClientHints {
ssrClientHints: ClientHintsRequest
}

export const VuetifyHTTPClientHints = 'vuetify:nuxt:ssr-client-hints'

0 comments on commit aaf4226

Please sign in to comment.