Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +74,7 @@ const Portal = React.forwardRef<any, PortalProps>((props, ref) => {
autoDestroy = true,
children,
onEsc,
nonce,
} = props;

const [shouldRender, setShouldRender] = React.useState(open);
Expand Down Expand Up @@ -117,13 +120,14 @@ const Portal = React.forwardRef<any, PortalProps>((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);
Expand Down
19 changes: 16 additions & 3 deletions src/useScrollLocker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +19 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current logic for parsing the lock parameter can be simplified for better readability and to handle null values gracefully. Currently, passing null as the lock argument would cause a runtime error because typeof null evaluates to 'object', leading to an attempt to access a property on a null value. This refactoring makes the code more robust and easier to understand.

Suggested change
const options = typeof lock === 'object' ? lock : { lock };
const mergedLock = !!(typeof lock === 'boolean' ? lock : options.lock);
const nonce = typeof lock === 'object' ? lock.nonce : undefined;
const isOptions = typeof lock === 'object' && lock !== null;
const mergedLock = !!(isOptions ? lock.lock : lock);
const nonce = isOptions ? lock.nonce : undefined;


const [id] = React.useState(() => {
uuid += 1;
return `${UNIQUE_ID}_${uuid}`;
Expand All @@ -27,6 +37,9 @@ html body {
${isOverflow ? `width: calc(100% - ${scrollbarSize}px);` : ''}
}`,
id,
{
csp: nonce ? { nonce } : undefined,
},
);
} else {
removeCSS(id);
Expand All @@ -35,5 +48,5 @@ html body {
return () => {
removeCSS(id);
};
}, [mergedLock, id]);
}, [mergedLock, id, nonce]);
}
125 changes: 125 additions & 0 deletions tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Portal open autoLock nonce={testNonce}>
<div>Content</div>
</Portal>,
);

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(
<Portal open nonce={testNonce}>
<div>Content</div>
</Portal>,
);

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(
<Portal open autoLock nonce={testNonce}>
<div>Content</div>
</Portal>,
);

expect(
document.querySelector(`style[nonce="${testNonce}"]`),
).toBeTruthy();

rerender(
<Portal open={false} autoLock nonce={testNonce}>
<div>Content</div>
</Portal>,
);

expect(document.querySelector(`style[nonce="${testNonce}"]`)).toBeFalsy();

// Reopen and verify nonce is still applied
rerender(
<Portal open autoLock nonce={testNonce}>
<div>Content</div>
</Portal>,
);

expect(
document.querySelector(`style[nonce="${testNonce}"]`),
).toBeTruthy();
});

it('should work with custom container and nonce', () => {
const testNonce = 'test-nonce-123';

render(
<Portal
open
autoLock
getContainer={() => document.body}
nonce={testNonce}
>
<div>Content</div>
</Portal>,
);

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(
<Portal open autoLock getContainer={() => div} nonce={testNonce}>
<div>Content</div>
</Portal>,
);

// 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(
<Portal open autoLock>
<div>Content</div>
</Portal>,
);

// 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(
<Portal open autoLock nonce={testNonce}>
<div>Content</div>
</Portal>,
{ wrapper: React.StrictMode },
);

const styleTag = document.querySelector('style[nonce]');
expect(styleTag?.getAttribute('nonce')).toBe(testNonce);
});
});
});
});