From 9e52ddcee6ec6f7a83d96345a0dbec47906b32f5 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:29:32 +0200 Subject: [PATCH] feat!: return the component the grid rendered from GridTester.getCellComponent (#250) `GridTester.getCellComponent` used to render the cell again on every call and attach the fresh copy to the grid, so a test never acted on the component the grid actually shows. It now returns the component the grid rendered for that row and column. The old behaviour is still available as the new `renderCellComponent`. Fixes #235 --- README.md | 32 ++- .../AbstractBrowserlessExtension.java | 8 +- .../component/grid/BasicGridTesterTest.java | 107 ++++++++- .../flow/component/grid/BasicGridView.java | 4 + .../grid/GetTextCellRendererTest.java | 14 +- .../browserless/BaseBrowserlessTest.java | 8 +- .../browserless/BrowserlessUIContext.java | 8 +- .../vaadin/browserless/ComponentTester.java | 8 +- .../internal/RenderedComponentSupport.java | 109 +++++++++ .../flow/component/grid/GridTester.java | 210 ++++++++++++++---- 10 files changed, 432 insertions(+), 76 deletions(-) create mode 100644 shared/src/main/java/com/vaadin/browserless/internal/RenderedComponentSupport.java diff --git a/README.md b/README.md index f93a3c2d..7190e15c 100644 --- a/README.md +++ b/README.md @@ -399,11 +399,11 @@ on locators. Use whichever fits — they search the same component tree. `find(Class)`, `findInView(Class)` and the typed locators all walk the same thing: the server-side component tree. A component that another component -renders per item does not exist until something renders it, and the content of -an overlay is attached only while the overlay is open. Neither is in the tree -until then — reach it through that component's tester instead. The lookup -returns an empty result rather than an error, so the failure reads as "the -component was never created". +renders per item is rendered into that component and not into the tree, and +the content of an overlay is attached only while the overlay is open. Neither +is reachable that way — reach it through that component's tester instead. The +lookup returns an empty result rather than an error, so the failure reads as +"the component was never created". ### Components rendered per item @@ -412,8 +412,9 @@ grid.addComponentColumn(person -> new Checkbox(person.isSubscriber())) .setKey("subscriber"); ``` -No checkbox exists until the renderer is asked to render a *specific* item, so -`find(Checkbox.class)` finds none. `GridTester` renders the cell on demand: +A grid renders that checkbox into the column, not into the grid, so +`find(Checkbox.class)` finds none of them, no matter how many rows are on +screen. `GridTester` hands out the one the grid rendered: ```java var checkbox = (Checkbox) test(grid).getCellComponent(0, "subscriber"); @@ -421,11 +422,18 @@ test(checkbox).click(); ``` - `getCellComponent(int row, int column)` / `getCellComponent(int row, String - columnKey)` — the component a `ComponentRenderer` column renders for a row. - Every call renders the cell again and attaches the new instance to the grid, - so asking twice for the same cell leaves two instances behind, and a later - `find()` reports both. Hold on to the component the tester returns instead of - asking for it again. + columnKey)` — the component the grid rendered for the cell, which is the one + the browser shows. Reading the same cell twice gives the same instance, and + the instance is replaced when the row is rendered anew, for example after + `refreshItem(...)`. A row the client has not asked for yet is scrolled into + view first, the way a user reaches it. A cell the grid does not render at + all, such as one in a hidden column, throws. +- `renderCellComponent(int row, int column)` / `renderCellComponent(int row, + String columnKey)` — renders the cell on its own, without the grid, and + attaches the copy to the grid so that it can be used. Every call renders the + cell again and leaves the copy behind, so a later `find()` reports every one + of them. It is for the cells the grid does not render, and for tests written + against the old behaviour of `getCellComponent`. - `getCellText(int row, int column)` — the text the cell sends to the client, for both value and component renderers. - `getLitRendererPropertyValue(...)` / `invokeLitRendererFunction(...)` — for diff --git a/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java b/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java index fbef6236..d4154b9c 100644 --- a/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java +++ b/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java @@ -270,10 +270,10 @@ public T navigate(String location, *

* The query walks the server-side component tree. A component that another * component renders per item, such as the component a - * {@code ComponentRenderer} column renders for a grid row, does not exist - * until something renders it, and the content of an overlay, such as a - * context menu, is attached only while the overlay is open. Neither is in - * the tree until then, and the lookup returns an empty result rather than + * {@code ComponentRenderer} column renders for a grid row, is rendered into + * the column and not into the tree, and the content of an overlay, such as + * a context menu, is attached only while the overlay is open. Neither is + * reachable this way, and the lookup returns an empty result rather than * failing, so reach those components through the owning component tester * instead: {@code GridTester.getCellComponent(row, column)} for grid cells, * {@code ContextMenuTester.open()} and then {@code clickItem(...)} for a diff --git a/junit6/src/test/java/com/vaadin/flow/component/grid/BasicGridTesterTest.java b/junit6/src/test/java/com/vaadin/flow/component/grid/BasicGridTesterTest.java index 823880ba..1bf9d5d2 100644 --- a/junit6/src/test/java/com/vaadin/flow/component/grid/BasicGridTesterTest.java +++ b/junit6/src/test/java/com/vaadin/flow/component/grid/BasicGridTesterTest.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.IntStream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -374,23 +375,115 @@ void getCellComponent_columnByKey_returnsInstantiatedComponent() { } @Test - void find_componentRenderedIntoCell_notInTreeButReachableThroughTester() { - // A ComponentRenderer component does not exist until the renderer is - // asked to render a specific item, so there is nothing for find() to - // walk into. + void getCellComponent_readTwice_returnsTheComponentTheGridRendered() { + // A ComponentRenderer component is created when the grid renders the + // row for the client, and it is rendered into the column rather than + // into the grid, so there is nothing for find() to walk into. Assertions.assertEquals(0, find(Button.class).all().size(), "a component rendered into a grid cell should not be reachable through find()"); final Component cellComponent = test(view.basicGrid).getCellComponent(1, BasicGridView.BUTTON_KEY); - final Component renderedAgain = test(view.basicGrid).getCellComponent(1, + final Component readAgain = test(view.basicGrid).getCellComponent(1, BasicGridView.BUTTON_KEY); Assertions.assertInstanceOf(Button.class, cellComponent); - Assertions.assertNotSame(cellComponent, renderedAgain, + Assertions.assertSame(cellComponent, readAgain, + "reading the same cell twice should give the one component the grid renders for it"); + Assertions.assertEquals(0, find(Button.class).all().size(), + "reading a cell should not attach a component to the grid"); + } + + @Test + void getCellComponent_itemRefreshed_returnsTheComponentRenderedAnew() { + final Component beforeRefresh = test(view.basicGrid).getCellComponent(1, + BasicGridView.BUTTON_KEY); + + view.basicGrid.getListDataView().refreshItem(view.person2); + + final Component afterRefresh = test(view.basicGrid).getCellComponent(1, + BasicGridView.BUTTON_KEY); + Assertions.assertNotSame(beforeRefresh, afterRefresh, + "a refreshed row is rendered anew, so its cell has a new component"); + Assertions.assertFalse(beforeRefresh.isAttached(), + "the component the refreshed row replaced should be gone"); + Assertions.assertTrue(afterRefresh.isAttached(), + "the component of the refreshed row should be the rendered one"); + } + + @Test + void getCellComponent_rowOutsideRenderedRange_scrollsItIntoView() { + // the client has only asked for the first row + view.basicGrid.setPageSize(1); + + final Component cellComponent = test(view.basicGrid).getCellComponent(1, + 4); + + Assertions.assertInstanceOf(Button.class, cellComponent); + Assertions.assertSame(cellComponent, + test(view.basicGrid).getCellComponent(1, 4), + "the row scrolled into view should stay rendered"); + } + + @Test + void getCellComponent_hiddenColumn_throwsAndSuggestsRendering() { + GridTester, Person> grid_ = test(view.basicGrid); + + // the browser shows no cell for a hidden column, so the grid renders + // no component for it + Assertions.assertThrows(IllegalStateException.class, () -> grid_ + .getCellComponent(1, BasicGridView.HIDDEN_BUTTON_KEY)); + + Assertions.assertInstanceOf(Button.class, + grid_.renderCellComponent(1, BasicGridView.HIDDEN_BUTTON_KEY)); + } + + @Test + void getCellComponent_itemsNotEqualAcrossFetches_throwsAndSuggestsRendering() { + // a data provider that hands out a new item instance on every fetch: + // the grid cannot tell that the item on the row is the item it + // rendered the row for + final Grid lazyGrid = new Grid<>(); + lazyGrid.addComponentColumn(person -> new Button(person.getFirstName())) + .setKey(BasicGridView.BUTTON_KEY); + lazyGrid.setItems(query -> IntStream + .range(query.getOffset(), query.getOffset() + query.getLimit()) + .mapToObj(index -> { + final Person person = new Person(); + person.setFirstName("Person " + index); + return person; + }), query -> 100); + view.add(lazyGrid); + + GridTester, Person> lazyGrid_ = test(lazyGrid); + final IllegalStateException exception = Assertions.assertThrows( + IllegalStateException.class, + () -> lazyGrid_.getCellComponent(0, BasicGridView.BUTTON_KEY)); + Assertions.assertTrue( + exception.getMessage().contains("Grid rendered no component") + && exception.getMessage() + .contains("renderCellComponent"), + "the failure should say the grid rendered nothing and point at the way to render the cell anyway"); + + Assertions.assertInstanceOf(Button.class, + lazyGrid_.renderCellComponent(0, BasicGridView.BUTTON_KEY)); + } + + @Test + void renderCellComponent_rendersACopyAndAttachesItToTheGrid() { + final Component rendered = test(view.basicGrid).renderCellComponent(1, + 4); + final Component renderedAgain = test(view.basicGrid) + .renderCellComponent(1, 4); + + Assertions.assertNotSame(rendered, renderedAgain, "every call should render the cell anew"); Assertions.assertEquals(2, find(Button.class).all().size(), - "every rendered instance is attached to the grid and found from then on"); + "every rendered copy is attached to the grid and found from then on"); + Assertions.assertNotSame(rendered, + test(view.basicGrid).getCellComponent(1, + BasicGridView.BUTTON_KEY), + "a rendered copy is not the component the grid shows"); } @Test diff --git a/junit6/src/test/java/com/vaadin/flow/component/grid/BasicGridView.java b/junit6/src/test/java/com/vaadin/flow/component/grid/BasicGridView.java index 03d37908..c5cb8577 100644 --- a/junit6/src/test/java/com/vaadin/flow/component/grid/BasicGridView.java +++ b/junit6/src/test/java/com/vaadin/flow/component/grid/BasicGridView.java @@ -34,6 +34,7 @@ public class BasicGridView extends Component implements HasComponents { static final String SUBSCRIBER_KEY = "Subscriber"; static final String DECEASED_KEY = "Deceased"; static final String BUTTON_KEY = "Button"; + static final String HIDDEN_BUTTON_KEY = "Hidden Button"; final Grid basicGrid; final Person person1; @@ -57,6 +58,9 @@ public BasicGridView() { .addComponentColumn(person -> new Button("Click", e -> Notification.show("Clicked!"))) .setKey(BUTTON_KEY).setHeader("Button"); + basicGrid.addComponentColumn(person -> new Button("Hidden")) + .setKey(HIDDEN_BUTTON_KEY).setHeader("Hidden Button") + .setVisible(false); add(basicGrid); diff --git a/junit6/src/test/java/com/vaadin/flow/component/grid/GetTextCellRendererTest.java b/junit6/src/test/java/com/vaadin/flow/component/grid/GetTextCellRendererTest.java index 0358dd2e..5115d9fe 100644 --- a/junit6/src/test/java/com/vaadin/flow/component/grid/GetTextCellRendererTest.java +++ b/junit6/src/test/java/com/vaadin/flow/component/grid/GetTextCellRendererTest.java @@ -21,6 +21,7 @@ import com.vaadin.browserless.BrowserlessTest; import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.Text; import com.vaadin.flow.router.RouteConfiguration; @ViewPackages @@ -47,8 +48,17 @@ void getCellText_componentRenderer_getTextRecursively() { } @Test - void getCellText_renderNull_getsNull() { - Assertions.assertNull(grid_.getCellText(0, 1)); + void getCellText_renderNull_getsEmptyString() { + // a renderer that returns no component renders an empty cell: the + // grid puts an empty text node in its place + Assertions.assertEquals("", grid_.getCellText(0, 1)); + } + + @Test + void getCellComponent_renderNull_getsTheEmptyTextTheGridRenders() { + // the grid renders an empty text node where the renderer produced + // nothing, so there is a component to hand out + Assertions.assertInstanceOf(Text.class, grid_.getCellComponent(0, 1)); } @Test diff --git a/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java b/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java index 69b3a332..777ad03b 100644 --- a/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java +++ b/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java @@ -446,10 +446,10 @@ public , Y extends Component> T test( *

* The query walks the server-side component tree. A component that another * component renders per item, such as the component a - * {@code ComponentRenderer} column renders for a grid row, does not exist - * until something renders it, and the content of an overlay, such as a - * context menu, is attached only while the overlay is open. Neither is in - * the tree until then, and the lookup returns an empty result rather than + * {@code ComponentRenderer} column renders for a grid row, is rendered into + * the column and not into the tree, and the content of an overlay, such as + * a context menu, is attached only while the overlay is open. Neither is + * reachable this way, and the lookup returns an empty result rather than * failing, so reach those components through the owning component tester * instead: {@code GridTester.getCellComponent(row, column)} for grid cells, * {@code ContextMenuTester.open()} and then {@code clickItem(...)} for a diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java index ff1b2f16..27d41669 100644 --- a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java +++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java @@ -310,10 +310,10 @@ public static BrowserlessUIContext forComponent( *

* The query walks the server-side component tree. A component that another * component renders per item, such as the component a - * {@code ComponentRenderer} column renders for a grid row, does not exist - * until something renders it, and the content of an overlay, such as a - * context menu, is attached only while the overlay is open. Neither is in - * the tree until then, and the lookup returns an empty result rather than + * {@code ComponentRenderer} column renders for a grid row, is rendered into + * the column and not into the tree, and the content of an overlay, such as + * a context menu, is attached only while the overlay is open. Neither is + * reachable this way, and the lookup returns an empty result rather than * failing, so reach those components through the owning component tester * instead: {@code GridTester.getCellComponent(row, column)} for grid cells, * {@code ContextMenuTester.open()} and then {@code clickItem(...)} for a diff --git a/shared/src/main/java/com/vaadin/browserless/ComponentTester.java b/shared/src/main/java/com/vaadin/browserless/ComponentTester.java index dc40ebc4..9f0f6a91 100644 --- a/shared/src/main/java/com/vaadin/browserless/ComponentTester.java +++ b/shared/src/main/java/com/vaadin/browserless/ComponentTester.java @@ -154,10 +154,10 @@ public void setModal(boolean modal) { *

* The query walks the server-side component tree. A component that another * component renders per item, such as the component a - * {@code ComponentRenderer} column renders for a grid row, does not exist - * until something renders it, and the content of an overlay, such as a - * context menu, is attached only while the overlay is open. Neither is in - * the tree until then, and the lookup returns an empty result rather than + * {@code ComponentRenderer} column renders for a grid row, is rendered into + * the column and not into the tree, and the content of an overlay, such as + * a context menu, is attached only while the overlay is open. Neither is + * reachable this way, and the lookup returns an empty result rather than * failing, so reach those components through the owning component tester * instead: {@code GridTester.getCellComponent(row, column)} for grid cells, * {@code ContextMenuTester.open()} and then {@code clickItem(...)} for a diff --git a/shared/src/main/java/com/vaadin/browserless/internal/RenderedComponentSupport.java b/shared/src/main/java/com/vaadin/browserless/internal/RenderedComponentSupport.java new file mode 100644 index 00000000..eb4261ba --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/internal/RenderedComponentSupport.java @@ -0,0 +1,109 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless.internal; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.Map; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.data.provider.AbstractComponentDataGenerator; +import com.vaadin.flow.data.provider.CompositeDataGenerator; +import com.vaadin.flow.data.provider.DataGenerator; +import com.vaadin.flow.data.renderer.ComponentRenderer; +import com.vaadin.flow.internal.ReflectTools; + +/** + * Reaches the components a {@link ComponentRenderer} has rendered for the rows + * the client has asked for. + *

+ * For internal use only. + */ +public final class RenderedComponentSupport { + + private RenderedComponentSupport() { + } + + /** + * Gets the component the column has rendered for the item with the given + * key. + *

+ * A component renderer keeps one component per item for as long as the + * client has the row, and that is the component the browser shows. Flow + * exposes no API for it: the mapping lives in + * {@code AbstractComponentDataGenerator.renderedComponents}, and the + * generator itself is reachable only through the private data generators of + * the column, so both are read reflectively. The accessor belongs upstream, + * on {@code Grid.Column} in {@code vaadin/flow-components} or on + * {@link AbstractComponentDataGenerator} in {@code vaadin/flow}. + * + * @param column + * the column that renders the cell + * @param itemKey + * key of the item the cell shows + * @return the rendered component, or {@literal null} when the column has + * rendered no component for the item + */ + public static Component getRenderedComponent(Grid.Column column, + String itemKey) { + DataGenerator columnDataGenerator = (DataGenerator) read( + Grid.Column.class, "compositeDataGenerator", column); + AbstractComponentDataGenerator generator = findComponentDataGenerator( + columnDataGenerator); + if (generator == null) { + return null; + } + if (read(AbstractComponentDataGenerator.class, "renderedComponents", + generator) instanceof Map rendered) { + return (Component) rendered.get(itemKey); + } + return null; + } + + private static AbstractComponentDataGenerator findComponentDataGenerator( + DataGenerator generator) { + if (generator instanceof AbstractComponentDataGenerator componentDataGenerator) { + return componentDataGenerator; + } + if (generator instanceof CompositeDataGenerator composite + && read(CompositeDataGenerator.class, "dataGenerators", + composite) instanceof Collection nested) { + for (Object child : nested) { + AbstractComponentDataGenerator found = findComponentDataGenerator( + (DataGenerator) child); + if (found != null) { + return found; + } + } + } + return null; + } + + private static Object read(Class owner, String fieldName, + Object instance) { + Field field = ReflectTools.findDeclaredField(owner, fieldName) + .orElseThrow(() -> new IllegalStateException("Unable to find " + + owner.getSimpleName() + "." + fieldName)); + try { + return field.get(instance); + } catch (IllegalAccessException e) { + throw new IllegalStateException( + "Unable to read " + owner.getSimpleName() + "." + fieldName, + e); + } + } +} diff --git a/shared/src/main/java/com/vaadin/flow/component/grid/GridTester.java b/shared/src/main/java/com/vaadin/flow/component/grid/GridTester.java index ef83e5d1..858aafb3 100644 --- a/shared/src/main/java/com/vaadin/flow/component/grid/GridTester.java +++ b/shared/src/main/java/com/vaadin/flow/component/grid/GridTester.java @@ -30,9 +30,11 @@ import com.vaadin.browserless.Tests; import com.vaadin.browserless.component.GridKt; import com.vaadin.browserless.internal.GridContextMenuSupport; +import com.vaadin.browserless.internal.RenderedComponentSupport; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.grid.contextmenu.GridContextMenu; import com.vaadin.flow.component.grid.contextmenu.GridContextMenuTester; +import com.vaadin.flow.data.provider.DataCommunicator; import com.vaadin.flow.data.provider.SortDirection; import com.vaadin.flow.data.provider.SortOrder; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -298,8 +300,12 @@ public void deselectAll() { * For the default renderer ColumnPathRenderer the result is the sent text * for defined object path. *

- * For a ComponentRenderer the result is the rendered component as - * prettyString. + * For a ComponentRenderer the result is the text of the component the grid + * rendered for the cell, read through {@link #getCellComponent(int, int)}: + * a row the client has not asked for yet is scrolled into view first, and a + * cell the grid renders no component for fails the same way. A renderer + * that returns no component renders an empty cell, so the text is empty + * rather than {@literal null}. *

* More to be added as we find other renderers that need handling. * @@ -309,17 +315,15 @@ public void deselectAll() { * column of cell * @return cell content that is sent to the client * @throws IllegalStateException - * if component is not visible + * if component is not visible, or if the grid renders no + * component for a ComponentRenderer cell */ public String getCellText(int row, int column) { ensureVisible(); final Grid.Column targetColumn = getColumns().get(column); if (targetColumn.getRenderer() instanceof ComponentRenderer) { - Component component = getCellComponent(row, column); - if (component == null) { - return null; - } - return component.getElement().getTextRecursively(); + return getCellComponent(row, column).getElement() + .getTextRecursively(); } else if (targetColumn.getRenderer() instanceof ColumnPathRenderer) { // This renderer just writes the object text using a path return getValueProviderString(row, targetColumn); @@ -328,77 +332,205 @@ public String getCellText(int row, int column) { } /** - * Get component for item in cell. + * Get the component the grid renders for the cell in the given position. * *

- * A component renderer only produces a component when it is asked to render - * a specific item, so until this method is called there is nothing in the + * A component renderer only produces a component when the grid renders a + * row for the client, so a renderer component is never part of the * component tree that {@code find(...)} walks, and this method is the way - * to reach it. Every call renders the cell again and attaches the new - * instance to the grid, so asking twice for the same cell leaves two - * instances behind and a later {@code find(...)} reports both. Hold on to - * the component this method returns instead of asking for it again. + * to reach it. What comes back is the very component the browser shows: + * asking for the same cell twice returns the same instance, and the + * instance is replaced when the grid re-renders the row, for example after + * {@code refreshItem(...)}. A row the client has not asked for yet is + * scrolled into view first, the way a user reaches it. + * + *

+ * Use {@link #renderCellComponent(int, int)} to render a cell on its own, + * without the grid. * * @param row * item row * @param column * column to get - * @return initialized component for the targeted cell + * @return the component the grid rendered for the targeted cell * @throws IllegalArgumentException * when the target column of the cell is not a component * renderer + * @throws IllegalStateException + * when the grid renders no component for the cell */ public Component getCellComponent(int row, int column) { ensureVisible(); final Grid.Column yColumn = getColumns().get(column); - return getRendererItem(row, yColumn); + return getRenderedCellComponent(row, yColumn); } /** - * Get component for item in column. + * Get the component the grid renders for the cell in the given row of the + * column with the given key. * *

- * A component renderer only produces a component when it is asked to render - * a specific item, so until this method is called there is nothing in the + * A component renderer only produces a component when the grid renders a + * row for the client, so a renderer component is never part of the * component tree that {@code find(...)} walks, and this method is the way - * to reach it. Every call renders the cell again and attaches the new - * instance to the grid, so asking twice for the same cell leaves two - * instances behind and a later {@code find(...)} reports both. Hold on to - * the component this method returns instead of asking for it again. + * to reach it. What comes back is the very component the browser shows: + * asking for the same cell twice returns the same instance, and the + * instance is replaced when the grid re-renders the row, for example after + * {@code refreshItem(...)}. A row the client has not asked for yet is + * scrolled into view first, the way a user reaches it. + * + *

+ * Use {@link #renderCellComponent(int, String)} to render a cell on its + * own, without the grid. * * @param row * item row * @param columnName * key/property of column - * @return initialized component for the target cell + * @return the component the grid rendered for the target cell * @throws IllegalArgumentException * when column for property doesn't exist or the target column * of the cell is not a component renderer + * @throws IllegalStateException + * when the grid renders no component for the cell, which is the + * case for a hidden column */ public Component getCellComponent(int row, String columnName) { ensureVisible(); - if (getComponent().getColumnByKey(columnName) == null) { + return getRenderedCellComponent(row, getColumnByKey(columnName)); + } + + /** + * Render the component for the cell in the given position on its own, + * without the grid, and attach it to the grid so that it can be used. + * + *

+ * This asks the column's component renderer for a component for the item on + * the row, which is not the instance the grid renders for the client: every + * call renders the cell again and attaches the new instance to the grid, so + * asking twice for the same cell leaves two instances behind and a later + * {@code find(...)} reports both. + * + *

+ * Prefer {@link #getCellComponent(int, int)}, which returns the component + * the browser shows. This method is for tests written against the older + * behaviour of that method. A column index addresses the visible columns, + * so the cells the grid renders nothing for, those of a hidden column, are + * only reachable through {@link #renderCellComponent(int, String)}. + * + * @param row + * item row + * @param column + * column to render + * @return a freshly rendered component for the targeted cell + * @throws IllegalArgumentException + * when the target column of the cell is not a component + * renderer + */ + public Component renderCellComponent(int row, int column) { + ensureVisible(); + return getRendererItem(row, getColumns().get(column)); + } + + /** + * Render the component for the cell in the given row of the column with the + * given key on its own, without the grid, and attach it to the grid so that + * it can be used. + * + *

+ * This asks the column's component renderer for a component for the item on + * the row, which is not the instance the grid renders for the client: every + * call renders the cell again and attaches the new instance to the grid, so + * asking twice for the same cell leaves two instances behind and a later + * {@code find(...)} reports both. + * + *

+ * Prefer {@link #getCellComponent(int, String)}, which returns the + * component the browser shows. This method is for the cases the grid does + * not render itself, such as a hidden column. + * + * @param row + * item row + * @param columnName + * key/property of column + * @return a freshly rendered component for the target cell + * @throws IllegalArgumentException + * when column for property doesn't exist or the target column + * of the cell is not a component renderer + */ + public Component renderCellComponent(int row, String columnName) { + ensureVisible(); + return getRendererItem(row, getColumnByKey(columnName)); + } + + private Grid.Column getColumnByKey(String columnName) { + final Grid.Column column = getComponent().getColumnByKey(columnName); + if (column == null) { throw new IllegalArgumentException( "No column for property '" + columnName + "' exists"); } + return column; + } + + private Component getRenderedCellComponent(int row, + Grid.Column yColumn) { + ensureComponentRenderer(yColumn); + if (!yColumn.isVisible()) { + throw new IllegalStateException("Column '" + yColumn.getKey() + + "' is not visible, so the grid renders no component for " + + "it. Use renderCellComponent to render the cell without " + + "the grid."); + } + // pending row rendering happens when the response is written + roundTrip(); + Component component = findRenderedCellComponent(row, yColumn); + if (component == null) { + // the client has not asked for the row yet, so the grid has not + // rendered it - bring it into the viewport as a user scrolling + // down to the row would + getComponent().scrollToIndex(row); + roundTrip(); + component = findRenderedCellComponent(row, yColumn); + } + if (component == null) { + throw new IllegalStateException("Grid rendered no component for " + + "row " + row + " in column '" + yColumn.getKey() + + "'. The item on the row is not the one the grid rendered," + + " which happens when the data provider returns items that" + + " are not equal across fetches. Use renderCellComponent" + + " to render the cell without the grid."); + } + return component; + } + + private Component findRenderedCellComponent(int row, + Grid.Column yColumn) { + final Y item = getRow(row); + final DataCommunicator dataCommunicator = getComponent() + .getDataCommunicator(); + if (!dataCommunicator.isItemActive(item)) { + return null; + } + return RenderedComponentSupport.getRenderedComponent(yColumn, + dataCommunicator.getKeyMapper().key(item)); + } - final Grid.Column yColumn = getComponent() - .getColumnByKey(columnName); - return getRendererItem(row, yColumn); + private void ensureComponentRenderer(Grid.Column yColumn) { + if (!(yColumn.getRenderer() instanceof ComponentRenderer)) { + throw new IllegalArgumentException( + "Target column doesn't have a ComponentRenderer."); + } } private Component getRendererItem(int row, Grid.Column yColumn) { - if (yColumn.getRenderer() instanceof ComponentRenderer) { - final Y item = getRow(row); - var component = ((ComponentRenderer) yColumn.getRenderer()) - .createComponent(item); - if (component != null) { - getComponent().getElement().appendChild(component.getElement()); - } - return component; + ensureComponentRenderer(yColumn); + final Y item = getRow(row); + var component = ((ComponentRenderer) yColumn.getRenderer()) + .createComponent(item); + if (component != null) { + getComponent().getElement().appendChild(component.getElement()); } - throw new IllegalArgumentException( - "Target column doesn't have a ComponentRenderer."); + return component; } private V getLitRendererPropertyValue(int row, Grid.Column column,