From 2ec1cfbf85d8437428b318f7f2f0d8fba33e182f Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Thu, 24 Sep 2026 02:30:28 +0100 Subject: [PATCH 1/4] Add failing test for ResizeObserver realm in portaled grids When the grid is portaled into another window (iframe, popup), it should be observed by that window's ResizeObserver. Refs #4184 Co-Authored-By: Claude Opus 5.5 (1M context) --- test/browser/gridDimensions.test.tsx | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 test/browser/gridDimensions.test.tsx diff --git a/test/browser/gridDimensions.test.tsx b/test/browser/gridDimensions.test.tsx new file mode 100644 index 0000000000..cfb5ab3caf --- /dev/null +++ b/test/browser/gridDimensions.test.tsx @@ -0,0 +1,58 @@ +import { createPortal } from 'react-dom'; +import { page } from 'vitest/browser'; + +import { DataGrid, type Column } from '../../src'; + +const columns: readonly Column[] = Array.from({ length: 20 }, (_, i) => ({ + key: String(i), + name: String(i), + width: 100 +})); + +const rows: readonly unknown[] = []; + +async function createIframe(width: number) { + const iframe = document.createElement('iframe'); + iframe.style.width = `${width}px`; + iframe.srcdoc = ''; + + await new Promise((resolve) => { + iframe.addEventListener('load', resolve, { once: true }); + document.body.append(iframe); + }); + + onTestFinished(() => { + iframe.remove(); + }); + + const iframeDocument = iframe.contentDocument!; + + // copy the grid styles into the iframe + for (const style of document.head.querySelectorAll('style')) { + iframeDocument.head.append(style.cloneNode(true)); + } + + return { iframe, iframeDocument, iframeWindow: iframeDocument.defaultView! }; +} + +// https://github.com/Comcast/react-data-grid/issues/4184 +test('should observe grid resizes with the ResizeObserver of the window the grid is rendered in', async () => { + const { iframe, iframeDocument, iframeWindow } = await createIframe(400); + const observeSpy = vi.spyOn(iframeWindow.ResizeObserver.prototype, 'observe'); + + await page.render(createPortal(, iframeDocument.body)); + + const grid = iframeDocument.querySelector('[role="grid"]'); + expect(grid).not.toBeNull(); + expect(observeSpy).toHaveBeenCalledWith(grid); + + function getLastHeaderCellColIndex() { + return iframeDocument.querySelector('[role="columnheader"]:last-child')?.ariaColIndex; + } + + await expect.poll(getLastHeaderCellColIndex).toBe('5'); + + iframe.style.width = '800px'; + + await expect.poll(getLastHeaderCellColIndex).toBe('9'); +}); From 51d1713fa8e218c905c6043b82f3a6b1d01e8649 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Thu, 24 Sep 2026 02:33:11 +0100 Subject: [PATCH 2/4] useGridDimensions: use the ResizeObserver of the grid's window Lazily create one ResizeObserver per window, derived from the grid element's ownerDocument, instead of a single module-level observer created in the realm the module was loaded in. Fixes #4184 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/hooks/useGridDimensions.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/hooks/useGridDimensions.ts b/src/hooks/useGridDimensions.ts index 40c5e11892..fa53a6f390 100644 --- a/src/hooks/useGridDimensions.ts +++ b/src/hooks/useGridDimensions.ts @@ -11,9 +11,24 @@ const sizeMap = new WeakMap, ResizeObserverSize const targetToRefMap = new WeakMap>(); const subscribers = new Map, () => void>(); -// don't break in Node.js (SSR), jsdom, and environments that don't support ResizeObserver -const resizeObserver = - typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resizeObserverCallback); +// one ResizeObserver per window, so that resizes are observed in the window the grid is rendered in, +// for example when the grid is portaled into an iframe or a popup window +const resizeObservers = new WeakMap(); + +function getResizeObserver(target: HTMLDivElement): ResizeObserver | null { + const ownerWindow = target.ownerDocument.defaultView; + + // don't break in jsdom, and environments that don't support ResizeObserver + if (ownerWindow?.ResizeObserver === undefined) return null; + + let resizeObserver = resizeObservers.get(ownerWindow); + if (resizeObserver === undefined) { + resizeObserver = new ownerWindow.ResizeObserver(resizeObserverCallback); + resizeObservers.set(ownerWindow, resizeObserver); + } + + return resizeObserver; +} function resizeObserverCallback(entries: ResizeObserverEntry[]) { for (const entry of entries) { @@ -66,6 +81,7 @@ export function useGridDimensions(gridRef: React.RefObject { const target = gridRef.current!; + const resizeObserver = getResizeObserver(target); targetToRefMap.set(target, gridRef); resizeObserver?.observe(target); From 0151334f7103a24e6d8b1ada4cd39795622b3f1d Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Thu, 24 Sep 2026 02:56:46 +0100 Subject: [PATCH 3/4] Add failing tests for grids portaled into another window Rename gridDimensions.test.tsx to crossWindow.test.tsx, and add expected-failure tests for keyboard navigation, Tab navigation out of an editor, and committing an editor on outside clicks, when the grid is rendered into an iframe. Refs #4184 Co-Authored-By: Claude Opus 5.5 (1M context) --- test/browser/crossWindow.test.tsx | 145 +++++++++++++++++++++++++++ test/browser/gridDimensions.test.tsx | 58 ----------- 2 files changed, 145 insertions(+), 58 deletions(-) create mode 100644 test/browser/crossWindow.test.tsx delete mode 100644 test/browser/gridDimensions.test.tsx diff --git a/test/browser/crossWindow.test.tsx b/test/browser/crossWindow.test.tsx new file mode 100644 index 0000000000..9bb46ce428 --- /dev/null +++ b/test/browser/crossWindow.test.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { page, userEvent } from 'vitest/browser'; + +import { DataGrid, type Column, type RenderEditCellProps } from '../../src'; + +// The grid can be portaled into another window, like an iframe or a popup window. +// Its DOM nodes then belong to that other window, while its JS still runs in this window. +// https://github.com/Comcast/react-data-grid/issues/4184 + +interface Row { + a: string; + b: string; +} + +const columns: readonly Column[] = Array.from({ length: 20 }, (_, i) => ({ + key: String(i), + name: String(i), + width: 100 +})); + +const noRows: readonly unknown[] = []; + +// unlike `renderTextEditor`, this editor does not commit on blur +function renderEditCell({ row, column, onRowChange }: RenderEditCellProps) { + const key = column.key as keyof Row; + + return ( + onRowChange({ ...row, [key]: event.target.value })} + /> + ); +} + +const editableColumns: readonly Column[] = [ + { key: 'a', name: 'A', renderEditCell }, + { key: 'b', name: 'B', renderEditCell } +]; + +const initialRows: readonly Row[] = [ + { a: 'a1', b: 'b1' }, + { a: 'a2', b: 'b2' } +]; + +function EditableGrid() { + const [rows, setRows] = useState(initialRows); + + return ( + <> +
outside
+ + + ); +} + +async function createIframe(width = 400) { + const iframe = document.createElement('iframe'); + iframe.style.width = `${width}px`; + iframe.srcdoc = ''; + + await new Promise((resolve) => { + iframe.addEventListener('load', resolve, { once: true }); + document.body.append(iframe); + }); + + onTestFinished(() => { + iframe.remove(); + }); + + const iframeDocument = iframe.contentDocument!; + + // copy the grid styles into the iframe + for (const style of document.head.querySelectorAll('style')) { + iframeDocument.head.append(style.cloneNode(true)); + } + + return { + iframe, + iframeDocument, + iframeWindow: iframeDocument.defaultView!, + frame: page.frameLocator(page.elementLocator(iframe)) + }; +} + +test('should observe grid resizes with the ResizeObserver of the window the grid is rendered in', async () => { + const { iframe, iframeDocument, iframeWindow } = await createIframe(); + const observeSpy = vi.spyOn(iframeWindow.ResizeObserver.prototype, 'observe'); + + await page.render( + createPortal(, iframeDocument.body) + ); + + const grid = iframeDocument.querySelector('[role="grid"]'); + expect(grid).not.toBeNull(); + expect(observeSpy).toHaveBeenCalledWith(grid); + + function getLastHeaderCellColIndex() { + return iframeDocument.querySelector('[role="columnheader"]:last-child')?.ariaColIndex; + } + + await expect.poll(getLastHeaderCellColIndex).toBe('5'); + + iframe.style.width = '800px'; + + await expect.poll(getLastHeaderCellColIndex).toBe('9'); +}); + +test.fails('should navigate between cells with the keyboard', async () => { + const { iframeDocument, frame } = await createIframe(); + await page.render(createPortal(, iframeDocument.body)); + + await userEvent.click(frame.getCell({ name: 'a1' })); + await expect.element(frame.getActiveCell()).toHaveTextContent('a1'); + await userEvent.keyboard('{ArrowRight}'); + await expect.element(frame.getActiveCell()).toHaveTextContent('b1'); +}); + +test.fails('should commit changes and navigate out of the editor on Tab', async () => { + const { iframeDocument, frame } = await createIframe(); + await page.render(createPortal(, iframeDocument.body)); + const editor = frame.getByRole('textbox', { name: 'editor' }); + + await userEvent.dblClick(frame.getCell({ name: 'a1' })); + await expect.element(editor).toHaveValue('a1'); + await userEvent.keyboard('new{Tab}'); + await expect.element(editor).not.toBeInTheDocument(); + await expect.element(frame.getActiveCell()).toHaveTextContent('b1'); + await expect.element(frame.getCell({ name: 'a1new' })).toBeInTheDocument(); +}); + +test.fails('should commit changes and close the editor when clicked outside', async () => { + const { iframeDocument, frame } = await createIframe(); + await page.render(createPortal(, iframeDocument.body)); + const editor = frame.getByRole('textbox', { name: 'editor' }); + + await userEvent.dblClick(frame.getCell({ name: 'a1' })); + await expect.element(editor).toHaveValue('a1'); + await userEvent.keyboard('new'); + await userEvent.click(frame.getByText('outside')); + await expect.element(editor).not.toBeInTheDocument(); + await expect.element(frame.getCell({ name: 'a1new' })).toBeInTheDocument(); +}); diff --git a/test/browser/gridDimensions.test.tsx b/test/browser/gridDimensions.test.tsx deleted file mode 100644 index cfb5ab3caf..0000000000 --- a/test/browser/gridDimensions.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { createPortal } from 'react-dom'; -import { page } from 'vitest/browser'; - -import { DataGrid, type Column } from '../../src'; - -const columns: readonly Column[] = Array.from({ length: 20 }, (_, i) => ({ - key: String(i), - name: String(i), - width: 100 -})); - -const rows: readonly unknown[] = []; - -async function createIframe(width: number) { - const iframe = document.createElement('iframe'); - iframe.style.width = `${width}px`; - iframe.srcdoc = ''; - - await new Promise((resolve) => { - iframe.addEventListener('load', resolve, { once: true }); - document.body.append(iframe); - }); - - onTestFinished(() => { - iframe.remove(); - }); - - const iframeDocument = iframe.contentDocument!; - - // copy the grid styles into the iframe - for (const style of document.head.querySelectorAll('style')) { - iframeDocument.head.append(style.cloneNode(true)); - } - - return { iframe, iframeDocument, iframeWindow: iframeDocument.defaultView! }; -} - -// https://github.com/Comcast/react-data-grid/issues/4184 -test('should observe grid resizes with the ResizeObserver of the window the grid is rendered in', async () => { - const { iframe, iframeDocument, iframeWindow } = await createIframe(400); - const observeSpy = vi.spyOn(iframeWindow.ResizeObserver.prototype, 'observe'); - - await page.render(createPortal(, iframeDocument.body)); - - const grid = iframeDocument.querySelector('[role="grid"]'); - expect(grid).not.toBeNull(); - expect(observeSpy).toHaveBeenCalledWith(grid); - - function getLastHeaderCellColIndex() { - return iframeDocument.querySelector('[role="columnheader"]:last-child')?.ariaColIndex; - } - - await expect.poll(getLastHeaderCellColIndex).toBe('5'); - - iframe.style.width = '800px'; - - await expect.poll(getLastHeaderCellColIndex).toBe('9'); -}); From 404a35b9a1e9ad40a6b030bb270a7f380e5732eb Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Thu, 24 Sep 2026 02:58:36 +0100 Subject: [PATCH 4/4] Support keyboard navigation and editors in grids portaled into another window - Replace `instanceof` checks, which fail for elements from another window, with a `nodeType` check and `matches()` - Listen for outside `mousedown` events, and schedule the commit check, on the window the editor is rendered in Fixes #4184 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/DataGrid.tsx | 3 +- src/EditCell.tsx | 48 +++++++++++++++++-------------- src/utils/domUtils.ts | 6 ++++ src/utils/keyboardUtils.ts | 8 ++---- test/browser/crossWindow.test.tsx | 6 ++-- 5 files changed, 40 insertions(+), 31 deletions(-) diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index ad622e23d1..018f6c4983 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -33,6 +33,7 @@ import { isCellEditableUtil, isCtrlKeyHeldDown, isDefaultCellInput, + isElement, renderMeasuringCells, scrollIntoView } from './utils'; @@ -616,7 +617,7 @@ export function DataGrid(props: DataGridPr const { target } = event; - if (!(target instanceof Element)) return; + if (!isElement(target)) return; const cell = target.closest('.rdg-cell'); const isCellEvent = cell !== null; diff --git a/src/EditCell.tsx b/src/EditCell.tsx index e9b04cbe56..b52fbe3b06 100644 --- a/src/EditCell.tsx +++ b/src/EditCell.tsx @@ -25,10 +25,15 @@ import type { * so `mousedown` is used instead of `click`. * * We must also rely on React's event capturing/bubbling to handle elements rendered in a portal. + * + * The grid may be portaled into another window, like an iframe or a popup window, + * so we listen to events and schedule tasks on the window the editor is rendered in. */ // TODO: remove when all browsers support the scheduler APIs -const canUsePostTask = typeof scheduler === 'object' && typeof scheduler.postTask === 'function'; +function canUsePostTask({ scheduler }: Window & typeof globalThis) { + return typeof scheduler === 'object' && typeof scheduler.postTask === 'function'; +} const cellEditing = css` @layer rdg.EditCell { @@ -59,9 +64,9 @@ export default function EditCell({ onKeyDown, navigate }: EditCellProps) { + const editCellRef = useRef(null); const captureEventRef = useRef(undefined); - const abortControllerRef = useRef(undefined); - const frameRequestRef = useRef(undefined); + const cancelScheduledTaskRef = useRef<() => void>(undefined); const commitOnOutsideClick = column.editorOptions?.commitOnOutsideClick ?? true; // We need to prevent the `useLayoutEffect` from cleaning up between re-renders, @@ -74,24 +79,30 @@ export default function EditCell({ useLayoutEffect(() => { if (!commitOnOutsideClick) return; + const ownerWindow = editCellRef.current!.ownerDocument.defaultView!; + function onWindowCaptureMouseDown(event: MouseEvent) { captureEventRef.current = event; - if (canUsePostTask) { + if (canUsePostTask(ownerWindow)) { const abortController = new AbortController(); - const { signal } = abortController; - abortControllerRef.current = abortController; + cancelScheduledTaskRef.current = () => { + abortController.abort(); + }; // Use postTask to ensure that the event is not called in the middle of a React render // and that it is called before the next paint. - scheduler + ownerWindow.scheduler .postTask(commitOnOutsideMouseDown, { priority: 'user-blocking', - signal + signal: abortController.signal }) // ignore abort errors .catch(() => {}); } else { - frameRequestRef.current = requestAnimationFrame(commitOnOutsideMouseDown); + const frameRequest = ownerWindow.requestAnimationFrame(commitOnOutsideMouseDown); + cancelScheduledTaskRef.current = () => { + ownerWindow.cancelAnimationFrame(frameRequest); + }; } } @@ -101,12 +112,12 @@ export default function EditCell({ } } - globalThis.addEventListener('mousedown', onWindowCaptureMouseDown, { capture: true }); - globalThis.addEventListener('mousedown', onWindowMouseDown); + ownerWindow.addEventListener('mousedown', onWindowCaptureMouseDown, { capture: true }); + ownerWindow.addEventListener('mousedown', onWindowMouseDown); return () => { - globalThis.removeEventListener('mousedown', onWindowCaptureMouseDown, { capture: true }); - globalThis.removeEventListener('mousedown', onWindowMouseDown); + ownerWindow.removeEventListener('mousedown', onWindowCaptureMouseDown, { capture: true }); + ownerWindow.removeEventListener('mousedown', onWindowMouseDown); cancelTask(); }; }, [commitOnOutsideClick]); @@ -115,14 +126,8 @@ export default function EditCell({ // oxlint-disable-next-line react/invariant function cancelTask() { captureEventRef.current = undefined; - if (abortControllerRef.current !== undefined) { - abortControllerRef.current.abort(); - abortControllerRef.current = undefined; - } - if (frameRequestRef.current !== undefined) { - cancelAnimationFrame(frameRequestRef.current); - frameRequestRef.current = undefined; - } + cancelScheduledTaskRef.current?.(); + cancelScheduledTaskRef.current = undefined; } function handleKeyDown(event: React.KeyboardEvent) { @@ -176,6 +181,7 @@ export default function EditCell({ return (
): boolean { - if ( - key === 'Tab' && - (target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement) - ) { + if (key === 'Tab' && isElement(target) && target.matches('input, textarea, select')) { return ( target.closest('.rdg-editor-container')?.querySelectorAll('input, textarea, select') .length === 1 diff --git a/test/browser/crossWindow.test.tsx b/test/browser/crossWindow.test.tsx index 9bb46ce428..b8a6e6464a 100644 --- a/test/browser/crossWindow.test.tsx +++ b/test/browser/crossWindow.test.tsx @@ -108,7 +108,7 @@ test('should observe grid resizes with the ResizeObserver of the window the grid await expect.poll(getLastHeaderCellColIndex).toBe('9'); }); -test.fails('should navigate between cells with the keyboard', async () => { +test('should navigate between cells with the keyboard', async () => { const { iframeDocument, frame } = await createIframe(); await page.render(createPortal(, iframeDocument.body)); @@ -118,7 +118,7 @@ test.fails('should navigate between cells with the keyboard', async () => { await expect.element(frame.getActiveCell()).toHaveTextContent('b1'); }); -test.fails('should commit changes and navigate out of the editor on Tab', async () => { +test('should commit changes and navigate out of the editor on Tab', async () => { const { iframeDocument, frame } = await createIframe(); await page.render(createPortal(, iframeDocument.body)); const editor = frame.getByRole('textbox', { name: 'editor' }); @@ -131,7 +131,7 @@ test.fails('should commit changes and navigate out of the editor on Tab', async await expect.element(frame.getCell({ name: 'a1new' })).toBeInTheDocument(); }); -test.fails('should commit changes and close the editor when clicked outside', async () => { +test('should commit changes and close the editor when clicked outside', async () => { const { iframeDocument, frame } = await createIframe(); await page.render(createPortal(, iframeDocument.body)); const editor = frame.getByRole('textbox', { name: 'editor' });