From 1965848b8d399bb0788bb38bade90986191d2fdb Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Wed, 3 Dec 2025 23:54:40 +0900 Subject: [PATCH 1/9] fix(overlays): cap visualViewport width at clientWidth to handle scrollbar gutter --- packages/@react-aria/overlays/src/calculatePosition.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index e5df4569701..a70f8b76d31 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -120,6 +120,12 @@ function getContainerDimensions(containerNode: Element, visualViewport: VisualVi totalHeight = documentElement.clientHeight; width = visualViewport?.width ?? totalWidth; height = visualViewport?.height ?? totalHeight; + + // If the visual viewport is larger than the client width, it means that the scrollbar gutter is taking up space + // that the visual viewport is not accounting for. In this case, we should cap the width at the client width. + if (width > documentElement.clientWidth) { + width = documentElement.clientWidth; + } scroll.top = documentElement.scrollTop || containerNode.scrollTop; scroll.left = documentElement.scrollLeft || containerNode.scrollLeft; @@ -587,7 +593,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult { } export function getRect(node: Element, ignoreScale: boolean) { - let {top, left, width, height} = node.getBoundingClientRect(); + let { top, left, width, height } = node.getBoundingClientRect(); // Use offsetWidth and offsetHeight if this is an HTML element, so that // the size is not affected by scale transforms. From 0bbdf4a623e5318b919d9ad4eee3165c36b26069 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 4 Dec 2025 00:32:43 +0900 Subject: [PATCH 2/9] fix(overlays): delete space --- packages/@react-aria/overlays/src/calculatePosition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index a70f8b76d31..5a24576d1f0 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -593,7 +593,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult { } export function getRect(node: Element, ignoreScale: boolean) { - let { top, left, width, height } = node.getBoundingClientRect(); + let {top, left, width, height} = node.getBoundingClientRect(); // Use offsetWidth and offsetHeight if this is an HTML element, so that // the size is not affected by scale transforms. From 233013eb36b5121eaf9cb81a80094b230dcf7b1c Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 4 Dec 2025 02:16:04 +0900 Subject: [PATCH 3/9] test(overlays): add visualViewport test case for scrollbar gutter handling --- .../overlays/test/calculatePosition.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/@react-aria/overlays/test/calculatePosition.test.ts b/packages/@react-aria/overlays/test/calculatePosition.test.ts index 3f9b4db7d86..fe1c1339172 100644 --- a/packages/@react-aria/overlays/test/calculatePosition.test.ts +++ b/packages/@react-aria/overlays/test/calculatePosition.test.ts @@ -446,4 +446,76 @@ describe('calculatePosition', function () { document.body.removeChild(target); }); }); + + describe('visualViewport larger than clientWidth (scrollbar gutter issue)', () => { + let clientWidthSpy; + + afterEach(() => { + if (clientWidthSpy) { + clientWidthSpy.mockRestore(); + } + }); + + it('caps width at clientWidth', () => { + // Mock clientWidth to be smaller than visualViewport + clientWidthSpy = jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => 985); + + // Mock visualViewport + window.visualViewport = { + width: 1000, + height: 600, + offsetLeft: 0, + offsetTop: 0, + pageLeft: 0, + pageTop: 0, + scale: 1, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + onresize: null, + onscroll: null + } as VisualViewport; + + const target = document.createElement('div'); + const overlayNode = document.createElement('div'); + // Use body as boundary to trigger the specific code path + const container = document.body; + + // Setup target position near the right edge + // Target at left=900, width=50. Center is 925. + jest.spyOn(target, 'getBoundingClientRect').mockImplementation(() => ({ + top: 0, left: 900, width: 50, height: 50, right: 950, bottom: 50, x: 900, y: 0, toJSON: () => { } + })); + + // Setup overlay size + // Width=200. + jest.spyOn(overlayNode, 'getBoundingClientRect').mockImplementation(() => ({ + top: 0, left: 0, width: 200, height: 200, right: 200, bottom: 200, x: 0, y: 0, toJSON: () => { } + })); + jest.spyOn(overlayNode, 'offsetWidth', 'get').mockImplementation(() => 200); + jest.spyOn(overlayNode, 'offsetHeight', 'get').mockImplementation(() => 200); + + let result = calculatePosition({ + placement: 'bottom', + overlayNode, + targetNode: target, + scrollNode: overlayNode, + padding: 0, + shouldFlip: false, + boundaryElement: container, + offset: 0, + crossOffset: 0, + arrowSize: 0 + }); + + // Expected calculation: + // Boundary width should be capped at 985 (clientWidth) instead of 1000 (visualViewport). + // Overlay width is 200. + // Max allowed left position = 985 - 200 = 785. + // Target center is 925. Centered overlay would be 925 - 100 = 825. + // 825 > 785, so it should be clamped to 785. + + expect(result.position.left).toBe(785); + }); + }); }); From 619b900e70143d2319b68c0d71c492007a5af552 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 4 Dec 2025 02:23:03 +0900 Subject: [PATCH 4/9] chore(overlays): remove outdated scrollbar gutter handling logic test --- packages/@react-aria/overlays/src/calculatePosition.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 5a24576d1f0..c516ae9a1e9 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -123,9 +123,9 @@ function getContainerDimensions(containerNode: Element, visualViewport: VisualVi // If the visual viewport is larger than the client width, it means that the scrollbar gutter is taking up space // that the visual viewport is not accounting for. In this case, we should cap the width at the client width. - if (width > documentElement.clientWidth) { - width = documentElement.clientWidth; - } + // if (width > documentElement.clientWidth) { + // width = documentElement.clientWidth; + // } scroll.top = documentElement.scrollTop || containerNode.scrollTop; scroll.left = documentElement.scrollLeft || containerNode.scrollLeft; From efc27d7b2bd063802050b65dc84d68f9c03f9251 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 4 Dec 2025 02:25:22 +0900 Subject: [PATCH 5/9] chore(overlays): comment out outdated visualViewport scrollbar gutter test --- .../overlays/test/calculatePosition.test.ts | 142 +++++++++--------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/packages/@react-aria/overlays/test/calculatePosition.test.ts b/packages/@react-aria/overlays/test/calculatePosition.test.ts index fe1c1339172..1131acf4975 100644 --- a/packages/@react-aria/overlays/test/calculatePosition.test.ts +++ b/packages/@react-aria/overlays/test/calculatePosition.test.ts @@ -447,75 +447,75 @@ describe('calculatePosition', function () { }); }); - describe('visualViewport larger than clientWidth (scrollbar gutter issue)', () => { - let clientWidthSpy; - - afterEach(() => { - if (clientWidthSpy) { - clientWidthSpy.mockRestore(); - } - }); - - it('caps width at clientWidth', () => { - // Mock clientWidth to be smaller than visualViewport - clientWidthSpy = jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => 985); - - // Mock visualViewport - window.visualViewport = { - width: 1000, - height: 600, - offsetLeft: 0, - offsetTop: 0, - pageLeft: 0, - pageTop: 0, - scale: 1, - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - onresize: null, - onscroll: null - } as VisualViewport; - - const target = document.createElement('div'); - const overlayNode = document.createElement('div'); - // Use body as boundary to trigger the specific code path - const container = document.body; - - // Setup target position near the right edge - // Target at left=900, width=50. Center is 925. - jest.spyOn(target, 'getBoundingClientRect').mockImplementation(() => ({ - top: 0, left: 900, width: 50, height: 50, right: 950, bottom: 50, x: 900, y: 0, toJSON: () => { } - })); - - // Setup overlay size - // Width=200. - jest.spyOn(overlayNode, 'getBoundingClientRect').mockImplementation(() => ({ - top: 0, left: 0, width: 200, height: 200, right: 200, bottom: 200, x: 0, y: 0, toJSON: () => { } - })); - jest.spyOn(overlayNode, 'offsetWidth', 'get').mockImplementation(() => 200); - jest.spyOn(overlayNode, 'offsetHeight', 'get').mockImplementation(() => 200); - - let result = calculatePosition({ - placement: 'bottom', - overlayNode, - targetNode: target, - scrollNode: overlayNode, - padding: 0, - shouldFlip: false, - boundaryElement: container, - offset: 0, - crossOffset: 0, - arrowSize: 0 - }); - - // Expected calculation: - // Boundary width should be capped at 985 (clientWidth) instead of 1000 (visualViewport). - // Overlay width is 200. - // Max allowed left position = 985 - 200 = 785. - // Target center is 925. Centered overlay would be 925 - 100 = 825. - // 825 > 785, so it should be clamped to 785. - - expect(result.position.left).toBe(785); - }); - }); + // describe('visualViewport larger than clientWidth (scrollbar gutter issue)', () => { + // let clientWidthSpy; + // + // afterEach(() => { + // if (clientWidthSpy) { + // clientWidthSpy.mockRestore(); + // } + // }); + // + // it('caps width at clientWidth', () => { + // // Mock clientWidth to be smaller than visualViewport + // clientWidthSpy = jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => 985); + // + // // Mock visualViewport + // window.visualViewport = { + // width: 1000, + // height: 600, + // offsetLeft: 0, + // offsetTop: 0, + // pageLeft: 0, + // pageTop: 0, + // scale: 1, + // addEventListener: jest.fn(), + // removeEventListener: jest.fn(), + // dispatchEvent: jest.fn(), + // onresize: null, + // onscroll: null + // } as VisualViewport; + // + // const target = document.createElement('div'); + // const overlayNode = document.createElement('div'); + // // Use body as boundary to trigger the specific code path + // const container = document.body; + // + // // Setup target position near the right edge + // // Target at left=900, width=50. Center is 925. + // jest.spyOn(target, 'getBoundingClientRect').mockImplementation(() => ({ + // top: 0, left: 900, width: 50, height: 50, right: 950, bottom: 50, x: 900, y: 0, toJSON: () => { } + // })); + // + // // Setup overlay size + // // Width=200. + // jest.spyOn(overlayNode, 'getBoundingClientRect').mockImplementation(() => ({ + // top: 0, left: 0, width: 200, height: 200, right: 200, bottom: 200, x: 0, y: 0, toJSON: () => { } + // })); + // jest.spyOn(overlayNode, 'offsetWidth', 'get').mockImplementation(() => 200); + // jest.spyOn(overlayNode, 'offsetHeight', 'get').mockImplementation(() => 200); + // + // let result = calculatePosition({ + // placement: 'bottom', + // overlayNode, + // targetNode: target, + // scrollNode: overlayNode, + // padding: 0, + // shouldFlip: false, + // boundaryElement: container, + // offset: 0, + // crossOffset: 0, + // arrowSize: 0 + // }); + // + // // Expected calculation: + // // Boundary width should be capped at 985 (clientWidth) instead of 1000 (visualViewport). + // // Overlay width is 200. + // // Max allowed left position = 985 - 200 = 785. + // // Target center is 925. Centered overlay would be 925 - 100 = 825. + // // 825 > 785, so it should be clamped to 785. + // + // expect(result.position.left).toBe(785); + // }); + // }); }); From c47c4f5b753c7236f307b9e045165e5ea24eb009 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 4 Dec 2025 02:48:40 +0900 Subject: [PATCH 6/9] Revert "chore(overlays): comment out outdated visualViewport scrollbar gutter test" This reverts commit efc27d7b2bd063802050b65dc84d68f9c03f9251. --- .../overlays/test/calculatePosition.test.ts | 142 +++++++++--------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/packages/@react-aria/overlays/test/calculatePosition.test.ts b/packages/@react-aria/overlays/test/calculatePosition.test.ts index 1131acf4975..fe1c1339172 100644 --- a/packages/@react-aria/overlays/test/calculatePosition.test.ts +++ b/packages/@react-aria/overlays/test/calculatePosition.test.ts @@ -447,75 +447,75 @@ describe('calculatePosition', function () { }); }); - // describe('visualViewport larger than clientWidth (scrollbar gutter issue)', () => { - // let clientWidthSpy; - // - // afterEach(() => { - // if (clientWidthSpy) { - // clientWidthSpy.mockRestore(); - // } - // }); - // - // it('caps width at clientWidth', () => { - // // Mock clientWidth to be smaller than visualViewport - // clientWidthSpy = jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => 985); - // - // // Mock visualViewport - // window.visualViewport = { - // width: 1000, - // height: 600, - // offsetLeft: 0, - // offsetTop: 0, - // pageLeft: 0, - // pageTop: 0, - // scale: 1, - // addEventListener: jest.fn(), - // removeEventListener: jest.fn(), - // dispatchEvent: jest.fn(), - // onresize: null, - // onscroll: null - // } as VisualViewport; - // - // const target = document.createElement('div'); - // const overlayNode = document.createElement('div'); - // // Use body as boundary to trigger the specific code path - // const container = document.body; - // - // // Setup target position near the right edge - // // Target at left=900, width=50. Center is 925. - // jest.spyOn(target, 'getBoundingClientRect').mockImplementation(() => ({ - // top: 0, left: 900, width: 50, height: 50, right: 950, bottom: 50, x: 900, y: 0, toJSON: () => { } - // })); - // - // // Setup overlay size - // // Width=200. - // jest.spyOn(overlayNode, 'getBoundingClientRect').mockImplementation(() => ({ - // top: 0, left: 0, width: 200, height: 200, right: 200, bottom: 200, x: 0, y: 0, toJSON: () => { } - // })); - // jest.spyOn(overlayNode, 'offsetWidth', 'get').mockImplementation(() => 200); - // jest.spyOn(overlayNode, 'offsetHeight', 'get').mockImplementation(() => 200); - // - // let result = calculatePosition({ - // placement: 'bottom', - // overlayNode, - // targetNode: target, - // scrollNode: overlayNode, - // padding: 0, - // shouldFlip: false, - // boundaryElement: container, - // offset: 0, - // crossOffset: 0, - // arrowSize: 0 - // }); - // - // // Expected calculation: - // // Boundary width should be capped at 985 (clientWidth) instead of 1000 (visualViewport). - // // Overlay width is 200. - // // Max allowed left position = 985 - 200 = 785. - // // Target center is 925. Centered overlay would be 925 - 100 = 825. - // // 825 > 785, so it should be clamped to 785. - // - // expect(result.position.left).toBe(785); - // }); - // }); + describe('visualViewport larger than clientWidth (scrollbar gutter issue)', () => { + let clientWidthSpy; + + afterEach(() => { + if (clientWidthSpy) { + clientWidthSpy.mockRestore(); + } + }); + + it('caps width at clientWidth', () => { + // Mock clientWidth to be smaller than visualViewport + clientWidthSpy = jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => 985); + + // Mock visualViewport + window.visualViewport = { + width: 1000, + height: 600, + offsetLeft: 0, + offsetTop: 0, + pageLeft: 0, + pageTop: 0, + scale: 1, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + onresize: null, + onscroll: null + } as VisualViewport; + + const target = document.createElement('div'); + const overlayNode = document.createElement('div'); + // Use body as boundary to trigger the specific code path + const container = document.body; + + // Setup target position near the right edge + // Target at left=900, width=50. Center is 925. + jest.spyOn(target, 'getBoundingClientRect').mockImplementation(() => ({ + top: 0, left: 900, width: 50, height: 50, right: 950, bottom: 50, x: 900, y: 0, toJSON: () => { } + })); + + // Setup overlay size + // Width=200. + jest.spyOn(overlayNode, 'getBoundingClientRect').mockImplementation(() => ({ + top: 0, left: 0, width: 200, height: 200, right: 200, bottom: 200, x: 0, y: 0, toJSON: () => { } + })); + jest.spyOn(overlayNode, 'offsetWidth', 'get').mockImplementation(() => 200); + jest.spyOn(overlayNode, 'offsetHeight', 'get').mockImplementation(() => 200); + + let result = calculatePosition({ + placement: 'bottom', + overlayNode, + targetNode: target, + scrollNode: overlayNode, + padding: 0, + shouldFlip: false, + boundaryElement: container, + offset: 0, + crossOffset: 0, + arrowSize: 0 + }); + + // Expected calculation: + // Boundary width should be capped at 985 (clientWidth) instead of 1000 (visualViewport). + // Overlay width is 200. + // Max allowed left position = 985 - 200 = 785. + // Target center is 925. Centered overlay would be 925 - 100 = 825. + // 825 > 785, so it should be clamped to 785. + + expect(result.position.left).toBe(785); + }); + }); }); From 535794e60db1309fa4d1557133c09fbcd6adb2b1 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 4 Dec 2025 02:48:41 +0900 Subject: [PATCH 7/9] Revert "chore(overlays): remove outdated scrollbar gutter handling logic test" This reverts commit 619b900e70143d2319b68c0d71c492007a5af552. --- packages/@react-aria/overlays/src/calculatePosition.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index c516ae9a1e9..5a24576d1f0 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -123,9 +123,9 @@ function getContainerDimensions(containerNode: Element, visualViewport: VisualVi // If the visual viewport is larger than the client width, it means that the scrollbar gutter is taking up space // that the visual viewport is not accounting for. In this case, we should cap the width at the client width. - // if (width > documentElement.clientWidth) { - // width = documentElement.clientWidth; - // } + if (width > documentElement.clientWidth) { + width = documentElement.clientWidth; + } scroll.top = documentElement.scrollTop || containerNode.scrollTop; scroll.left = documentElement.scrollLeft || containerNode.scrollLeft; From f9d56124453c02f2e2f9287b043c6f5b2a06c031 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 4 Dec 2025 03:39:29 +0900 Subject: [PATCH 8/9] fix(overlays): round visualViewport width before comparing to clientWidth --- packages/@react-aria/overlays/src/calculatePosition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 5a24576d1f0..a02de0671dd 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -123,7 +123,7 @@ function getContainerDimensions(containerNode: Element, visualViewport: VisualVi // If the visual viewport is larger than the client width, it means that the scrollbar gutter is taking up space // that the visual viewport is not accounting for. In this case, we should cap the width at the client width. - if (width > documentElement.clientWidth) { + if (Math.round(width) > documentElement.clientWidth) { width = documentElement.clientWidth; } scroll.top = documentElement.scrollTop || containerNode.scrollTop; From f6e4372852728e7131adee62b1384a65884fed42 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Fri, 5 Dec 2025 23:07:31 +0900 Subject: [PATCH 9/9] fix(overlays): use totalWidth to cap visualViewport width --- packages/@react-aria/overlays/src/calculatePosition.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index a02de0671dd..88e4ea90e06 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -123,9 +123,8 @@ function getContainerDimensions(containerNode: Element, visualViewport: VisualVi // If the visual viewport is larger than the client width, it means that the scrollbar gutter is taking up space // that the visual viewport is not accounting for. In this case, we should cap the width at the client width. - if (Math.round(width) > documentElement.clientWidth) { - width = documentElement.clientWidth; - } + width = Math.min(Math.round(width), totalWidth); + scroll.top = documentElement.scrollTop || containerNode.scrollTop; scroll.left = documentElement.scrollLeft || containerNode.scrollLeft;