Skip to content

Commit 223475c

Browse files
committed
feat: support CSP nonce on injected style elements
Add a getNonce() utility that reads the CSP nonce from a <meta property="csp-nonce"> tag (used by Vite) or the __webpack_nonce__ global (used by webpack). Apply the nonce to dynamically injected <style> elements in usePress and usePreventScroll so they work with strict Content Security Policy. Fixes #8273
1 parent 88fc6bd commit 223475c

File tree

5 files changed

+79
-1
lines changed

5 files changed

+79
-1
lines changed

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
chain,
2020
focusWithoutScrolling,
2121
getEventTarget,
22+
getNonce,
2223
getOwnerDocument,
2324
getOwnerWindow,
2425
isMac,
@@ -887,6 +888,10 @@ export function usePress(props: PressHookProps): PressResult {
887888

888889
const style = ownerDocument.createElement('style');
889890
style.id = STYLE_ID;
891+
let nonce = getNonce();
892+
if (nonce) {
893+
style.nonce = nonce;
894+
}
890895
// touchAction: 'manipulation' is supposed to be equivalent, but in
891896
// Safari it causes onPointerCancel not to fire on scroll.
892897
// https://bugs.webkit.org/show_bug.cgi?id=240917

packages/@react-aria/overlays/src/usePreventScroll.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {chain, getActiveElement, getEventTarget, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
13+
import {chain, getActiveElement, getEventTarget, getNonce, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
1414

1515
interface PreventScrollOptions {
1616
/** Whether the scroll lock is disabled. */
@@ -130,6 +130,10 @@ function preventScrollMobileSafari() {
130130
// the window instead.
131131
// This must be applied before the touchstart event as of iOS 26, so inject it as a <style> element.
132132
let style = document.createElement('style');
133+
let nonce = getNonce();
134+
if (nonce) {
135+
style.nonce = nonce;
136+
}
133137
style.textContent = `
134138
@layer {
135139
* {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Returns the CSP nonce, if configured via a `<meta property="csp-nonce">` tag or `__webpack_nonce__`.
3+
* This allows dynamically injected `<style>` elements to work with Content Security Policy.
4+
*/
5+
export function getNonce(): string | undefined {
6+
let meta = typeof document !== 'undefined'
7+
? document.querySelector('meta[property="csp-nonce"]') as HTMLMetaElement | null
8+
: null;
9+
return meta?.nonce || meta?.content || (globalThis as Record<string, any>)['__webpack_nonce__'] || undefined;
10+
}

packages/@react-aria/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants';
5151
export {isCtrlKeyPressed, willOpenKeyboard} from './keyboard';
5252
export {useEnterAnimation, useExitAnimation} from './animation';
5353
export {isFocusable, isTabbable} from './isFocusable';
54+
export {getNonce} from './getNonce';
5455

5556
export type {LoadMoreSentinelProps} from './useLoadMoreSentinel';
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {getNonce} from '../';
14+
15+
describe('getNonce', () => {
16+
afterEach(() => {
17+
document.querySelectorAll('meta[property="csp-nonce"]').forEach(el => el.remove());
18+
delete globalThis['__webpack_nonce__'];
19+
});
20+
21+
it('returns undefined when no nonce is configured', () => {
22+
expect(getNonce()).toBeUndefined();
23+
});
24+
25+
it('reads nonce from meta tag nonce attribute', () => {
26+
let meta = document.createElement('meta');
27+
meta.setAttribute('property', 'csp-nonce');
28+
meta.nonce = 'test-nonce-123';
29+
document.head.appendChild(meta);
30+
31+
expect(getNonce()).toBe('test-nonce-123');
32+
});
33+
34+
it('reads nonce from meta tag content attribute', () => {
35+
let meta = document.createElement('meta');
36+
meta.setAttribute('property', 'csp-nonce');
37+
meta.setAttribute('content', 'content-nonce-456');
38+
document.head.appendChild(meta);
39+
40+
expect(getNonce()).toBe('content-nonce-456');
41+
});
42+
43+
it('reads nonce from __webpack_nonce__ global', () => {
44+
globalThis['__webpack_nonce__'] = 'webpack-nonce-789';
45+
46+
expect(getNonce()).toBe('webpack-nonce-789');
47+
});
48+
49+
it('prefers meta tag nonce over __webpack_nonce__', () => {
50+
let meta = document.createElement('meta');
51+
meta.setAttribute('property', 'csp-nonce');
52+
meta.nonce = 'meta-nonce';
53+
document.head.appendChild(meta);
54+
globalThis['__webpack_nonce__'] = 'webpack-nonce';
55+
56+
expect(getNonce()).toBe('meta-nonce');
57+
});
58+
});

0 commit comments

Comments
 (0)