Skip to content

Commit 8292233

Browse files
authored
fix(ssr): prevent reloadOnFirstRequest infinite reload loop in hint-stripping browsers (#334) (#376)
* feat(ssr): add one-shot first-request reload guard helpers (#334) * fix(ssr): guard reloadOnFirstRequest against infinite reload loop (#334) * fix(ssr): write reload guard cookie only when reloading; test path arg (#334)
1 parent 7e4bc9b commit 8292233

3 files changed

Lines changed: 71 additions & 7 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Session cookie marking that the one-time `reloadOnFirstRequest` reload has
3+
* already happened this browser session. Prevents an infinite reload loop when
4+
* a browser requests client hints but never delivers them (e.g. Brave Shields
5+
* strip `Sec-CH-*`), since `firstRequest` would otherwise stay `true` forever (#334).
6+
*/
7+
export const RELOAD_GUARD_COOKIE = 'vuetify-nuxt-client-hints-reloaded'
8+
9+
/** True when the guard cookie is present in a `document.cookie` string. */
10+
export function hasReloadGuardCookie (cookie: string): boolean {
11+
const prefix = `${RELOAD_GUARD_COOKIE}=`
12+
return cookie.split(';').some(c => c.trim().startsWith(prefix))
13+
}
14+
15+
/** Build a session guard cookie (no expiry → cleared on browser close). */
16+
export function buildReloadGuardCookie (path: string): string {
17+
return `${RELOAD_GUARD_COOKIE}=1; Path=${path}; SameSite=Lax`
18+
}
19+
20+
/** Whether to perform the first-request reload: only once per session. */
21+
export function shouldReloadOnFirstRequest (
22+
firstRequest: boolean,
23+
reloadOnFirstRequest: boolean,
24+
alreadyReloaded: boolean,
25+
): boolean {
26+
return firstRequest && reloadOnFirstRequest && !alreadyReloaded
27+
}

packages/vuetify-nuxt-module/src/runtime/plugins/vuetify-client-hints.client.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { defineNuxtPlugin, useNuxtApp, useState } from '#imports'
55
import { ssrClientHintsConfiguration } from 'virtual:vuetify-ssr-client-hints-configuration'
66
import { reactive, ref, watch } from 'vue'
77
import { VuetifyHTTPClientHints } from './client-hints'
8+
import { buildReloadGuardCookie, hasReloadGuardCookie, shouldReloadOnFirstRequest } from './first-request-reload-guard'
89

910
const plugin: Plugin<{
1011
ssrClientHints: UnwrapNestedRefs<SSRClientHints>
@@ -31,8 +32,17 @@ const plugin: Plugin<{
3132
prefersColorSchemeOptions,
3233
} = ssrClientHintsConfiguration
3334

34-
// reload the page when it is the first request, explicitly configured, and any feature available
35-
if (firstRequest && reloadOnFirstRequest) {
35+
// reload the page when it is the first request, explicitly configured, and not already
36+
// reloaded this session — the guard cookie prevents an infinite loop when the browser
37+
// requests client hints but never delivers them (e.g. Brave Shields strip Sec-CH-*) (#334)
38+
if (shouldReloadOnFirstRequest(firstRequest, reloadOnFirstRequest, hasReloadGuardCookie(document.cookie))) {
39+
// set the one-shot guard cookie (session cookie) immediately before reloading so a
40+
// browser that never delivers client hints (e.g. Brave) reloads at most once (#334)
41+
const markAndReload = () => {
42+
// eslint-disable-next-line unicorn/no-document-cookie
43+
document.cookie = buildReloadGuardCookie(prefersColorSchemeOptions?.baseUrl ?? '/')
44+
window.location.reload()
45+
}
3646
if (prefersColorScheme) {
3747
const themeCookie = state.value.colorSchemeCookie
3848
// write the cookie and refresh the page if configured
@@ -44,22 +54,22 @@ const plugin: Plugin<{
4454
const newThemeName = prefersDark ? prefersColorSchemeOptions.darkThemeName : prefersColorSchemeOptions.lightThemeName
4555
// eslint-disable-next-line unicorn/no-document-cookie
4656
document.cookie = themeCookie.replace(cookieEntry, `${cookieName}=${newThemeName};`)
47-
window.location.reload()
57+
markAndReload()
4858
} else if (prefersColorSchemeAvailable) {
49-
window.location.reload()
59+
markAndReload()
5060
}
5161
}
5262

5363
if (prefersReducedMotion && prefersReducedMotionAvailable) {
54-
window.location.reload()
64+
markAndReload()
5565
}
5666

5767
if (viewportSize && viewportHeightAvailable) {
58-
window.location.reload()
68+
markAndReload()
5969
}
6070

6171
if (viewportSize && viewportWidthAvailable) {
62-
window.location.reload()
72+
markAndReload()
6373
}
6474
}
6575

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { buildReloadGuardCookie, hasReloadGuardCookie, RELOAD_GUARD_COOKIE, shouldReloadOnFirstRequest } from '../src/runtime/plugins/first-request-reload-guard'
3+
4+
describe('first-request reload guard', () => {
5+
it('detects the guard cookie among others', () => {
6+
expect(hasReloadGuardCookie(`color-scheme=dark; ${RELOAD_GUARD_COOKIE}=1`)).toBe(true)
7+
expect(hasReloadGuardCookie('color-scheme=dark')).toBe(false)
8+
expect(hasReloadGuardCookie('')).toBe(false)
9+
expect(hasReloadGuardCookie(`x-${RELOAD_GUARD_COOKIE}=1`)).toBe(false)
10+
})
11+
12+
it('builds a session cookie (no Expires/Max-Age)', () => {
13+
const c = buildReloadGuardCookie('/')
14+
expect(c).toContain(`${RELOAD_GUARD_COOKIE}=1`)
15+
expect(c).toContain('Path=/')
16+
expect(c).toContain('SameSite=Lax')
17+
expect(c).not.toMatch(/Expires|Max-Age/i)
18+
expect(buildReloadGuardCookie('/app')).toContain('Path=/app')
19+
})
20+
21+
it('reloads only on a first request when configured and not already reloaded', () => {
22+
expect(shouldReloadOnFirstRequest(true, true, false)).toBe(true)
23+
expect(shouldReloadOnFirstRequest(true, true, true)).toBe(false)
24+
expect(shouldReloadOnFirstRequest(false, true, false)).toBe(false)
25+
expect(shouldReloadOnFirstRequest(true, false, false)).toBe(false)
26+
})
27+
})

0 commit comments

Comments
 (0)