Skip to content

Commit e1d9d63

Browse files
Improve client hint handling with error resilience and reload prevention
Co-authored-by: me <me@kentcdodds.com>
1 parent 2a4e223 commit e1d9d63

File tree

2 files changed

+144
-2
lines changed

2 files changed

+144
-2
lines changed

src/index.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,18 @@ export function getHintUtils<Hints extends Record<string, ClientHint<any>>>(
1818
.find((c: string) => c.startsWith(hint.cookieName + '='))
1919
?.split('=')[1]
2020

21-
return value ? decodeURIComponent(value) : null
21+
if (!value) return null
22+
23+
try {
24+
return decodeURIComponent(value)
25+
} catch (error) {
26+
// Handle malformed URI gracefully by falling back to null
27+
// This prevents crashes and allows the hint's fallback value to be used
28+
console.warn(
29+
`Failed to decode cookie value for ${hint.cookieName}: ${error}`,
30+
)
31+
return null
32+
}
2233
}
2334

2435
function getHints(request?: Request): ClientHintsValue<Hints> {
@@ -77,20 +88,44 @@ function checkClientHints() {
7788
})
7889
.join(',\n')}
7990
];
91+
92+
// Add safety check to prevent infinite refresh scenarios
93+
let reloadAttempts = parseInt(sessionStorage.getItem('clientHintReloadAttempts') || '0');
94+
if (reloadAttempts > 3) {
95+
console.warn('Too many client hint reload attempts, skipping reload to prevent infinite loop');
96+
return;
97+
}
98+
8099
for (const hint of hints) {
81100
document.cookie = encodeURIComponent(hint.name) + '=' + encodeURIComponent(hint.actual) + '; Max-Age=31536000; SameSite=Lax; path=/';
82-
if (decodeURIComponent(hint.value) !== hint.actual) {
101+
102+
try {
103+
const decodedValue = decodeURIComponent(hint.value);
104+
if (decodedValue !== hint.actual) {
105+
cookieChanged = true;
106+
}
107+
} catch (error) {
108+
// Handle malformed URI gracefully
109+
console.warn('Failed to decode cookie value during client hint check:', error);
110+
// If we can't decode the value, assume it's different to be safe
83111
cookieChanged = true;
84112
}
85113
}
114+
86115
if (cookieChanged) {
116+
// Increment reload attempts counter
117+
sessionStorage.setItem('clientHintReloadAttempts', String(reloadAttempts + 1));
118+
87119
// Hide the page content immediately to prevent visual flicker
88120
const style = document.createElement('style');
89121
style.textContent = 'html { visibility: hidden !important; }';
90122
document.head.appendChild(style);
91123
92124
// Trigger the reload
93125
window.location.reload();
126+
} else {
127+
// Reset reload attempts counter if no reload was needed
128+
sessionStorage.removeItem('clientHintReloadAttempts');
94129
}
95130
}
96131

test/index.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,110 @@ test('getting values from document', () => {
8484
delete global.document
8585
}
8686
})
87+
88+
test('handles malformed URI in cookie values gracefully', () => {
89+
const hints = getHintUtils({
90+
colorScheme: colorSchemeHint,
91+
timeZone: timeZoneHint,
92+
reducedMotion: reducedMotionHint,
93+
})
94+
95+
// Test with malformed URI that would cause decodeURIComponent to fail
96+
const request = new Request('https://example.com', {
97+
headers: {
98+
Cookie:
99+
'CH-prefers-color-scheme=dark; CH-time-zone=%C0%AF; CH-reduced-motion=reduce',
100+
},
101+
})
102+
103+
// The malformed timezone should fall back to the fallback value
104+
const result = hints.getHints(request)
105+
assert.strictEqual(result.colorScheme, 'dark')
106+
assert.strictEqual(result.timeZone, timeZoneHint.fallback) // Should fall back due to malformed URI
107+
assert.strictEqual(result.reducedMotion, 'reduce')
108+
})
109+
110+
test('handles completely malformed cookie values', () => {
111+
const hints = getHintUtils({
112+
colorScheme: colorSchemeHint,
113+
timeZone: timeZoneHint,
114+
reducedMotion: reducedMotionHint,
115+
})
116+
117+
// Test with completely invalid URI sequences
118+
const request = new Request('https://example.com', {
119+
headers: {
120+
Cookie:
121+
'CH-prefers-color-scheme=%C0%AF; CH-time-zone=%FF%FE; CH-reduced-motion=%E0%80%80',
122+
},
123+
})
124+
125+
// All malformed values should fall back to their fallback values
126+
const result = hints.getHints(request)
127+
assert.strictEqual(result.colorScheme, colorSchemeHint.fallback)
128+
assert.strictEqual(result.timeZone, timeZoneHint.fallback)
129+
assert.strictEqual(result.reducedMotion, reducedMotionHint.fallback)
130+
})
131+
132+
test('handles mixed valid and invalid cookie values', () => {
133+
const hints = getHintUtils({
134+
colorScheme: colorSchemeHint,
135+
timeZone: timeZoneHint,
136+
reducedMotion: reducedMotionHint,
137+
})
138+
139+
// Test with mix of valid and invalid values
140+
const request = new Request('https://example.com', {
141+
headers: {
142+
Cookie:
143+
'CH-prefers-color-scheme=light; CH-time-zone=%C0%AF; CH-reduced-motion=no-preference',
144+
},
145+
})
146+
147+
// Valid values should work, invalid ones should fall back
148+
const result = hints.getHints(request)
149+
assert.strictEqual(result.colorScheme, 'light') // Valid value
150+
assert.strictEqual(result.timeZone, timeZoneHint.fallback) // Invalid value, should fall back
151+
assert.strictEqual(result.reducedMotion, 'no-preference') // Valid value
152+
})
153+
154+
test('handles empty cookie values gracefully', () => {
155+
const hints = getHintUtils({
156+
colorScheme: colorSchemeHint,
157+
timeZone: timeZoneHint,
158+
reducedMotion: reducedMotionHint,
159+
})
160+
161+
// Test with empty cookie values
162+
const request = new Request('https://example.com', {
163+
headers: {
164+
Cookie: 'CH-prefers-color-scheme=; CH-time-zone=; CH-reduced-motion=',
165+
},
166+
})
167+
168+
// Empty values should fall back to fallback values
169+
const result = hints.getHints(request)
170+
assert.strictEqual(result.colorScheme, colorSchemeHint.fallback)
171+
assert.strictEqual(result.timeZone, timeZoneHint.fallback)
172+
assert.strictEqual(result.reducedMotion, reducedMotionHint.fallback)
173+
})
174+
175+
test('client script includes infinite refresh prevention', () => {
176+
const hints = getHintUtils({
177+
colorScheme: colorSchemeHint,
178+
timeZone: timeZoneHint,
179+
reducedMotion: reducedMotionHint,
180+
})
181+
182+
const checkScript = hints.getClientHintCheckScript()
183+
184+
// Should include sessionStorage check for infinite refresh prevention
185+
assert.ok(checkScript.includes('sessionStorage.getItem'))
186+
assert.ok(checkScript.includes('clientHintReloadAttempts'))
187+
assert.ok(checkScript.includes('Too many client hint reload attempts'))
188+
189+
// Should include try-catch around decodeURIComponent
190+
assert.ok(checkScript.includes('try'))
191+
assert.ok(checkScript.includes('catch'))
192+
assert.ok(checkScript.includes('decodeURIComponent'))
193+
})

0 commit comments

Comments
 (0)