From 47a5762b9ec1fefdf51ee41240348c531292e694 Mon Sep 17 00:00:00 2001 From: juno-the-programmer Date: Fri, 2 Jan 2026 15:40:57 +0800 Subject: [PATCH] feat: add CSP nonce support to Portal --- src/Portal.tsx | 16 +++-- src/useScrollLocker.tsx | 19 +++++- tests/index.test.tsx | 125 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/Portal.tsx b/src/Portal.tsx index 5359cc7..a04e080 100644 --- a/src/Portal.tsx +++ b/src/Portal.tsx @@ -40,6 +40,8 @@ export interface PortalProps { /** Lock screen scroll when open */ autoLock?: boolean; onEsc?: EscCallback; + /** Nonce for Content Security Policy */ + nonce?: string; /** @private debug name. Do not use in prod */ debug?: string; @@ -72,6 +74,7 @@ const Portal = React.forwardRef((props, ref) => { autoDestroy = true, children, onEsc, + nonce, } = props; const [shouldRender, setShouldRender] = React.useState(open); @@ -117,13 +120,14 @@ const Portal = React.forwardRef((props, ref) => { const mergedContainer = innerContainer ?? defaultContainer; // ========================= Locker ========================== - useScrollLocker( + const shouldLock = autoLock && - open && - canUseDom() && - (mergedContainer === defaultContainer || - mergedContainer === document.body), - ); + open && + canUseDom() && + (mergedContainer === defaultContainer || + mergedContainer === document.body); + + useScrollLocker({ lock: shouldLock, nonce }); // ========================= Esc Keydown ========================== useEscKeyDown(open, onEsc); diff --git a/src/useScrollLocker.tsx b/src/useScrollLocker.tsx index 01a6630..b4781df 100644 --- a/src/useScrollLocker.tsx +++ b/src/useScrollLocker.tsx @@ -8,8 +8,18 @@ const UNIQUE_ID = `rc-util-locker-${Date.now()}`; let uuid = 0; -export default function useScrollLocker(lock?: boolean) { - const mergedLock = !!lock; +export interface UseScrollLockerOptions { + lock?: boolean; + nonce?: string; +} + +export default function useScrollLocker( + lock?: boolean | UseScrollLockerOptions, +) { + const options = typeof lock === 'object' ? lock : { lock }; + const mergedLock = !!(typeof lock === 'boolean' ? lock : options.lock); + const nonce = typeof lock === 'object' ? lock.nonce : undefined; + const [id] = React.useState(() => { uuid += 1; return `${UNIQUE_ID}_${uuid}`; @@ -27,6 +37,9 @@ html body { ${isOverflow ? `width: calc(100% - ${scrollbarSize}px);` : ''} }`, id, + { + csp: nonce ? { nonce } : undefined, + }, ); } else { removeCSS(id); @@ -35,5 +48,5 @@ html body { return () => { removeCSS(id); }; - }, [mergedLock, id]); + }, [mergedLock, id, nonce]); } diff --git a/tests/index.test.tsx b/tests/index.test.tsx index bc2b3a0..5dd1e6f 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -467,5 +467,130 @@ describe('Portal', () => { expect.objectContaining({ top: true }), ); }); + + describe('nonce', () => { + it('should apply nonce to style tag when autoLock is enabled', () => { + const testNonce = 'test-nonce-123'; + + render( + +
Content
+
, + ); + + const styleTag = document.querySelector('style[nonce]'); + expect(styleTag).toBeTruthy(); + expect(styleTag?.getAttribute('nonce')).toBe(testNonce); + }); + + it('should not apply nonce when autoLock is disabled', () => { + const testNonce = 'test-nonce-123'; + + render( + +
Content
+
, + ); + + const styleTag = document.querySelector(`style[nonce="${testNonce}"]`); + expect(styleTag).toBeFalsy(); + }); + + it('should remove style tag when portal closes but preserve nonce capability', () => { + const testNonce = 'test-nonce-123'; + + const { rerender } = render( + +
Content
+
, + ); + + expect( + document.querySelector(`style[nonce="${testNonce}"]`), + ).toBeTruthy(); + + rerender( + +
Content
+
, + ); + + expect(document.querySelector(`style[nonce="${testNonce}"]`)).toBeFalsy(); + + // Reopen and verify nonce is still applied + rerender( + +
Content
+
, + ); + + expect( + document.querySelector(`style[nonce="${testNonce}"]`), + ).toBeTruthy(); + }); + + it('should work with custom container and nonce', () => { + const testNonce = 'test-nonce-123'; + + render( + document.body} + nonce={testNonce} + > +
Content
+
, + ); + + const styleTag = document.querySelector('style[nonce]'); + expect(styleTag?.getAttribute('nonce')).toBe(testNonce); + }); + + it('should not apply nonce when rendering to custom non-body container', () => { + const testNonce = 'test-nonce-123'; + const div = document.createElement('div'); + document.body.appendChild(div); + + render( + div} nonce={testNonce}> +
Content
+
, + ); + + // Should not lock body when container is custom div + const styleTag = document.querySelector(`style[nonce="${testNonce}"]`); + expect(styleTag).toBeFalsy(); + + document.body.removeChild(div); + }); + + it('should handle undefined nonce gracefully', () => { + render( + +
Content
+
, + ); + + // Should still create style tag, just without nonce + expect(document.body).toHaveStyle({ + overflowY: 'hidden', + }); + }); + + it('should work in StrictMode with nonce', () => { + const testNonce = 'test-nonce-strict'; + + render( + +
Content
+
, + { wrapper: React.StrictMode }, + ); + + const styleTag = document.querySelector('style[nonce]'); + expect(styleTag?.getAttribute('nonce')).toBe(testNonce); + }); + }); }); });