From 08aa69ea83f007f2febd3e5e15cda1090e3a9426 Mon Sep 17 00:00:00 2001 From: Mikael Grankvist Date: Thu, 23 May 2024 15:12:36 +0300 Subject: [PATCH 1/3] feat: Flow components as children for ReactAdapterComponent Add support for having Flow Components as children in a ReactAdapoterComponent Fixes #19379 --- .../java/com/vaadin/client/ElementUtil.java | 3 + .../java/com/vaadin/client/ReactUtils.java | 63 +++++++++++++++++ .../binding/SimpleElementBindingStrategy.java | 17 +++++ .../react/ReactAdapterComponent.java | 22 ++++++ .../server/frontend/ReactAdapter.template | 37 +++++++++- .../dom/impl/BasicElementStateProvider.java | 3 +- .../internal/nodefeature/NodeProperties.java | 5 ++ .../src/main/frontend/ReactLayout.tsx | 32 +++++++++ .../vaadin/flow/FlowInReactComponentView.java | 44 ++++++++++++ .../java/com/vaadin/flow/ReactLayout.java | 50 +++++++++++++ .../vaadin/flow/FlowInReactComponentIT.java | 70 +++++++++++++++++++ 11 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 flow-client/src/main/java/com/vaadin/client/ReactUtils.java create mode 100644 flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx create mode 100644 flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/FlowInReactComponentView.java create mode 100644 flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/ReactLayout.java create mode 100644 flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/FlowInReactComponentIT.java diff --git a/flow-client/src/main/java/com/vaadin/client/ElementUtil.java b/flow-client/src/main/java/com/vaadin/client/ElementUtil.java index dc51e44457f..d9f6e3c78f0 100644 --- a/flow-client/src/main/java/com/vaadin/client/ElementUtil.java +++ b/flow-client/src/main/java/com/vaadin/client/ElementUtil.java @@ -72,4 +72,7 @@ public static native Element getElementById(Node context, String id) } }-*/; + public static native Element getElementByName(Node context, String name) /*-{ + return Array.from(context.querySelectorAll('[name]')).find(function(e) {return e.getAttribute('name') == name}); + }-*/; } diff --git a/flow-client/src/main/java/com/vaadin/client/ReactUtils.java b/flow-client/src/main/java/com/vaadin/client/ReactUtils.java new file mode 100644 index 00000000000..6f824990519 --- /dev/null +++ b/flow-client/src/main/java/com/vaadin/client/ReactUtils.java @@ -0,0 +1,63 @@ +/* + * Copyright 2000-2024 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.client; + +import java.util.function.Supplier; + +import elemental.dom.Element; + +/** + * Utils class, intended to ease working with React component related code on + * the client side. + * + * @author Vaadin Ltd + * @since 24.5. + */ +public final class ReactUtils { + + /** + * Add a callback to the react component that is called when the component + * initialization is ready for binding flow. + * + * @param element + * react component element + * @param runnable + * callback function runnable + */ + public static native void addReadyCallback(Element element, + Runnable runnable) + /*-{ + if(element.addReadyCallback){ + element.addReadyCallback( + function() { + runnable.@java.lang.Runnable::run(*)(); + } + ); + } + }-*/; + + /** + * Check if the react element is initialized and functional. + * + * @param elementLookup + * react element lookup supplier + * @return {@code true} if Flow binding can already be done + */ + public static boolean isInitialized(Supplier elementLookup) { + return elementLookup.get() != null; + } +} diff --git a/flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java b/flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java index f266e6d1abd..8a682dfe66f 100644 --- a/flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java +++ b/flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java @@ -31,6 +31,7 @@ import com.vaadin.client.InitialPropertiesHandler; import com.vaadin.client.LitUtils; import com.vaadin.client.PolymerUtils; +import com.vaadin.client.ReactUtils; import com.vaadin.client.WidgetUtil; import com.vaadin.client.flow.ConstantPool; import com.vaadin.client.flow.StateNode; @@ -878,6 +879,22 @@ private void appendVirtualChild(BindingContext context, StateNode node, return; } handleTemplateInTemplate(context, node, object, reactivePhase); + } else if (NodeProperties.INJECT_BY_NAME.equals(type)) { + String name = object.getString(NodeProperties.PAYLOAD); + String address = "name='" + name + "'"; + + Supplier elementLookup = () -> ElementUtil + .getElementByName(context.htmlNode, name); + + if (!ReactUtils.isInitialized(elementLookup)) { + ReactUtils.addReadyCallback((Element) context.htmlNode, () -> { + doAppendVirtualChild(context, node, false, elementLookup, + name, address); + }); + return; + } + doAppendVirtualChild(context, node, reactivePhase, elementLookup, + name, address); } else { assert false : "Unexpected payload type " + type; } diff --git a/flow-react/src/main/java/com/vaadin/flow/component/react/ReactAdapterComponent.java b/flow-react/src/main/java/com/vaadin/flow/component/react/ReactAdapterComponent.java index b26938d69a1..6904f760311 100644 --- a/flow-react/src/main/java/com/vaadin/flow/component/react/ReactAdapterComponent.java +++ b/flow-react/src/main/java/com/vaadin/flow/component/react/ReactAdapterComponent.java @@ -19,14 +19,21 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.dom.DomListenerRegistration; +import com.vaadin.flow.dom.Element; import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.function.SerializableFunction; import com.vaadin.flow.internal.JsonCodec; import com.vaadin.flow.internal.JsonUtils; +import com.vaadin.flow.internal.StateNode; +import com.vaadin.flow.internal.nodefeature.NodeProperties; + import elemental.json.Json; import elemental.json.JsonValue; +import java.util.HashMap; +import java.util.Map; + /** * An abstract implementation of an adapter for integrating with React * components. To be used together with a React adapter Web Component that @@ -49,6 +56,8 @@ * @since 24.4 */ public abstract class ReactAdapterComponent extends Component { + private Map contentMap = new HashMap<>(); + /** * Adds the specified listener for the state change event in the React * adapter. @@ -177,6 +186,19 @@ protected static JsonValue writeAsJson(Object object) { return JsonUtils.writeValue(object); } + protected Element getContentElement(String name) { + if (!contentMap.containsKey(name)) { + var element = new Element("flow-content-container"); + contentMap.put(name, element); + getElement().getStateProvider().appendVirtualChild( + getElement().getNode(), element, + NodeProperties.INJECT_BY_NAME, name); + return element; + } + + return contentMap.get(name); + } + private JsonValue getPropertyJson(String propertyName) { var rawValue = getElement().getPropertyRaw(propertyName); if (rawValue == null) { diff --git a/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template b/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template index 8c6148abec9..1253c0a78cb 100644 --- a/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template +++ b/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template @@ -14,7 +14,13 @@ * the License. */ import {createRoot, Root} from "react-dom/client"; -import {createElement, type Dispatch, type ReactElement, useReducer} from "react"; +import { + createElement, + type Dispatch, + type ReactElement, Ref, useEffect, + useReducer, + useRef +} from "react"; type FlowStateKeyChangedAction = Readonly<{ type: 'stateKeyChanged', @@ -82,6 +88,10 @@ export type RenderHooks = { readonly useCustomEvent: ReactAdapterElement["useCustomEvent"] }; +interface ReadyCallbackFunction { + (): void; +} + /** * A base class for Web Components that render using React. Enables creating * adapters for integrating React components with Flow. Intended for use with @@ -94,8 +104,11 @@ export abstract class ReactAdapterElement extends HTMLElement { #state: Record = Object.create(null); #stateSetters = new Map>(); #customEvents = new Map>(); + #contents = new Map(); #dispatchFlowState: Dispatch = emptyAction; + #readyCallback: ReadyCallbackFunction = () => {}; + readonly #renderHooks: RenderHooks; readonly #Wrapper: () => ReactElement | null; @@ -109,15 +122,24 @@ export abstract class ReactAdapterElement extends HTMLElement { useCustomEvent: this.useCustomEvent.bind(this) }; this.#Wrapper = this.#renderWrapper.bind(this); - this.#markAsUsed(); } public async connectedCallback() { + for (const child of this.children) { + let name: string | null; + if (child.localName === 'flow-content-container' && (name = child.getAttribute('name'))) { + this.#contents.set(name, child); + } + } await this.#unmountComplete; this.#root = createRoot(this); this.#maybeRenderRoot(); } + public addReadyCallback(readyCallback: ReadyCallbackFunction) { + this.#readyCallback = readyCallback; + } + public async disconnectedCallback() { this.#unmountComplete = Promise.resolve(); await this.#unmountComplete; @@ -207,6 +229,17 @@ export abstract class ReactAdapterElement extends HTMLElement { */ protected abstract render(hooks: RenderHooks): ReactElement | null; + protected useContent(name: string): ReactElement | null { + const ref = useRef(null); + useEffect(() => { + // if (this.#contents.has(name)) { + this.#readyCallback(); + // ref.current?.appendChild(this.#contents.get(name)!); + // } + }, []); + return createElement('flow-content-container', {name, ref, style: {display: 'contents'}}); + } + #maybeRenderRoot() { if (this.#rootRendered || !this.#root) { return; diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java b/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java index a55ef50c30c..b162ee9b6a8 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java @@ -365,7 +365,8 @@ public void visit(StateNode node, NodeVisitor visitor) { visitDescendants = visitor .visit(NodeVisitor.ElementType.VIRTUAL, element); } else if (NodeProperties.INJECT_BY_ID.equals(type) - || NodeProperties.TEMPLATE_IN_TEMPLATE.equals(type)) { + || NodeProperties.TEMPLATE_IN_TEMPLATE.equals(type) + || NodeProperties.INJECT_BY_NAME.equals(type)) { visitDescendants = visitor.visit( NodeVisitor.ElementType.VIRTUAL_ATTACHED, element); } else { diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeProperties.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeProperties.java index e117a5e8bcf..fa190cba71c 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeProperties.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeProperties.java @@ -68,6 +68,11 @@ public final class NodeProperties { */ public static final String INJECT_BY_ID = "@id"; + /** + * JsonObject {@code @name} type value for {@link VirtualChildrenList}. + */ + public static final String INJECT_BY_NAME = "@name"; + /** * JsonObject template-in-template type value for * {@link VirtualChildrenList}. diff --git a/flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx b/flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx new file mode 100644 index 00000000000..d9e70eb7d89 --- /dev/null +++ b/flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2000-2024 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. + */ + +import {ReactAdapterElement, RenderHooks} from "Frontend/generated/flow/ReactAdapter.js"; +import React from "react"; + +class ReactLayoutElement extends ReactAdapterElement { + protected render(hooks: RenderHooks): React.ReactElement | null { + const content = this.useContent('content'); + return <> + Before Flow components content + {content} +
+ After Flow components content + ; + } +} + +customElements.define('react-layout', ReactLayoutElement); diff --git a/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/FlowInReactComponentView.java b/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/FlowInReactComponentView.java new file mode 100644 index 00000000000..1aee91f6f53 --- /dev/null +++ b/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/FlowInReactComponentView.java @@ -0,0 +1,44 @@ +/* + * Copyright 2000-2024 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.flow; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.router.Route; + +@Route("com.vaadin.flow.FlowInReactComponentView") +public class FlowInReactComponentView extends Div { + + public FlowInReactComponentView() { + + ReactLayout gridLayout = new ReactLayout(); + add(gridLayout); + + NativeButton addDiv = new NativeButton("Add div", + event -> gridLayout.add(new Div("Clicked button"))); + addDiv.setId("add"); + NativeButton removeDiv = new NativeButton("Remove div", + event -> gridLayout.getChildren() + .filter(component -> component instanceof Div) + .findFirst().ifPresent(Component::removeFromParent)); + removeDiv.setId("remove"); + + gridLayout.add(new H3("Flow Admin View"), addDiv, removeDiv); + } +} diff --git a/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/ReactLayout.java b/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/ReactLayout.java new file mode 100644 index 00000000000..00a17a2690f --- /dev/null +++ b/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/ReactLayout.java @@ -0,0 +1,50 @@ +/* + * Copyright 2000-2024 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.flow; + +import java.util.Arrays; +import java.util.stream.Stream; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.react.ReactAdapterComponent; +import com.vaadin.flow.dom.Element; + +@JsModule("./ReactLayout.tsx") +@Tag("react-layout") +public class ReactLayout extends ReactAdapterComponent { + + public ReactLayout(Component... components) { + add(components); + } + + public void add(Component... components) { + Arrays.stream(components).forEach(this::add); + } + + public void add(Component components) { + getContentElement("content").appendChild(components.getElement()); + } + + @Override + public Stream getChildren() { + return getContentElement("content").getChildren() + .map(Element::getComponent) + .flatMap(o -> o.map(Stream::of).orElseGet(Stream::empty)); + } +} diff --git a/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/FlowInReactComponentIT.java b/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/FlowInReactComponentIT.java new file mode 100644 index 00000000000..d6857b8fbf5 --- /dev/null +++ b/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/FlowInReactComponentIT.java @@ -0,0 +1,70 @@ +package com.vaadin.flow; + +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import com.vaadin.flow.component.html.testbench.DivElement; +import com.vaadin.flow.component.html.testbench.NativeButtonElement; +import com.vaadin.flow.component.html.testbench.SpanElement; +import com.vaadin.flow.testutil.ChromeBrowserTest; +import com.vaadin.testbench.TestBenchElement; + +public class FlowInReactComponentIT extends ChromeBrowserTest { + + @Test + public void validateComponentPlacesAndFunction() { + open(); + + waitForDevServer(); + + Assert.assertTrue("No react component displayed", + $("react-layout").first().isDisplayed()); + + List list = $("react-layout").first() + .findElements(By.xpath("./child::*")); + Assert.assertEquals("React component child count wrong", list.size(), + 4); + + Assert.assertEquals("span", list.get(0).getTagName()); + Assert.assertEquals("flow-content-container", list.get(1).getTagName()); + Assert.assertEquals("div", list.get(2).getTagName()); + Assert.assertEquals("span", list.get(3).getTagName()); + + TestBenchElement content = $("react-layout").first() + .findElement(By.name("content")); + + list = content.findElements(By.xpath("./child::*")); + Assert.assertEquals("Flow content container count wrong", list.size(), + 3); + + $(NativeButtonElement.class).id("add").click(); + Assert.assertEquals(1, content.$(DivElement.class).all().size()); + + list = $("react-layout").first().findElements(By.xpath("./child::*")); + Assert.assertEquals( + "Adding flow component should not add to main react component", + list.size(), 4); + + list = content.findElements(By.xpath("./child::*")); + Assert.assertEquals("Flow content container count wrong", list.size(), + 4); + + $(NativeButtonElement.class).id("add").click(); + Assert.assertEquals(2, content.$(DivElement.class).all().size()); + $(NativeButtonElement.class).id("add").click(); + Assert.assertEquals(3, content.$(DivElement.class).all().size()); + + $(NativeButtonElement.class).id("remove").click(); + Assert.assertEquals(2, content.$(DivElement.class).all().size()); + $(NativeButtonElement.class).id("remove").click(); + Assert.assertEquals(1, content.$(DivElement.class).all().size()); + $(NativeButtonElement.class).id("remove").click(); + Assert.assertEquals(0, content.$(DivElement.class).all().size()); + + } + +} From 30a696b0d6a85270a834aba2d01b018e36dd79e0 Mon Sep 17 00:00:00 2001 From: Mikael Grankvist Date: Fri, 24 May 2024 09:50:43 +0300 Subject: [PATCH 2/3] Enable use of multiple containers Review fixes. Cleanup. Expand test. --- .../java/com/vaadin/client/ReactUtils.java | 12 ++--- .../binding/SimpleElementBindingStrategy.java | 9 ++-- .../react/ReactAdapterComponent.java | 5 +- .../server/frontend/ReactAdapter.template | 21 ++------ .../src/main/frontend/ReactLayout.tsx | 3 ++ .../vaadin/flow/FlowInReactComponentView.java | 21 +++++++- .../java/com/vaadin/flow/ReactLayout.java | 21 +++++++- .../vaadin/flow/FlowInReactComponentIT.java | 48 +++++++++++++++---- 8 files changed, 100 insertions(+), 40 deletions(-) diff --git a/flow-client/src/main/java/com/vaadin/client/ReactUtils.java b/flow-client/src/main/java/com/vaadin/client/ReactUtils.java index 6f824990519..cfc4d13006c 100644 --- a/flow-client/src/main/java/com/vaadin/client/ReactUtils.java +++ b/flow-client/src/main/java/com/vaadin/client/ReactUtils.java @@ -35,18 +35,18 @@ public final class ReactUtils { * * @param element * react component element + * @param name + * name of container to bind to * @param runnable * callback function runnable */ - public static native void addReadyCallback(Element element, + public static native void addReadyCallback(Element element, String name, Runnable runnable) /*-{ if(element.addReadyCallback){ - element.addReadyCallback( - function() { - runnable.@java.lang.Runnable::run(*)(); - } - ); + element.addReadyCallback(name, + $entry(runnable.@java.lang.Runnable::run(*).bind(runnable)) + ); } }-*/; diff --git a/flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java b/flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java index 8a682dfe66f..31c5a8e925c 100644 --- a/flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java +++ b/flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java @@ -887,10 +887,11 @@ private void appendVirtualChild(BindingContext context, StateNode node, .getElementByName(context.htmlNode, name); if (!ReactUtils.isInitialized(elementLookup)) { - ReactUtils.addReadyCallback((Element) context.htmlNode, () -> { - doAppendVirtualChild(context, node, false, elementLookup, - name, address); - }); + ReactUtils.addReadyCallback((Element) context.htmlNode, name, + () -> { + doAppendVirtualChild(context, node, false, + elementLookup, name, address); + }); return; } doAppendVirtualChild(context, node, reactivePhase, elementLookup, diff --git a/flow-react/src/main/java/com/vaadin/flow/component/react/ReactAdapterComponent.java b/flow-react/src/main/java/com/vaadin/flow/component/react/ReactAdapterComponent.java index 6904f760311..abeb629155c 100644 --- a/flow-react/src/main/java/com/vaadin/flow/component/react/ReactAdapterComponent.java +++ b/flow-react/src/main/java/com/vaadin/flow/component/react/ReactAdapterComponent.java @@ -56,7 +56,7 @@ * @since 24.4 */ public abstract class ReactAdapterComponent extends Component { - private Map contentMap = new HashMap<>(); + private Map contentMap; /** * Adds the specified listener for the state change event in the React @@ -187,6 +187,9 @@ protected static JsonValue writeAsJson(Object object) { } protected Element getContentElement(String name) { + if (contentMap == null) { + contentMap = new HashMap<>(); + } if (!contentMap.containsKey(name)) { var element = new Element("flow-content-container"); contentMap.put(name, element); diff --git a/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template b/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template index 1253c0a78cb..3ba92cf2bc0 100644 --- a/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template +++ b/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template @@ -104,10 +104,9 @@ export abstract class ReactAdapterElement extends HTMLElement { #state: Record = Object.create(null); #stateSetters = new Map>(); #customEvents = new Map>(); - #contents = new Map(); #dispatchFlowState: Dispatch = emptyAction; - #readyCallback: ReadyCallbackFunction = () => {}; + #readyCallback = new Map(); readonly #renderHooks: RenderHooks; @@ -125,19 +124,13 @@ export abstract class ReactAdapterElement extends HTMLElement { } public async connectedCallback() { - for (const child of this.children) { - let name: string | null; - if (child.localName === 'flow-content-container' && (name = child.getAttribute('name'))) { - this.#contents.set(name, child); - } - } await this.#unmountComplete; this.#root = createRoot(this); this.#maybeRenderRoot(); } - public addReadyCallback(readyCallback: ReadyCallbackFunction) { - this.#readyCallback = readyCallback; + public addReadyCallback(id: string, readyCallback: ReadyCallbackFunction) { + this.#readyCallback.set(id, readyCallback); } public async disconnectedCallback() { @@ -230,14 +223,10 @@ export abstract class ReactAdapterElement extends HTMLElement { protected abstract render(hooks: RenderHooks): ReactElement | null; protected useContent(name: string): ReactElement | null { - const ref = useRef(null); useEffect(() => { - // if (this.#contents.has(name)) { - this.#readyCallback(); - // ref.current?.appendChild(this.#contents.get(name)!); - // } + this.#readyCallback.get(name)?.(); }, []); - return createElement('flow-content-container', {name, ref, style: {display: 'contents'}}); + return createElement('flow-content-container', {name, style: {display: 'contents'}}); } #maybeRenderRoot() { diff --git a/flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx b/flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx index d9e70eb7d89..ecc349f450d 100644 --- a/flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx +++ b/flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx @@ -20,11 +20,14 @@ import React from "react"; class ReactLayoutElement extends ReactAdapterElement { protected render(hooks: RenderHooks): React.ReactElement | null { const content = this.useContent('content'); + const second = this.useContent('second'); return <> Before Flow components content {content}
After Flow components content +
+ {second} ; } } diff --git a/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/FlowInReactComponentView.java b/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/FlowInReactComponentView.java index 1aee91f6f53..204dba2db73 100644 --- a/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/FlowInReactComponentView.java +++ b/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/FlowInReactComponentView.java @@ -19,12 +19,18 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.H4; import com.vaadin.flow.component.html.NativeButton; import com.vaadin.flow.router.Route; @Route("com.vaadin.flow.FlowInReactComponentView") public class FlowInReactComponentView extends Div { + public static final String ADD_MAIN = "add"; + public static final String REMOVE_MAIN = "remove"; + public static final String ADD_SECONDARY = "add-secondary"; + public static final String REMOVE_SECONDARY = "remove-secondary"; + public FlowInReactComponentView() { ReactLayout gridLayout = new ReactLayout(); @@ -32,13 +38,24 @@ public FlowInReactComponentView() { NativeButton addDiv = new NativeButton("Add div", event -> gridLayout.add(new Div("Clicked button"))); - addDiv.setId("add"); + addDiv.setId(ADD_MAIN); NativeButton removeDiv = new NativeButton("Remove div", event -> gridLayout.getChildren() .filter(component -> component instanceof Div) .findFirst().ifPresent(Component::removeFromParent)); - removeDiv.setId("remove"); + removeDiv.setId(REMOVE_MAIN); gridLayout.add(new H3("Flow Admin View"), addDiv, removeDiv); + + NativeButton addSecondary = new NativeButton("Add div", + event -> gridLayout.addSecondary(new Div("Secondary div"))); + addSecondary.setId(ADD_SECONDARY); + NativeButton removeSecondary = new NativeButton("Remove div", + event -> gridLayout.getSecondaryChildren() + .filter(component -> component instanceof Div) + .findFirst().ifPresent(Component::removeFromParent)); + removeSecondary.setId(REMOVE_SECONDARY); + gridLayout.addSecondary(new H4("Second container"), addSecondary, + removeSecondary); } } diff --git a/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/ReactLayout.java b/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/ReactLayout.java index 00a17a2690f..c6d587f7a93 100644 --- a/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/ReactLayout.java +++ b/flow-tests/test-react-adapter/src/main/java/com/vaadin/flow/ReactLayout.java @@ -29,6 +29,9 @@ @Tag("react-layout") public class ReactLayout extends ReactAdapterComponent { + public static final String MAIN_CONTENT = "content"; + public static final String SECONDARY_CONTENT = "second"; + public ReactLayout(Component... components) { add(components); } @@ -38,13 +41,27 @@ public void add(Component... components) { } public void add(Component components) { - getContentElement("content").appendChild(components.getElement()); + getContentElement(MAIN_CONTENT).appendChild(components.getElement()); } @Override public Stream getChildren() { - return getContentElement("content").getChildren() + return getContentElement(MAIN_CONTENT).getChildren() + .map(Element::getComponent) + .flatMap(o -> o.map(Stream::of).orElseGet(Stream::empty)); + } + + public void addSecondary(Component... components) { + for (Component component : components) { + getContentElement(SECONDARY_CONTENT) + .appendChild(component.getElement()); + } + } + + public Stream getSecondaryChildren() { + return getContentElement(SECONDARY_CONTENT).getChildren() .map(Element::getComponent) .flatMap(o -> o.map(Stream::of).orElseGet(Stream::empty)); } + } diff --git a/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/FlowInReactComponentIT.java b/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/FlowInReactComponentIT.java index d6857b8fbf5..d119bfca67c 100644 --- a/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/FlowInReactComponentIT.java +++ b/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/FlowInReactComponentIT.java @@ -13,6 +13,13 @@ import com.vaadin.flow.testutil.ChromeBrowserTest; import com.vaadin.testbench.TestBenchElement; +import static com.vaadin.flow.FlowInReactComponentView.ADD_MAIN; +import static com.vaadin.flow.FlowInReactComponentView.ADD_SECONDARY; +import static com.vaadin.flow.FlowInReactComponentView.REMOVE_MAIN; +import static com.vaadin.flow.FlowInReactComponentView.REMOVE_SECONDARY; +import static com.vaadin.flow.ReactLayout.MAIN_CONTENT; +import static com.vaadin.flow.ReactLayout.SECONDARY_CONTENT; + public class FlowInReactComponentIT extends ChromeBrowserTest { @Test @@ -27,44 +34,67 @@ public void validateComponentPlacesAndFunction() { List list = $("react-layout").first() .findElements(By.xpath("./child::*")); Assert.assertEquals("React component child count wrong", list.size(), - 4); + 6); Assert.assertEquals("span", list.get(0).getTagName()); Assert.assertEquals("flow-content-container", list.get(1).getTagName()); Assert.assertEquals("div", list.get(2).getTagName()); Assert.assertEquals("span", list.get(3).getTagName()); + Assert.assertEquals("div", list.get(4).getTagName()); + Assert.assertEquals("flow-content-container", list.get(5).getTagName()); TestBenchElement content = $("react-layout").first() - .findElement(By.name("content")); + .findElement(By.name(MAIN_CONTENT)); + TestBenchElement secondary = $("react-layout").first() + .findElement(By.name(SECONDARY_CONTENT)); list = content.findElements(By.xpath("./child::*")); Assert.assertEquals("Flow content container count wrong", list.size(), 3); - $(NativeButtonElement.class).id("add").click(); + $(NativeButtonElement.class).id(ADD_MAIN).click(); Assert.assertEquals(1, content.$(DivElement.class).all().size()); list = $("react-layout").first().findElements(By.xpath("./child::*")); Assert.assertEquals( "Adding flow component should not add to main react component", - list.size(), 4); + list.size(), 6); + + list = secondary.findElements(By.xpath("./child::*")); + Assert.assertEquals( + "Adding flow component should not add to secondary flow content", + list.size(), 3); list = content.findElements(By.xpath("./child::*")); Assert.assertEquals("Flow content container count wrong", list.size(), 4); - $(NativeButtonElement.class).id("add").click(); + $(NativeButtonElement.class).id(ADD_MAIN).click(); Assert.assertEquals(2, content.$(DivElement.class).all().size()); - $(NativeButtonElement.class).id("add").click(); + $(NativeButtonElement.class).id(ADD_MAIN).click(); Assert.assertEquals(3, content.$(DivElement.class).all().size()); - $(NativeButtonElement.class).id("remove").click(); + $(NativeButtonElement.class).id(REMOVE_MAIN).click(); Assert.assertEquals(2, content.$(DivElement.class).all().size()); - $(NativeButtonElement.class).id("remove").click(); + $(NativeButtonElement.class).id(REMOVE_MAIN).click(); Assert.assertEquals(1, content.$(DivElement.class).all().size()); - $(NativeButtonElement.class).id("remove").click(); + $(NativeButtonElement.class).id(REMOVE_MAIN).click(); Assert.assertEquals(0, content.$(DivElement.class).all().size()); + $(NativeButtonElement.class).id(ADD_SECONDARY).click(); + Assert.assertEquals(1, secondary.$(DivElement.class).all().size()); + + list = content.findElements(By.xpath("./child::*")); + Assert.assertEquals("Flow content container count wrong", list.size(), + 3); + + list = $("react-layout").first().findElements(By.xpath("./child::*")); + Assert.assertEquals( + "Adding flow component should not add to main react component", + list.size(), 6); + + $(NativeButtonElement.class).id(REMOVE_SECONDARY).click(); + Assert.assertEquals(0, secondary.$(DivElement.class).all().size()); } } From 0e0c4ce6b757d3656e0754ca212627a75676f97a Mon Sep 17 00:00:00 2001 From: Mikael Grankvist Date: Mon, 3 Jun 2024 13:17:14 +0300 Subject: [PATCH 3/3] Add JSdoc for new method --- .../vaadin/flow/server/frontend/ReactAdapter.template | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template b/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template index 3ba92cf2bc0..c05f676a83e 100644 --- a/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template +++ b/flow-react/src/main/resources/com/vaadin/flow/server/frontend/ReactAdapter.template @@ -129,6 +129,16 @@ export abstract class ReactAdapterElement extends HTMLElement { this.#maybeRenderRoot(); } + /** + * Add a callback for specified element identifier to be called when + * react element is ready. + *

+ * For internal use only. May be renamed or removed in a future release. + * + * @param id element identifier that callback is for + * @param readyCallback callback method to be informed on element ready state + * @internal + */ public addReadyCallback(id: string, readyCallback: ReadyCallbackFunction) { this.#readyCallback.set(id, readyCallback); }