From cf60b54a9c2e4fba114eeeca5bce39d12dfa7565 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Thu, 21 Nov 2024 15:42:29 +0400 Subject: [PATCH] fix: batch flushSync execution within a microtask (#273) --- .../src/GridProEditColumn.tsx | 167 ++++++----- packages/react-components/package.json | 4 + packages/react-components/src/Grid.tsx | 41 ++- packages/react-components/src/GridColumn.tsx | 6 +- .../react-components/src/GridColumnGroup.tsx | 4 +- .../react-components/src/GridFilterColumn.tsx | 4 +- .../src/GridSelectionColumn.tsx | 6 +- .../react-components/src/GridSortColumn.tsx | 4 +- .../react-components/src/GridTreeColumn.tsx | 4 +- .../src/renderers/useModelRenderer.ts | 2 +- .../src/renderers/useRenderer.ts | 47 ++- .../renderers/useSimpleOrChildrenRenderer.ts | 2 +- .../src/renderers/useSimpleRenderer.ts | 2 +- .../src/utils/flushMicrotask.ts | 18 ++ test/Grid.spec.tsx | 276 ++++++++++++++---- 15 files changed, 385 insertions(+), 202 deletions(-) create mode 100644 packages/react-components/src/utils/flushMicrotask.ts diff --git a/packages/react-components-pro/src/GridProEditColumn.tsx b/packages/react-components-pro/src/GridProEditColumn.tsx index 3aa20bcb..b86a022e 100644 --- a/packages/react-components-pro/src/GridProEditColumn.tsx +++ b/packages/react-components-pro/src/GridProEditColumn.tsx @@ -8,17 +8,11 @@ * See https://vaadin.com/commercial-license-and-service-terms for the full * license. */ -import React from 'react'; -import { - type ForwardedRef, - forwardRef, - type ReactElement, - type ReactNode, - type RefAttributes, - createElement, -} from 'react'; -import type { GridBodyRenderer, GridDefaultItem } from '@vaadin/react-components/Grid.js'; -import type { GridColumnElement, GridColumnProps } from '@vaadin/react-components/GridColumn.js'; +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { type ForwardedRef, forwardRef, type ReactElement, type ReactNode, type RefAttributes } from 'react'; +import { flushSync } from 'react-dom'; +import type { GridDefaultItem } from '@vaadin/react-components/Grid.js'; +import type { GridColumnProps } from '@vaadin/react-components/GridColumn.js'; import { GridProEditColumn as _GridProEditColumn, type GridProEditColumnElement, @@ -27,6 +21,7 @@ import { import { useModelRenderer } from '@vaadin/react-components/renderers/useModelRenderer.js'; import { useSimpleOrChildrenRenderer } from '@vaadin/react-components/renderers/useSimpleOrChildrenRenderer.js'; import type { OmittedGridColumnHTMLAttributes } from '@vaadin/react-components/GridColumn.js'; +import useMergedRefs from '@vaadin/react-components/utils/useMergedRefs.js'; export * from './generated/GridProEditColumn.js'; @@ -61,99 +56,101 @@ export type GridProEditColumnProps = Partial< renderer?: GridColumnRenderer; }>; -type ReactBodyRenderer = GridColumnRenderer & { - __wrapperRenderer?: ReactBodyRenderer; +type GridProEditColumnElementInternals = { + _clearCellContent(cell: HTMLElement & { [SKIP_CLEARING_CELL_CONTENT]?: boolean }): void; + _renderEditor(cell: HTMLElement & { [SKIP_CLEARING_CELL_CONTENT]?: boolean }, model: { item: TItem }): void; + _removeEditor(cell: HTMLElement & { [SKIP_CLEARING_CELL_CONTENT]?: boolean }, model: { item: TItem }): void; }; -type EditColumnRendererRoot = HTMLElement & { __editColumnRenderer?: GridBodyRenderer }; - -type ClearFunction = (arg0: HTMLElement & { _content: EditColumnRendererRoot }) => void; - -/** - * Wraps a React renderer function to render empty when requested - * - * @returns - */ -function editColumnReactRenderer(reactBodyRenderer?: ReactBodyRenderer | null) { - if (!reactBodyRenderer) { - return undefined; - } - - reactBodyRenderer.__wrapperRenderer ||= function GridProEditColumnRenderer(props) { - // If the model has __renderEmpty set, return null, otherwise call the original renderer - return '__renderEmpty' in props.model ? null : createElement(reactBodyRenderer, props); - }; - - return reactBodyRenderer.__wrapperRenderer; -} - -/** - * Wraps a Grid body renderer function to make it request empty render before - * the GridPro edit column clears cell content. - */ -function editColumnRenderer(bodyRenderer?: (GridBodyRenderer & { __wrapperRenderer?: GridBodyRenderer }) | null) { - if (!bodyRenderer) { - return undefined; - } - - bodyRenderer.__wrapperRenderer ||= ( - root: EditColumnRendererRoot, - column: GridColumnElement & { - __originalClearCellContent?: ClearFunction; - _clearCellContent?: ClearFunction; - }, - model, - ) => { - // Patch the column's _clearCellContent function which is called internally by grid-pro - // when switching from edit mode to view mode and vice versa - if (!column.__originalClearCellContent) { - column.__originalClearCellContent = column._clearCellContent; - - column._clearCellContent = (cell) => { - const cellRoot = cell._content; - // Call the original renderer with __renderEmpty set to true to clear the content it manages - cellRoot.__editColumnRenderer?.(cellRoot, column, Object.assign({}, model, { __renderEmpty: true })); - // Call the original clearCellContent function to manually clear the cell content - column.__originalClearCellContent?.(cell); - }; - } - - // Update the cell content's renderer reference so that the correct one is used - // to render empty when the cell is cleared - root.__editColumnRenderer = bodyRenderer; - - // Call the original renderer - bodyRenderer(root, column, model); - }; - - return bodyRenderer.__wrapperRenderer; -} +const SKIP_CLEARING_CELL_CONTENT = Symbol(); function GridProEditColumn( { children, footer, header, ...props }: GridProEditColumnProps, ref: ForwardedRef>, ): ReactElement | null { - const [editModePortals, editModeRenderer] = useModelRenderer(editColumnReactRenderer(props.editModeRenderer), { - renderSync: true, + const [editedItem, setEditedItem] = useState(null); + + const [editModePortals, editModeRenderer] = useModelRenderer(props.editModeRenderer, { + // The web component implementation currently requires the editor to be rendered synchronously. + renderMode: 'sync', + shouldRenderPortal: (_root, _column, model) => editedItem === model.item, }); const [headerPortals, headerRenderer] = useSimpleOrChildrenRenderer(props.headerRenderer, header, { - renderSync: true, + renderMode: 'microtask', }); const [footerPortals, footerRenderer] = useSimpleOrChildrenRenderer(props.footerRenderer, footer, { - renderSync: true, + renderMode: 'microtask', }); - const [bodyPortals, bodyRenderer] = useModelRenderer(editColumnReactRenderer(props.renderer ?? children), { - renderSync: true, + const [bodyPortals, bodyRenderer] = useModelRenderer(props.renderer ?? children, { + renderMode: 'microtask', + shouldRenderPortal: (_root, _column, model) => editedItem !== model.item, }); + const innerRef = useRef & GridProEditColumnElementInternals>(null); + const finalRef = useMergedRefs(innerRef, ref); + + useLayoutEffect(() => { + innerRef.current!._clearCellContent = function (cell) { + // Clearing cell content in _renderEditor and _removeEditor is decided + // based on whether the content was rendered by a React renderer or not. + if (!cell[SKIP_CLEARING_CELL_CONTENT]) { + Object.getPrototypeOf(this)._clearCellContent.call(this, cell); + } + }; + }, []); + + useLayoutEffect(() => { + innerRef.current!._renderEditor = function (cell, model) { + // Manually clear the cell content only if it was rendered by the default grid renderer. + // For content rendered by a React renderer, clearing is handled by removing the portal. + if (!bodyRenderer) { + this._clearCellContent(cell); + } + + // Ensure the corresponding bodyRenderer portal is removed and the editModeRenderer portal + // is added instead. + flushSync(() => { + setEditedItem(model.item); + }); + + cell[SKIP_CLEARING_CELL_CONTENT] = true; + Object.getPrototypeOf(this)._renderEditor.call(this, cell, model); + cell[SKIP_CLEARING_CELL_CONTENT] = false; + }; + }, [bodyRenderer]); + + useLayoutEffect(() => { + innerRef.current!._removeEditor = function (cell, model) { + // Manually clear the cell content only if it was rendered by the default grid renderer. + // For content rendered by a React renderer, clearing is handled by removing the portal. + if (!editModeRenderer) { + this._clearCellContent(cell); + } + + // Ensure the editModeRenderer portal is removed and the corresponding bodyRenderer portal + // is added again. Please note the bodyRenderer portal will be added synchronously even though + // the renderer has renderMode set to microtask. It's because the portal already has content + // from the previous render cycle and we just show it again. + flushSync(() => { + setEditedItem((editedItem) => { + return editedItem === model.item ? null : editedItem; + }); + }); + + cell[SKIP_CLEARING_CELL_CONTENT] = true; + Object.getPrototypeOf(this)._removeEditor.call(this, cell, model); + cell[SKIP_CLEARING_CELL_CONTENT] = false; + }; + }, [editModeRenderer]); + return ( <_GridProEditColumn {...props} - editModeRenderer={editColumnRenderer(editModeRenderer)} + editModeRenderer={editModeRenderer} footerRenderer={footerRenderer} headerRenderer={headerRenderer} - ref={ref} - renderer={editColumnRenderer(bodyRenderer)} + ref={finalRef} + renderer={bodyRenderer} > {editModePortals} {headerPortals} diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 84d0e49b..dbc6abb0 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -501,6 +501,10 @@ "./utils/createComponentWithOrderedProps.d.ts.map": "./utils/createComponentWithOrderedProps.d.ts.map", "./utils/createComponentWithOrderedProps.js": "./utils/createComponentWithOrderedProps.js", "./utils/createComponentWithOrderedProps.js.map": "./utils/createComponentWithOrderedProps.js.map", + "./utils/flushMicrotask.d.ts": "./utils/flushMicrotask.d.ts", + "./utils/flushMicrotask.d.ts.map": "./utils/flushMicrotask.d.ts.map", + "./utils/flushMicrotask.js": "./utils/flushMicrotask.js", + "./utils/flushMicrotask.js.map": "./utils/flushMicrotask.js.map", "./utils/mapItemsWithComponents.d.ts": "./utils/mapItemsWithComponents.d.ts", "./utils/mapItemsWithComponents.d.ts.map": "./utils/mapItemsWithComponents.d.ts.map", "./utils/mapItemsWithComponents.js": "./utils/mapItemsWithComponents.js", diff --git a/packages/react-components/src/Grid.tsx b/packages/react-components/src/Grid.tsx index 6bc10e8a..7a761d03 100644 --- a/packages/react-components/src/Grid.tsx +++ b/packages/react-components/src/Grid.tsx @@ -1,4 +1,12 @@ -import { type ComponentType, type ForwardedRef, forwardRef, type ReactElement, type RefAttributes } from 'react'; +import { + type ComponentType, + type ForwardedRef, + forwardRef, + type ReactElement, + type RefAttributes, + useLayoutEffect, + useRef, +} from 'react'; import { Grid as _Grid, type GridDefaultItem, @@ -7,6 +15,7 @@ import { } from './generated/Grid.js'; import type { GridRowDetailsReactRendererProps } from './renderers/grid.js'; import { useModelRenderer } from './renderers/useModelRenderer.js'; +import useMergedRefs from './utils/useMergedRefs.js'; export * from './generated/Grid.js'; @@ -19,10 +28,24 @@ function Grid( props: GridProps, ref: ForwardedRef>, ): ReactElement | null { - const [portals, rowDetailsRenderer] = useModelRenderer(props.rowDetailsRenderer, { renderSync: true }); + const [portals, rowDetailsRenderer] = useModelRenderer(props.rowDetailsRenderer, { + renderMode: 'microtask', + }); + + const innerRef = useRef(null); + const finalRef = useMergedRefs(innerRef, ref); + + useLayoutEffect(() => { + innerRef.current!.recalculateColumnWidths = function (...args) { + // Wait for column content to finish rendering before recalculating widths. + queueMicrotask(() => { + Object.getPrototypeOf(this).recalculateColumnWidths.call(this, ...args); + }); + }; + }, []); return ( - <_Grid {...props} ref={ref} rowDetailsRenderer={rowDetailsRenderer}> + <_Grid {...props} ref={finalRef} rowDetailsRenderer={rowDetailsRenderer}> {props.children} {portals} @@ -34,15 +57,3 @@ const ForwardedGrid = forwardRef(Grid) as ( ) => ReactElement | null; export { ForwardedGrid as Grid }; - -customElements.whenDefined('vaadin-grid').then(() => { - const gridProto = customElements.get('vaadin-grid')?.prototype; - const originalRecalculateColumnWidths = gridProto?._recalculateColumnWidths; - gridProto._recalculateColumnWidths = function (...args: any[]) { - // Multiple synchronous calls to the renderers using flushSync cause - // some of the renderers to be called asynchronously (see useRenderer.ts). - // To make sure all the column cell content is rendered before recalculating - // the column widths, we need to make _recalculateColumnWidths asynchronous. - queueMicrotask(() => originalRecalculateColumnWidths.call(this, ...args)); - }; -}); diff --git a/packages/react-components/src/GridColumn.tsx b/packages/react-components/src/GridColumn.tsx index a5979253..2b933518 100644 --- a/packages/react-components/src/GridColumn.tsx +++ b/packages/react-components/src/GridColumn.tsx @@ -55,13 +55,13 @@ function GridColumn( ref: ForwardedRef>, ): ReactElement | null { const [headerPortals, headerRenderer] = useSimpleOrChildrenRenderer(props.headerRenderer, header, { - renderSync: true, + renderMode: 'microtask', }); const [footerPortals, footerRenderer] = useSimpleOrChildrenRenderer(props.footerRenderer, footer, { - renderSync: true, + renderMode: 'microtask', }); const [bodyPortals, bodyRenderer] = useModelRenderer(props.renderer ?? children, { - renderSync: true, + renderMode: 'microtask', }); return ( diff --git a/packages/react-components/src/GridColumnGroup.tsx b/packages/react-components/src/GridColumnGroup.tsx index b7518b4e..9a89f422 100644 --- a/packages/react-components/src/GridColumnGroup.tsx +++ b/packages/react-components/src/GridColumnGroup.tsx @@ -41,10 +41,10 @@ function GridColumnGroup( ref: ForwardedRef, ): ReactElement | null { const [headerPortals, headerRenderer] = useSimpleOrChildrenRenderer(props.headerRenderer, header, { - renderSync: true, + renderMode: 'microtask', }); const [footerPortals, footerRenderer] = useSimpleOrChildrenRenderer(props.footerRenderer, footer, { - renderSync: true, + renderMode: 'microtask', }); return ( diff --git a/packages/react-components/src/GridFilterColumn.tsx b/packages/react-components/src/GridFilterColumn.tsx index 8489a86c..4a26ccbb 100644 --- a/packages/react-components/src/GridFilterColumn.tsx +++ b/packages/react-components/src/GridFilterColumn.tsx @@ -44,10 +44,10 @@ function GridFilterColumn( ref: ForwardedRef>, ): ReactElement | null { const [footerPortals, footerRenderer] = useSimpleOrChildrenRenderer(props.footerRenderer, footer, { - renderSync: true, + renderMode: 'microtask', }); const [bodyPortals, bodyRenderer] = useModelRenderer(props.renderer ?? props.children, { - renderSync: true, + renderMode: 'microtask', }); return ( diff --git a/packages/react-components/src/GridSelectionColumn.tsx b/packages/react-components/src/GridSelectionColumn.tsx index df3913c0..b0f23d5e 100644 --- a/packages/react-components/src/GridSelectionColumn.tsx +++ b/packages/react-components/src/GridSelectionColumn.tsx @@ -50,13 +50,13 @@ function GridSelectionColumn( ref: ForwardedRef>, ): ReactElement | null { const [headerPortals, headerRenderer] = useSimpleOrChildrenRenderer(props.headerRenderer, header, { - renderSync: true, + renderMode: 'microtask', }); const [footerPortals, footerRenderer] = useSimpleOrChildrenRenderer(props.footerRenderer, footer, { - renderSync: true, + renderMode: 'microtask', }); const [bodyPortals, bodyRenderer] = useModelRenderer(props.renderer ?? props.children, { - renderSync: true, + renderMode: 'microtask', }); return ( diff --git a/packages/react-components/src/GridSortColumn.tsx b/packages/react-components/src/GridSortColumn.tsx index 3b405a40..76c2bfb7 100644 --- a/packages/react-components/src/GridSortColumn.tsx +++ b/packages/react-components/src/GridSortColumn.tsx @@ -43,10 +43,10 @@ function GridSortColumn( ref: ForwardedRef>, ): ReactElement | null { const [footerPortals, footerRenderer] = useSimpleOrChildrenRenderer(props.footerRenderer, footer, { - renderSync: true, + renderMode: 'microtask', }); const [bodyPortals, bodyRenderer] = useModelRenderer(props.renderer ?? props.children, { - renderSync: true, + renderMode: 'microtask', }); return ( diff --git a/packages/react-components/src/GridTreeColumn.tsx b/packages/react-components/src/GridTreeColumn.tsx index 85f5aafa..2712b622 100644 --- a/packages/react-components/src/GridTreeColumn.tsx +++ b/packages/react-components/src/GridTreeColumn.tsx @@ -47,10 +47,10 @@ function GridTreeColumn( ref: ForwardedRef>, ): ReactElement | null { const [headerPortals, headerRenderer] = useSimpleOrChildrenRenderer(props.headerRenderer, header, { - renderSync: true, + renderMode: 'microtask', }); const [footerPortals, footerRenderer] = useSimpleOrChildrenRenderer(props.footerRenderer, footer, { - renderSync: true, + renderMode: 'microtask', }); return ( diff --git a/packages/react-components/src/renderers/useModelRenderer.ts b/packages/react-components/src/renderers/useModelRenderer.ts index 27476bd3..73728bfb 100644 --- a/packages/react-components/src/renderers/useModelRenderer.ts +++ b/packages/react-components/src/renderers/useModelRenderer.ts @@ -27,7 +27,7 @@ export function convertModelRendererArgs, O extends HTMLEl export function useModelRenderer, O extends HTMLElement>( reactRenderer?: ComponentType> | null, - config?: RendererConfig, + config?: RendererConfig>, ): UseRendererResult> { return useRenderer(reactRenderer, convertModelRendererArgs, config); } diff --git a/packages/react-components/src/renderers/useRenderer.ts b/packages/react-components/src/renderers/useRenderer.ts index 79bb02b3..4f3e55bb 100644 --- a/packages/react-components/src/renderers/useRenderer.ts +++ b/packages/react-components/src/renderers/useRenderer.ts @@ -9,6 +9,7 @@ import { } from 'react'; import { createPortal, flushSync } from 'react-dom'; import type { Slice, WebComponentRenderer } from './renderer.js'; +import { flushMicrotask } from '../utils/flushMicrotask.js'; export type UseRendererResult = readonly [ portals?: ReadonlyArray, @@ -24,43 +25,33 @@ function rendererReducer( return new Map(state).set(root, args as Slice, 1>); } -export type RendererConfig = { - renderSync?: boolean; +export type RendererConfig = { + renderMode?: 'default' | 'sync' | 'microtask'; + shouldRenderPortal?(root: HTMLElement, ...args: Slice, 1>): boolean; }; export function useRenderer

( node: ReactNode, convert?: (props: Slice, 1>) => PropsWithChildren

, - config?: RendererConfig, + config?: RendererConfig, ): UseRendererResult; export function useRenderer

( reactRenderer: ComponentType

| null | undefined, convert: (props: Slice, 1>) => PropsWithChildren

, - config?: RendererConfig, + config?: RendererConfig, ): UseRendererResult; export function useRenderer

( reactRendererOrNode: ReactNode | ComponentType

| null | undefined, convert?: (props: Slice, 1>) => PropsWithChildren

, - config?: RendererConfig, + config?: RendererConfig, ): UseRendererResult { const [map, update] = useReducer>(rendererReducer, initialState); const renderer = useCallback( ((...args: Parameters) => { - if (config?.renderSync) { - // The web components may request multiple synchronous renderer calls that - // would result in flushSync logging a warning (and actually executing the - // overlapping flushSync in microtask timing). Suppress the warning and allow - // the resulting asynchronicity. - const console = globalThis.console as any; - const error = console.error; - console.error = (message: string) => { - if (message.includes('flushSync')) { - return; - } - error(message); - }; + if (config?.renderMode === 'microtask') { + flushMicrotask(() => update(args)); + } else if (config?.renderMode === 'sync') { flushSync(() => update(args)); - console.error = error; } else { update(args); } @@ -70,14 +61,18 @@ export function useRenderer

( return reactRendererOrNode ? [ - Array.from(map.entries()).map(([root, args]) => - createPortal( - convert - ? createElement

(reactRendererOrNode as ComponentType

, convert(args)) - : (reactRendererOrNode as ReactNode), - root, + Array.from(map.entries()) + .filter(([root, args]) => { + return config?.shouldRenderPortal?.(root, ...args) ?? true; + }) + .map(([root, args]) => + createPortal( + convert + ? createElement

(reactRendererOrNode as ComponentType

, convert(args)) + : (reactRendererOrNode as ReactNode), + root, + ), ), - ), renderer, ] : []; diff --git a/packages/react-components/src/renderers/useSimpleOrChildrenRenderer.ts b/packages/react-components/src/renderers/useSimpleOrChildrenRenderer.ts index 3131987d..91e9c9be 100644 --- a/packages/react-components/src/renderers/useSimpleOrChildrenRenderer.ts +++ b/packages/react-components/src/renderers/useSimpleOrChildrenRenderer.ts @@ -10,7 +10,7 @@ import { export function useSimpleOrChildrenRenderer( fnRenderer?: ComponentType> | null, children?: ReactNode | ComponentType>, - config?: RendererConfig, + config?: RendererConfig>, ): UseRendererResult> { let _children: ReactNode | undefined; let _fnRenderer: ComponentType> | null | undefined; diff --git a/packages/react-components/src/renderers/useSimpleRenderer.ts b/packages/react-components/src/renderers/useSimpleRenderer.ts index d854e104..d40749bb 100644 --- a/packages/react-components/src/renderers/useSimpleRenderer.ts +++ b/packages/react-components/src/renderers/useSimpleRenderer.ts @@ -16,7 +16,7 @@ function convertSimpleRendererArgs([original]: Slice< export function useSimpleRenderer( reactRenderer?: ComponentType> | null, - config?: RendererConfig, + config?: RendererConfig>, ): UseRendererResult> { return useRenderer(reactRenderer, convertSimpleRendererArgs, config); } diff --git a/packages/react-components/src/utils/flushMicrotask.ts b/packages/react-components/src/utils/flushMicrotask.ts new file mode 100644 index 00000000..3e922e7a --- /dev/null +++ b/packages/react-components/src/utils/flushMicrotask.ts @@ -0,0 +1,18 @@ +import { flushSync } from 'react-dom'; + +let callbackQueue: Function[] = []; + +export function flushMicrotask(callback: Function) { + callbackQueue.push(callback); + + if (callbackQueue.length === 1) { + queueMicrotask(() => { + const queue = callbackQueue; + callbackQueue = []; + + flushSync(() => { + queue.forEach((callback) => callback()); + }); + }); + } +} diff --git a/test/Grid.spec.tsx b/test/Grid.spec.tsx index e73a6d90..732b85e8 100644 --- a/test/Grid.spec.tsx +++ b/test/Grid.spec.tsx @@ -1,6 +1,6 @@ import { expect, use as useChaiPlugin } from '@esm-bundle/chai'; import chaiDom from 'chai-dom'; -import { cleanup, render } from '@testing-library/react/pure.js'; +import { cleanup, render, waitFor } from '@testing-library/react/pure.js'; import { Grid, type GridDataProvider } from '../packages/react-components/src/Grid.js'; import { GridColumn, GridColumnElement } from '../packages/react-components/src/GridColumn.js'; import { GridFilterColumn } from '../packages/react-components/src/GridFilterColumn.js'; @@ -9,7 +9,6 @@ import { GridSelectionColumn } from '../packages/react-components/src/GridSelect import { GridSortColumn } from '../packages/react-components/src/GridSortColumn.js'; import { GridTreeColumn } from '../packages/react-components/src/GridTreeColumn.js'; import type { GridBodyReactRendererProps } from '../packages/react-components/src/renderers/grid.js'; -import catchRender from './utils/catchRender.js'; import { GridColumnGroup } from '../packages/react-components/src/GridColumnGroup.js'; import { findByQuerySelector } from './utils/findByQuerySelector.js'; import { GridPro } from '../packages/react-components-pro/src/GridPro.js'; @@ -55,29 +54,28 @@ describe('Grid', () => { return <>{item.name}; } - function isGridCellContentNodeRendered(node: Node) { - return ( - node instanceof Text && - node.parentNode instanceof HTMLElement && - node.parentNode.localName === 'vaadin-grid-cell-content' - ); - } - - async function getGridMeaningfulParts(columnElementName: string) { - const grid = document.querySelector('vaadin-grid, vaadin-grid-pro')!; - expect(grid).to.exist; + function getGridMeaningfulParts( + columnElementName: string, + assertions: { expectedColumnCount: number; expectedCellCount: number }, + ) { + return waitFor(async () => { + const grid = document.querySelector('vaadin-grid, vaadin-grid-pro')!; + expect(grid).to.exist; - await catchRender(grid, isGridCellContentNodeRendered); + const columns = document.querySelectorAll(columnElementName); - const columns = document.querySelectorAll(columnElementName); + // Filter cells that don't have any textContent. Grid creates empty cells for some calculations, + // but we don't need them. + const cells = Array.from(grid!.querySelectorAll('vaadin-grid-cell-content')).filter( + ({ textContent }) => textContent, + ); - // Filter cells that don't have any textContent. Grid creates empty cells for some calculations, - // but we don't need them. - const cells = Array.from(grid!.querySelectorAll('vaadin-grid-cell-content')).filter( - ({ textContent }) => textContent, - ); + const { expectedColumnCount, expectedCellCount } = assertions; + expect(columns).to.have.lengthOf(expectedColumnCount); + expect(cells).to.have.lengthOf(expectedCellCount); - return [columns, cells] as const; + return [columns, cells] as const; + }); } afterEach(cleanup); @@ -102,10 +100,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-column'); - - expect(columns).to.have.length(3); - expect(cells).to.have.length(15); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-column', { + expectedColumnCount: 3, + expectedCellCount: 15, + }); const [headerRendererCell, headerInlineCell, nameHeaderCell, surnameHeaderCell, roleHeaderCell] = cells.slice( 0, @@ -217,10 +215,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-column'); - - expect(columns).to.have.length(1); - expect(cells).to.have.length(6); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-column', { + expectedColumnCount: 1, + expectedCellCount: 6, + }); const [groupHeaderCell, nameHeaderCell, nameFooterCell, groupFooterCell] = cells; @@ -239,9 +237,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-filter-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(3); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-filter-column', { + expectedColumnCount: 1, + expectedCellCount: 3, + }); const [footerCell, bodyCell1, bodyCell2] = cells; @@ -257,9 +256,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-filter-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(4); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-filter-column', { + expectedColumnCount: 1, + expectedCellCount: 4, + }); const footerCell = cells[1]; @@ -277,9 +277,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-selection-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(4); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-selection-column', { + expectedColumnCount: 1, + expectedCellCount: 4, + }); const [headerCell, footerCell, bodyCell1, bodyCell2] = cells; @@ -298,9 +299,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-selection-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(4); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-selection-column', { + expectedColumnCount: 1, + expectedCellCount: 4, + }); const [headerCell, footerCell] = cells; @@ -317,9 +319,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-sort-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(3); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-sort-column', { + expectedColumnCount: 1, + expectedCellCount: 3, + }); const [footerCell, bodyCell1, bodyCell2] = cells; @@ -335,9 +338,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-sort-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(4); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-sort-column', { + expectedColumnCount: 1, + expectedCellCount: 4, + }); const footerCell = cells[1]; @@ -355,9 +359,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-pro-edit-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(4); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-pro-edit-column', { + expectedColumnCount: 1, + expectedCellCount: 4, + }); const [headerCell, footerCell, bodyCell1, bodyCell2] = cells; @@ -374,9 +379,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-pro-edit-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(4); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-pro-edit-column', { + expectedColumnCount: 1, + expectedCellCount: 4, + }); const [headerCell, footerCell] = cells; @@ -384,6 +390,74 @@ describe('Grid', () => { expect(footerCell).to.have.text('Name Footer'); }); + describe('default renderers', () => { + type GridProItem = { name: string }; + + let items: GridProItem[]; + + function doubleClick(element: Element) { + element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + } + + beforeEach(() => { + items = [{ name: 'name-0' }]; + }); + + it('should toggle edit mode on double click', async () => { + render( + items={items}> + path="name" /> + , + ); + + const cellContent = await until(() => + Array.from(document.querySelectorAll('vaadin-grid-cell-content')).find( + (cellContent) => cellContent.textContent === 'name-0', + ), + ); + + for (let i = 0; i < 2; i++) { + expect(cellContent.textContent?.trim()).to.equal('name-0'); + doubleClick(cellContent); + + const cellEditor = await until(() => + cellContent.querySelector('vaadin-grid-pro-edit-text-field'), + ); + expect(cellContent.textContent?.trim()).to.be.empty; + cellEditor.blur(); + + await until(() => !cellContent.contains(cellEditor)); + } + }); + + it('should update the content', async () => { + render( + items={items}> + path="name" /> + , + ); + + // Get the cell content + const cellContent = await until(() => + Array.from(document.querySelectorAll('vaadin-grid-cell-content')).find( + (cellContent) => cellContent.textContent === 'name-0', + ), + ); + doubleClick(cellContent); + const cellEditor = await until(() => + cellContent.querySelector('vaadin-grid-pro-edit-text-field'), + ); + // Set a new value + cellEditor.value = 'foo'; + // Exit edit mode + cellEditor.blur(); + // Wait for the editor to close + await until(() => !cellContent.contains(cellEditor)); + // Expect the cell content to be connected and have the new value + await until(() => cellContent.textContent === 'foo'); + }); + }); + describe('custom renderers', () => { type GridProItem = { name: string }; @@ -445,6 +519,33 @@ describe('Grid', () => { expect(cellContent.isConnected).to.be.false; }); + it('should toggle edit mode on double click', async () => { + render( + items={items}> + path="name" editModeRenderer={() => }> + {({ item }) => {item.name}} + + , + ); + + const cellContent = await until(() => + Array.from(document.querySelectorAll('vaadin-grid-cell-content')).find((cellContent) => + cellContent.querySelector('.content'), + ), + ); + + for (let i = 0; i < 2; i++) { + expect(cellContent.textContent?.trim()).to.equal('name-0'); + doubleClick(cellContent); + + const cellEditor = await until(() => cellContent.querySelector('.editor')); + expect(cellContent.textContent?.trim()).to.be.empty; + cellEditor.blur(); + + await until(() => !cellContent.contains(cellEditor)); + } + }); + it('should have updated content', async () => { render( items={items}> @@ -470,6 +571,36 @@ describe('Grid', () => { expect(cellContent).to.have.text('foo'); }); + it('should toggle edit mode on double click without a custom editor', async () => { + render( + items={items}> + + path="name" + renderer={({ item }) => {item.name}} + /> + , + ); + + const cellContent = await until(() => + Array.from(document.querySelectorAll('vaadin-grid-cell-content')).find((cellContent) => + cellContent.querySelector('.content'), + ), + ); + + for (let i = 0; i < 2; i++) { + expect(cellContent.textContent?.trim()).to.equal('name-0'); + doubleClick(cellContent); + + const cellEditor = await until(() => + cellContent.querySelector('vaadin-grid-pro-edit-text-field'), + ); + expect(cellContent.textContent?.trim()).to.be.empty; + cellEditor.blur(); + + await until(() => !cellContent.contains(cellEditor)); + } + }); + it('should update the content without a custom editor', async () => { render( items={items}> @@ -495,6 +626,31 @@ describe('Grid', () => { expect(cellContent).to.have.text('foo'); }); + it('should toggle edit mode on double click without a custom renderer', async () => { + render( + items={items}> + path="name" editModeRenderer={() => } /> + , + ); + + const cellContent = await until(() => + Array.from(document.querySelectorAll('vaadin-grid-cell-content')).find( + (cellContent) => cellContent.textContent === 'name-0', + ), + ); + + for (let i = 0; i < 2; i++) { + expect(cellContent.textContent?.trim()).to.equal('name-0'); + doubleClick(cellContent); + + const cellEditor = await until(() => cellContent.querySelector('.editor')); + expect(cellContent.textContent?.trim()).to.equal(''); + cellEditor.blur(); + + await until(() => !cellContent.contains(cellEditor)); + } + }); + it('should update the content without a custom renderer', async () => { render( items={items}> @@ -585,9 +741,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-tree-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(7); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-tree-column', { + expectedColumnCount: 1, + expectedCellCount: 7, + }); const [treeHeaderCell, nameHeaderCell, treeFooterCell] = cells; @@ -604,9 +761,10 @@ describe('Grid', () => { , ); - const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-tree-column'); - expect(columns).to.have.length(1); - expect(cells).to.have.length(7); + const [columns, cells] = await getGridMeaningfulParts('vaadin-grid-tree-column', { + expectedColumnCount: 1, + expectedCellCount: 7, + }); const [treeHeaderCell, nameHeaderCell, treeFooterCell] = cells;