From 69b3861d68e327eda240caad2c4a37f88cb93b14 Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Fri, 20 Dec 2024 15:38:54 +0200 Subject: [PATCH 01/11] fix(flow-react): avoid Flow-React portal outlet removal conflicts Some routing cases in hybrid Flow layout + React view applications could produce DOM tree conflicts from Flow server-side changes and React client-side portal removal happening simultaneously. This could throw DOM `NotFoundError` in the browser. This change introduces a dedicated DOM element for React portal outlet, which allows to avoid the error. Fixes vaadin/hilla#3002 --- .../flow/server/frontend/ReactAdapter.template | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 f1ce4af0874..ecaed86d233 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 @@ -139,6 +139,12 @@ export abstract class ReactAdapterElement extends HTMLElement { #unmountComplete = Promise.resolve(); + // NOTE: Separate element used as a portal outlet in React. Allows to avoid + // removal conflicts when Flow and React clear or remove it simultaneously, + // e. g., when navigating away from the `ReactRouterOutlet`. See also: + // https://github.com/vaadin/hilla/issues/3002 + #portalOutlet: HTMLElement | null = null; + constructor() { super(); this.#renderHooks = { @@ -151,9 +157,14 @@ export abstract class ReactAdapterElement extends HTMLElement { } public async connectedCallback() { + if (this.#portalOutlet === null) { + this.#portalOutlet = document.createElement('flow-portal-outlet'); + this.#portalOutlet.style.display = 'contents'; + this.appendChild(this.#portalOutlet); + } await this.#unmountComplete; this.#rendering = createElement(this.#Wrapper); - const createNewRoot = this.dispatchEvent(new CustomEvent('flow-portal-add', { + const createNewRoot = this.#portalOutlet!.dispatchEvent(new CustomEvent('flow-portal-add', { bubbles: true, cancelable: true, composed: true, @@ -167,7 +178,7 @@ export abstract class ReactAdapterElement extends HTMLElement { return; } - this.#root = createRoot(this); + this.#root = createRoot(this.#portalOutlet!); this.#maybeRenderRoot(); this.#root.render(this.#rendering); } @@ -187,7 +198,7 @@ export abstract class ReactAdapterElement extends HTMLElement { } public async disconnectedCallback() { - this.dispatchEvent(new CustomEvent('flow-portal-remove', { + this.#portalOutlet!.dispatchEvent(new CustomEvent('flow-portal-remove', { bubbles: true, cancelable: true, composed: true, From 28ef2d78327cc823db5c954331ebad43d4328c0c Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Fri, 20 Dec 2024 17:44:46 +0200 Subject: [PATCH 02/11] fix(flow-react): use element as portal outlet --- .../com/vaadin/flow/server/frontend/ReactAdapter.template | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ecaed86d233..565b04bba56 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 @@ -170,7 +170,7 @@ export abstract class ReactAdapterElement extends HTMLElement { composed: true, detail: { children: this.#rendering, - domNode: this, + domNode: this.#portalOutlet!, } })); @@ -204,7 +204,7 @@ export abstract class ReactAdapterElement extends HTMLElement { composed: true, detail: { children: this.#rendering, - domNode: this, + domNode: this.#portalOutlet!, } })); this.#unmountComplete = Promise.resolve(); From 15da758f8ba19434bb42c43b79f0b6b783023d17 Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Fri, 20 Dec 2024 17:45:37 +0200 Subject: [PATCH 03/11] test(react-adapter): assert new adapter DOM structure --- .../vaadin/flow/FlowInReactComponentIT.java | 32 +++++++++---------- .../java/com/vaadin/flow/ReactAdapterIT.java | 7 ++++ 2 files changed, 23 insertions(+), 16 deletions(-) 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 d119bfca67c..7df24ebf741 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 @@ -9,7 +9,6 @@ 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; @@ -31,10 +30,9 @@ public void validateComponentPlacesAndFunction() { Assert.assertTrue("No react component displayed", $("react-layout").first().isDisplayed()); - List list = $("react-layout").first() + List list = getReactLayoutOutlet() .findElements(By.xpath("./child::*")); - Assert.assertEquals("React component child count wrong", list.size(), - 6); + Assert.assertEquals("React component child count wrong", 6, list.size()); Assert.assertEquals("span", list.get(0).getTagName()); Assert.assertEquals("flow-content-container", list.get(1).getTagName()); @@ -43,9 +41,9 @@ public void validateComponentPlacesAndFunction() { Assert.assertEquals("div", list.get(4).getTagName()); Assert.assertEquals("flow-content-container", list.get(5).getTagName()); - TestBenchElement content = $("react-layout").first() + TestBenchElement content = getReactLayoutOutlet() .findElement(By.name(MAIN_CONTENT)); - TestBenchElement secondary = $("react-layout").first() + TestBenchElement secondary = getReactLayoutOutlet() .findElement(By.name(SECONDARY_CONTENT)); list = content.findElements(By.xpath("./child::*")); @@ -55,19 +53,18 @@ public void validateComponentPlacesAndFunction() { $(NativeButtonElement.class).id(ADD_MAIN).click(); Assert.assertEquals(1, content.$(DivElement.class).all().size()); - list = $("react-layout").first().findElements(By.xpath("./child::*")); + list = getReactLayoutOutlet().findElements(By.xpath("./child::*")); Assert.assertEquals( "Adding flow component should not add to main react component", - list.size(), 6); + 6, list.size()); list = secondary.findElements(By.xpath("./child::*")); Assert.assertEquals( "Adding flow component should not add to secondary flow content", - list.size(), 3); + 3, list.size()); list = content.findElements(By.xpath("./child::*")); - Assert.assertEquals("Flow content container count wrong", list.size(), - 4); + Assert.assertEquals("Flow content container count wrong", 4, list.size()); $(NativeButtonElement.class).id(ADD_MAIN).click(); Assert.assertEquals(2, content.$(DivElement.class).all().size()); @@ -85,16 +82,19 @@ public void validateComponentPlacesAndFunction() { Assert.assertEquals(1, secondary.$(DivElement.class).all().size()); list = content.findElements(By.xpath("./child::*")); - Assert.assertEquals("Flow content container count wrong", list.size(), - 3); + Assert.assertEquals("Flow content container count wrong", 3, list.size()); - list = $("react-layout").first().findElements(By.xpath("./child::*")); + list = getReactLayoutOutlet().findElements(By.xpath("./child::*")); Assert.assertEquals( - "Adding flow component should not add to main react component", - list.size(), 6); + "Adding flow component should not add to main react component", 6, + list.size()); $(NativeButtonElement.class).id(REMOVE_SECONDARY).click(); Assert.assertEquals(0, secondary.$(DivElement.class).all().size()); } + private TestBenchElement getReactLayoutOutlet() { + return $("react-layout").first().$("flow-portal-outlet").first(); + } + } diff --git a/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/ReactAdapterIT.java b/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/ReactAdapterIT.java index e8d23267dc9..5cd2a93ea3f 100644 --- a/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/ReactAdapterIT.java +++ b/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/ReactAdapterIT.java @@ -7,6 +7,7 @@ import org.junit.Test; import com.vaadin.flow.testutil.ChromeBrowserTest; +import org.openqa.selenium.By; public class ReactAdapterIT extends ChromeBrowserTest { @@ -22,6 +23,12 @@ public void validateInitialState() { $(NativeButtonElement.class).id("getValueButton").click(); Assert.assertEquals("initialValue", $(SpanElement.class).id("getOutput").getText()); + + var adapterFirstChild = getAdapterElement().findElement(By.xpath("./child::*")); + Assert.assertEquals("Missing React root wrapper", "flow-portal-outlet", adapterFirstChild.getTagName()); + var nativeInputElement = adapterFirstChild.findElement(By.xpath("./child::*")); + Assert.assertNotNull(nativeInputElement); + Assert.assertEquals("Unexpected first child", getReactElement(), nativeInputElement); } @Test From 196ee1297d55da042e25ab4d1676f137dfa6adf1 Mon Sep 17 00:00:00 2001 From: Vlad Rindevich Date: Mon, 23 Dec 2024 14:06:29 +0200 Subject: [PATCH 04/11] fix(server, react): resolve racing condition on react-router-outlet unmounting --- .../server/frontend/ReactAdapter.template | 38 +- .../com/vaadin/flow/server/frontend/Flow.tsx | 830 +++++++++--------- 2 files changed, 454 insertions(+), 414 deletions(-) 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 565b04bba56..680637370ff 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 @@ -19,10 +19,8 @@ import { type Dispatch, type ReactElement, type ReactNode, - Ref, useEffect, useReducer, - useRef } from "react"; type FlowStateKeyChangedAction = Readonly<{ @@ -137,13 +135,8 @@ export abstract class ReactAdapterElement extends HTMLElement { readonly #Wrapper: () => ReactElement | null; - #unmountComplete = Promise.resolve(); - - // NOTE: Separate element used as a portal outlet in React. Allows to avoid - // removal conflicts when Flow and React clear or remove it simultaneously, - // e. g., when navigating away from the `ReactRouterOutlet`. See also: - // https://github.com/vaadin/hilla/issues/3002 - #portalOutlet: HTMLElement | null = null; + #unmounting = Promise.resolve(); + #resolveUnmounting = () => {}; constructor() { super(); @@ -157,20 +150,15 @@ export abstract class ReactAdapterElement extends HTMLElement { } public async connectedCallback() { - if (this.#portalOutlet === null) { - this.#portalOutlet = document.createElement('flow-portal-outlet'); - this.#portalOutlet.style.display = 'contents'; - this.appendChild(this.#portalOutlet); - } - await this.#unmountComplete; + await this.#unmounting; this.#rendering = createElement(this.#Wrapper); - const createNewRoot = this.#portalOutlet!.dispatchEvent(new CustomEvent('flow-portal-add', { + const createNewRoot = this.dispatchEvent(new CustomEvent('flow-portal-add', { bubbles: true, cancelable: true, composed: true, detail: { children: this.#rendering, - domNode: this.#portalOutlet!, + domNode: this, } })); @@ -178,7 +166,7 @@ export abstract class ReactAdapterElement extends HTMLElement { return; } - this.#root = createRoot(this.#portalOutlet!); + this.#root = createRoot(this); this.#maybeRenderRoot(); this.#root.render(this.#rendering); } @@ -198,23 +186,29 @@ export abstract class ReactAdapterElement extends HTMLElement { } public async disconnectedCallback() { - this.#portalOutlet!.dispatchEvent(new CustomEvent('flow-portal-remove', { + this.dispatchEvent(new CustomEvent('flow-portal-remove', { bubbles: true, cancelable: true, composed: true, detail: { children: this.#rendering, - domNode: this.#portalOutlet!, + domNode: this, } })); - this.#unmountComplete = Promise.resolve(); - await this.#unmountComplete; + this.#unmounting = new Promise((resolve) => { + this.#resolveUnmounting = resolve; + }); + await this.#unmounting; this.#root?.unmount(); this.#root = undefined; this.#rootRendered = false; this.#rendering = undefined; } + rendered() { + this.#resolveUnmounting(); + } + /** * A hook API for using stateful JS properties of the Web Component from * the React `render()`. diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx index ebcf9e252ff..91af6f466d1 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx @@ -14,136 +14,138 @@ * the License. */ /// +import { nanoid } from 'nanoid'; +import type { ReactAdapterElement } from 'Frontend/generated/flow/ReactAdapter.js'; import { Flow as _Flow } from "Frontend/generated/jar-resources/Flow.js"; import React, { - useCallback, - useEffect, - useReducer, - useRef, - useState, - type ReactNode + useCallback, + useEffect, + useReducer, + useRef, + useState, + type ReactNode } from "react"; import { - matchRoutes, - useBlocker, - useLocation, - useNavigate, - type NavigateOptions, useHref, + matchRoutes, + useBlocker, + useLocation, + useNavigate, + type NavigateOptions, useHref, } from "react-router"; import { createPortal } from "react-dom"; const flow = new _Flow({ - imports: () => import("Frontend/generated/flow/generated-flow-imports.js") + imports: () => import("Frontend/generated/flow/generated-flow-imports.js") }); const router = { - render() { - return Promise.resolve(); - } + render() { + return Promise.resolve(); + } }; // ClickHandler for vaadin-router-go event is copied from vaadin/router click.js // @ts-ignore function getAnchorOrigin(anchor) { - // IE11: on HTTP and HTTPS the default port is not included into - // window.location.origin, so won't include it here either. - const port = anchor.port; - const protocol = anchor.protocol; - const defaultHttp = protocol === 'http:' && port === '80'; - const defaultHttps = protocol === 'https:' && port === '443'; - const host = (defaultHttp || defaultHttps) - ? anchor.hostname // does not include the port number (e.g. www.example.org) - : anchor.host; // does include the port number (e.g. www.example.org:80) - return `${protocol}//${host}`; + // IE11: on HTTP and HTTPS the default port is not included into + // window.location.origin, so won't include it here either. + const port = anchor.port; + const protocol = anchor.protocol; + const defaultHttp = protocol === 'http:' && port === '80'; + const defaultHttps = protocol === 'https:' && port === '443'; + const host = (defaultHttp || defaultHttps) + ? anchor.hostname // does not include the port number (e.g. www.example.org) + : anchor.host; // does include the port number (e.g. www.example.org:80) + return `${protocol}//${host}`; } function normalizeURL(url: URL): void | string { - // ignore click if baseURI does not match the document (external) - if (!url.href.startsWith(document.baseURI)) { - return; - } + // ignore click if baseURI does not match the document (external) + if (!url.href.startsWith(document.baseURI)) { + return; + } - // Normalize path against baseURI - return '/' + url.href.slice(document.baseURI.length); + // Normalize path against baseURI + return '/' + url.href.slice(document.baseURI.length); } function extractPath(event: MouseEvent): void | string { - // ignore the click if the default action is prevented - if (event.defaultPrevented) { - return; - } - - // ignore the click if not with the primary mouse button - if (event.button !== 0) { - return; - } - - // ignore the click if a modifier key is pressed - if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { - return; - } - - // find the element that the click is at (or within) - let maybeAnchor = event.target; - const path = event.composedPath - ? event.composedPath() - // @ts-ignore - : (event.path || []); - - // example to check: `for...of` loop here throws the "Not yet implemented" error - for (let i = 0; i < path.length; i++) { - const target = path[i]; - if (target.nodeName && target.nodeName.toLowerCase() === 'a') { - maybeAnchor = target; - break; - } - } - + // ignore the click if the default action is prevented + if (event.defaultPrevented) { + return; + } + + // ignore the click if not with the primary mouse button + if (event.button !== 0) { + return; + } + + // ignore the click if a modifier key is pressed + if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { + return; + } + + // find the element that the click is at (or within) + let maybeAnchor = event.target; + const path = event.composedPath + ? event.composedPath() // @ts-ignore - while (maybeAnchor && maybeAnchor.nodeName.toLowerCase() !== 'a') { - // @ts-ignore - maybeAnchor = maybeAnchor.parentNode; + : (event.path || []); + + // example to check: `for...of` loop here throws the "Not yet implemented" error + for (let i = 0; i < path.length; i++) { + const target = path[i]; + if (target.nodeName && target.nodeName.toLowerCase() === 'a') { + maybeAnchor = target; + break; } + } - // ignore the click if not at an element + // @ts-ignore + while (maybeAnchor && maybeAnchor.nodeName.toLowerCase() !== 'a') { // @ts-ignore - if (!maybeAnchor || maybeAnchor.nodeName.toLowerCase() !== 'a') { - return; - } + maybeAnchor = maybeAnchor.parentNode; + } - const anchor = maybeAnchor as HTMLAnchorElement; + // ignore the click if not at an element + // @ts-ignore + if (!maybeAnchor || maybeAnchor.nodeName.toLowerCase() !== 'a') { + return; + } - // ignore the click if the element has a non-default target - if (anchor.target && anchor.target.toLowerCase() !== '_self') { - return; - } + const anchor = maybeAnchor as HTMLAnchorElement; - // ignore the click if the element has the 'download' attribute - if (anchor.hasAttribute('download')) { - return; - } + // ignore the click if the element has a non-default target + if (anchor.target && anchor.target.toLowerCase() !== '_self') { + return; + } - // ignore the click if the element has the 'router-ignore' attribute - if (anchor.hasAttribute('router-ignore')) { - return; - } + // ignore the click if the element has the 'download' attribute + if (anchor.hasAttribute('download')) { + return; + } - // ignore the click if the target URL is a fragment on the current page - if (anchor.pathname === window.location.pathname && anchor.hash !== '') { - // @ts-ignore - window.location.hash = anchor.hash; - return; - } + // ignore the click if the element has the 'router-ignore' attribute + if (anchor.hasAttribute('router-ignore')) { + return; + } - // ignore the click if the target is external to the app - // In IE11 HTMLAnchorElement does not have the `origin` property + // ignore the click if the target URL is a fragment on the current page + if (anchor.pathname === window.location.pathname && anchor.hash !== '') { // @ts-ignore - const origin = anchor.origin || getAnchorOrigin(anchor); - if (origin !== window.location.origin) { - return; - } + window.location.hash = anchor.hash; + return; + } - return normalizeURL(new URL(anchor.href, anchor.baseURI)); + // ignore the click if the target is external to the app + // In IE11 HTMLAnchorElement does not have the `origin` property + // @ts-ignore + const origin = anchor.origin || getAnchorOrigin(anchor); + if (origin !== window.location.origin) { + return; + } + + return normalizeURL(new URL(anchor.href, anchor.baseURI)); } /** @@ -152,17 +154,17 @@ function extractPath(event: MouseEvent): void | string { * @param search search of navigation */ function fireNavigated(pathname:string, search: string) { - setTimeout(() => { - window.dispatchEvent(new CustomEvent('vaadin-navigated', { - detail: { - pathname, - search - } - })); - // @ts-ignore - delete window.Vaadin.Flow.navigation; + setTimeout(() => { + window.dispatchEvent(new CustomEvent('vaadin-navigated', { + detail: { + pathname, + search } - ) + })); + // @ts-ignore + delete window.Vaadin.Flow.navigation; + } + ) } function postpone() { @@ -173,39 +175,79 @@ const prevent = () => postpone; type RouterContainer = Awaited>; type PortalEntry = { - readonly children: ReactNode, - readonly domNode: Element | DocumentFragment, + readonly children: ReactNode, + readonly domNode: ReactAdapterElement, }; -const enum PortalActionType { - Add = 'add', - Remove = 'remove', +type FlowPortalProps = React.PropsWithChildren>; + +function FlowPortal({children, domNode, onRemove}: FlowPortalProps) { + const [rendered, setRendered] = useState(false); + + useEffect(() => { + domNode.addEventListener('flow-portal-remove', (event) => { + event.preventDefault(); + onRemove(); + setRendered(true); + }, {once: true}); + }, []); + + useEffect(() => { + domNode.rendered(); + }, [rendered]); + + return createPortal(children, domNode); } -type PortalAction = { - readonly type: PortalActionType, - readonly entry: PortalEntry, -}; +const ADD_FLOW_PORTAL = 'ADD_FLOW_PORTAL'; -function portalsReducer(portals: readonly PortalEntry[], action: PortalAction) { - switch (action.type) { - case PortalActionType.Add: - return [ - ...portals, - action.entry - ]; - case PortalActionType.Remove: - return portals.filter(({domNode}) => domNode !== action.entry.domNode); - default: - return portals; - } +type AddFlowPortalAction = Readonly<{ + type: typeof ADD_FLOW_PORTAL, + portal: React.ReactElement; +}>; + +function addFlowPortal(portal: React.ReactElement): AddFlowPortalAction { + return { + type: ADD_FLOW_PORTAL, + portal, + }; } +const REMOVE_FLOW_PORTAL = 'REMOVE_FLOW_PORTAL'; + +type RemoveFlowPortalAction = Readonly<{ + type: typeof REMOVE_FLOW_PORTAL, + key: string; +}>; + +function removeFlowPortal(key: string): RemoveFlowPortalAction { + return { + type: REMOVE_FLOW_PORTAL, + key, + }; +} + +function flowPortalsReducer(portals: readonly React.ReactElement[], action: AddFlowPortalAction | RemoveFlowPortalAction) { + switch (action.type) { + case ADD_FLOW_PORTAL: + return [ + ...portals, + action.portal, + ]; + case REMOVE_FLOW_PORTAL: + return portals.filter(({key}) => key !== action.key); + default: + return portals; + } +} type NavigateOpts = { - to: string, - callback: boolean, - opts?: NavigateOptions + to: string, + callback: boolean, + opts?: NavigateOptions }; type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => void; @@ -216,262 +258,266 @@ type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => voi * queue for processing navigate calls. */ function useQueuedNavigate(waitReference: React.MutableRefObject | undefined>, navigated: React.MutableRefObject): NavigateFn { - const navigate = useNavigate(); - const navigateQueue = useRef([]).current; - const [navigateQueueLength, setNavigateQueueLength] = useState(0); - - const dequeueNavigation = useCallback(() => { - const navigateArgs = navigateQueue.shift(); - if (navigateArgs === undefined) { - // Empty queue, do nothing. - return; - } + const navigate = useNavigate(); + const navigateQueue = useRef([]).current; + const [navigateQueueLength, setNavigateQueueLength] = useState(0); + + const dequeueNavigation = useCallback(() => { + const navigateArgs = navigateQueue.shift(); + if (navigateArgs === undefined) { + // Empty queue, do nothing. + return; + } - const blockingNavigate = async () => { - if (waitReference.current) { - await waitReference.current; - waitReference.current = undefined; - } - navigated.current = !navigateArgs.callback; - navigate(navigateArgs.to, navigateArgs.opts); - setNavigateQueueLength(navigateQueue.length); - } - blockingNavigate(); - }, [navigate, setNavigateQueueLength]); - - const dequeueNavigationAfterCurrentTask = useCallback(() => { - queueMicrotask(dequeueNavigation); - }, [dequeueNavigation]); - - const enqueueNavigation = useCallback((to: string, callback: boolean, opts?: NavigateOptions) => { - navigateQueue.push({to: to, callback: callback, opts: opts}); - setNavigateQueueLength(navigateQueue.length); - if (navigateQueue.length === 1) { - // The first navigation can be started right after any pending sync - // jobs, which could add more navigations to the queue. - dequeueNavigationAfterCurrentTask(); - } - }, [setNavigateQueueLength, dequeueNavigationAfterCurrentTask]); + const blockingNavigate = async () => { + if (waitReference.current) { + await waitReference.current; + waitReference.current = undefined; + } + navigated.current = !navigateArgs.callback; + navigate(navigateArgs.to, navigateArgs.opts); + setNavigateQueueLength(navigateQueue.length); + } + blockingNavigate(); + }, [navigate, setNavigateQueueLength]); + + const dequeueNavigationAfterCurrentTask = useCallback(() => { + queueMicrotask(dequeueNavigation); + }, [dequeueNavigation]); + + const enqueueNavigation = useCallback((to: string, callback: boolean, opts?: NavigateOptions) => { + navigateQueue.push({to: to, callback: callback, opts: opts}); + setNavigateQueueLength(navigateQueue.length); + if (navigateQueue.length === 1) { + // The first navigation can be started right after any pending sync + // jobs, which could add more navigations to the queue. + dequeueNavigationAfterCurrentTask(); + } + }, [setNavigateQueueLength, dequeueNavigationAfterCurrentTask]); - useEffect(() => () => { - // The Flow component has rendered, but history might not be - // updated yet, as React Router does it asynchronously. - // Use microtask callback for history consistency. - dequeueNavigationAfterCurrentTask(); - }, [navigateQueueLength, dequeueNavigationAfterCurrentTask]); + useEffect(() => () => { + // The Flow component has rendered, but history might not be + // updated yet, as React Router does it asynchronously. + // Use microtask callback for history consistency. + dequeueNavigationAfterCurrentTask(); + }, [navigateQueueLength, dequeueNavigationAfterCurrentTask]); - return enqueueNavigation; + return enqueueNavigation; } function Flow() { - const ref = useRef(null); - const navigate = useNavigate(); - const blocker = useBlocker(({ currentLocation, nextLocation }) => { - navigated.current = navigated.current || (nextLocation.pathname === currentLocation.pathname && nextLocation.search === currentLocation.search && nextLocation.hash === currentLocation.hash); - return true; - }); - const location = useLocation(); - const navigated = useRef(false); - const blockerHandled = useRef(false); - const fromAnchor = useRef(false); - const containerRef = useRef(undefined); - const roundTrip = useRef | undefined>(undefined); - const queuedNavigate = useQueuedNavigate(roundTrip, navigated); - const basename = useHref('/'); - - // portalsReducer function is used as state outside the Flow component. - const [portals, dispatchPortalAction] = useReducer(portalsReducer, []); - - const removePortalEventHandler = useCallback((event: CustomEvent) => { - event.preventDefault(); - dispatchPortalAction({type: PortalActionType.Remove, entry: event.detail}); - }, [dispatchPortalAction]) - - const addPortalEventHandler = useCallback((event: CustomEvent) => { - event.preventDefault(); - // Add remove event listener to the portal node - event.detail.domNode.addEventListener('flow-portal-remove', removePortalEventHandler as EventListener, {once: true}); - dispatchPortalAction({type: PortalActionType.Add, entry: event.detail}); - }, [dispatchPortalAction, removePortalEventHandler]); - - const navigateEventHandler = useCallback((event: MouseEvent) => { - const path = extractPath(event); - if (!path) { - return; - } - - if (event && event.preventDefault) { - event.preventDefault(); - } - - navigated.current = false; - // When navigation is triggered by click on a link, fromAnchor is set to true - // in order to get a server round-trip even when navigating to the same URL again - fromAnchor.current = true; - navigate(path); - // Dispatch close event for overlay drawer on click navigation. - window.dispatchEvent(new CustomEvent('close-overlay-drawer')); - }, [navigate]); - - const vaadinRouterGoEventHandler = useCallback((event: CustomEvent) => { - const url = event.detail; - const path = normalizeURL(url); - if (!path) { - return; - } - - event.preventDefault(); - navigate(path); - }, [navigate]); + const ref = useRef(null); + const navigate = useNavigate(); + const blocker = useBlocker(({ currentLocation, nextLocation }) => { + navigated.current = navigated.current || (nextLocation.pathname === currentLocation.pathname && nextLocation.search === currentLocation.search && nextLocation.hash === currentLocation.hash); + return true; + }); + const location = useLocation(); + const navigated = useRef(false); + const blockerHandled = useRef(false); + const fromAnchor = useRef(false); + const containerRef = useRef(undefined); + const roundTrip = useRef | undefined>(undefined); + const queuedNavigate = useQueuedNavigate(roundTrip, navigated); + const basename = useHref('/'); + + // portalsReducer function is used as state outside the Flow component. + const [portals, dispatchPortalAction] = useReducer(flowPortalsReducer, []); + + const addPortalEventHandler = useCallback((event: CustomEvent) => { + event.preventDefault(); + + const key = nanoid(); + dispatchPortalAction( + addFlowPortal( + dispatchPortalAction(removeFlowPortal(key))}> + {event.detail.children} + + ) + ); + }, [dispatchPortalAction]); + + const navigateEventHandler = useCallback((event: MouseEvent) => { + const path = extractPath(event); + if (!path) { + return; + } - const vaadinNavigateEventHandler = useCallback((event: CustomEvent<{state: unknown, url: string, replace?: boolean, callback: boolean}>) => { - // @ts-ignore - window.Vaadin.Flow.navigation = true; - const path = '/' + event.detail.url; - fromAnchor.current = false; - queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace }); - }, [navigate]); + if (event && event.preventDefault) { + event.preventDefault(); + } - const redirect = useCallback((path: string) => { - return (() => { - navigate(path, {replace: true}); - }); - }, [navigate]); + navigated.current = false; + // When navigation is triggered by click on a link, fromAnchor is set to true + // in order to get a server round-trip even when navigating to the same URL again + fromAnchor.current = true; + navigate(path); + // Dispatch close event for overlay drawer on click navigation. + window.dispatchEvent(new CustomEvent('close-overlay-drawer')); + }, [navigate]); + + const vaadinRouterGoEventHandler = useCallback((event: CustomEvent) => { + const url = event.detail; + const path = normalizeURL(url); + if (!path) { + return; + } - useEffect(() => { - // @ts-ignore - window.addEventListener('vaadin-router-go', vaadinRouterGoEventHandler); - // @ts-ignore - window.addEventListener('vaadin-navigate', vaadinNavigateEventHandler); - - return () => { - // @ts-ignore - window.removeEventListener('vaadin-router-go', vaadinRouterGoEventHandler); - // @ts-ignore - window.removeEventListener('vaadin-navigate', vaadinNavigateEventHandler); - }; - }, [vaadinRouterGoEventHandler, vaadinNavigateEventHandler]); + event.preventDefault(); + navigate(path); + }, [navigate]); - useEffect(() => { - return () => { - containerRef.current?.parentNode?.removeChild(containerRef.current); - containerRef.current?.removeEventListener('flow-portal-add', addPortalEventHandler as EventListener); - containerRef.current = undefined; - }; - }, []); + const vaadinNavigateEventHandler = useCallback((event: CustomEvent<{state: unknown, url: string, replace?: boolean, callback: boolean}>) => { + // @ts-ignore + window.Vaadin.Flow.navigation = true; + const path = '/' + event.detail.url; + fromAnchor.current = false; + queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace }); + }, [navigate]); + + const redirect = useCallback((path: string) => { + return (() => { + navigate(path, {replace: true}); + }); + }, [navigate]); - useEffect(() => { - if (blocker.state === 'blocked') { - if(blockerHandled.current) { - // Blocker is handled and the new navigation - // gets queued to be executed after the current handling ends. - const {pathname, state} = blocker.location; - // Clear base name to not get /baseName/basename/path - const pathNoBase = pathname.substring(basename.length); - // path should always start with / else react-router will append to current url - queuedNavigate(pathNoBase.startsWith('/') ? pathNoBase : '/'+pathNoBase, true, { state: state, replace: true }); - return; - } - blockerHandled.current = true; - let blockingPromise: any; - roundTrip.current = new Promise((resolve,reject) => blockingPromise = {resolve:resolve,reject:reject}); - // Release blocker handling after promise is fulfilled - roundTrip.current.then(() => blockerHandled.current = false, () => blockerHandled.current = false); - - // Proceed to the blocked location, unless the navigation originates from a click on a link. - // In that case continue with function execution and perform a server round-trip - if (navigated.current && !fromAnchor.current) { - blocker.proceed(); - blockingPromise.resolve(); - return; + useEffect(() => { + // @ts-ignore + window.addEventListener('vaadin-router-go', vaadinRouterGoEventHandler); + // @ts-ignore + window.addEventListener('vaadin-navigate', vaadinNavigateEventHandler); + + return () => { + // @ts-ignore + window.removeEventListener('vaadin-router-go', vaadinRouterGoEventHandler); + // @ts-ignore + window.removeEventListener('vaadin-navigate', vaadinNavigateEventHandler); + }; + }, [vaadinRouterGoEventHandler, vaadinNavigateEventHandler]); + + useEffect(() => { + return () => { + containerRef.current?.parentNode?.removeChild(containerRef.current); + containerRef.current?.removeEventListener('flow-portal-add', addPortalEventHandler as EventListener); + containerRef.current = undefined; + }; + }, []); + + useEffect(() => { + if (blocker.state === 'blocked') { + if(blockerHandled.current) { + // Blocker is handled and the new navigation + // gets queued to be executed after the current handling ends. + const {pathname, state} = blocker.location; + // Clear base name to not get /baseName/basename/path + const pathNoBase = pathname.substring(basename.length); + // path should always start with / else react-router will append to current url + queuedNavigate(pathNoBase.startsWith('/') ? pathNoBase : '/'+pathNoBase, true, { state: state, replace: true }); + return; + } + blockerHandled.current = true; + let blockingPromise: any; + roundTrip.current = new Promise((resolve,reject) => blockingPromise = {resolve:resolve,reject:reject}); + // Release blocker handling after promise is fulfilled + roundTrip.current.then(() => blockerHandled.current = false, () => blockerHandled.current = false); + + // Proceed to the blocked location, unless the navigation originates from a click on a link. + // In that case continue with function execution and perform a server round-trip + if (navigated.current && !fromAnchor.current) { + blocker.proceed(); + blockingPromise.resolve(); + return; + } + fromAnchor.current = false; + const {pathname, search} = blocker.location; + const routes = ((window as any)?.Vaadin?.routesConfig || []) as any[]; + let matched = matchRoutes(Array.from(routes), pathname); + + // Navigation between server routes + // @ts-ignore + if (matched && matched.filter(path => path.route?.element?.type?.name === Flow.name).length != 0) { + containerRef.current?.onBeforeEnter?.call(containerRef?.current, + {pathname, search}, { + prevent() { + blocker.reset(); + blockingPromise.resolve(); + navigated.current = false; + }, + redirect, + continue() { + blocker.proceed(); + blockingPromise.resolve(); } - fromAnchor.current = false; - const {pathname, search} = blocker.location; - const routes = ((window as any)?.Vaadin?.routesConfig || []) as any[]; - let matched = matchRoutes(Array.from(routes), pathname); - - // Navigation between server routes - // @ts-ignore - if (matched && matched.filter(path => path.route?.element?.type?.name === Flow.name).length != 0) { - containerRef.current?.onBeforeEnter?.call(containerRef?.current, - {pathname, search}, { - prevent() { - blocker.reset(); - blockingPromise.resolve(); - navigated.current = false; - }, - redirect, - continue() { - blocker.proceed(); - blockingPromise.resolve(); - } - }, router); - navigated.current = true; + }, router); + navigated.current = true; + } else { + // For covering the 'server -> client' use case + Promise.resolve(containerRef.current?.onBeforeLeave?.call(containerRef?.current, { + pathname, + search + }, {prevent}, router)) + .then((cmd: unknown) => { + if (cmd === postpone && containerRef.current) { + // postponed navigation: expose existing blocker to Flow + containerRef.current.serverConnected = (cancel) => { + if (cancel) { + blocker.reset(); + blockingPromise.resolve(); + } else { + blocker.proceed(); + blockingPromise.resolve(); + } + } } else { - // For covering the 'server -> client' use case - Promise.resolve(containerRef.current?.onBeforeLeave?.call(containerRef?.current, { - pathname, - search - }, {prevent}, router)) - .then((cmd: unknown) => { - if (cmd === postpone && containerRef.current) { - // postponed navigation: expose existing blocker to Flow - containerRef.current.serverConnected = (cancel) => { - if (cancel) { - blocker.reset(); - blockingPromise.resolve(); - } else { - blocker.proceed(); - blockingPromise.resolve(); - } - } - } else { - // permitted navigation: proceed with the blocker - blocker.proceed(); - blockingPromise.resolve(); - } - }); + // permitted navigation: proceed with the blocker + blocker.proceed(); + blockingPromise.resolve(); } - } - }, [blocker.state, blocker.location]); + }); + } + } + }, [blocker.state, blocker.location]); - useEffect(() => { - if (blocker.state === 'blocked') { - return; + useEffect(() => { + if (blocker.state === 'blocked') { + return; + } + if (navigated.current) { + navigated.current = false; + fireNavigated(location.pathname,location.search); + return; + } + flow.serverSideRoutes[0].action({pathname: location.pathname, search: location.search}) + .then((container) => { + const outlet = ref.current?.parentNode; + if (outlet && outlet !== container.parentNode) { + outlet.append(container); + container.addEventListener('flow-portal-add', addPortalEventHandler as EventListener); + window.addEventListener('click', navigateEventHandler); + containerRef.current = container } - if (navigated.current) { - navigated.current = false; - fireNavigated(location.pathname,location.search); - return; + return container.onBeforeEnter?.call(container, {pathname: location.pathname, search: location.search}, {prevent, redirect, continue() { + fireNavigated(location.pathname,location.search);}}, router); + }) + .then((result: unknown) => { + if (typeof result === "function") { + result(); } - flow.serverSideRoutes[0].action({pathname: location.pathname, search: location.search}) - .then((container) => { - const outlet = ref.current?.parentNode; - if (outlet && outlet !== container.parentNode) { - outlet.append(container); - container.addEventListener('flow-portal-add', addPortalEventHandler as EventListener); - window.addEventListener('click', navigateEventHandler); - containerRef.current = container - } - return container.onBeforeEnter?.call(container, {pathname: location.pathname, search: location.search}, {prevent, redirect, continue() { - fireNavigated(location.pathname,location.search);}}, router); - }) - .then((result: unknown) => { - if (typeof result === "function") { - result(); - } - }); - }, [location]); + }); + }, [location]); - return <> - - {portals.map(({children, domNode}) => createPortal(children, domNode))} - ; + return <> + + {portals} + ; } Flow.type = 'FlowContainer'; // This is for copilot to recognize this export const serverSideRoutes = [ - { path: '/*', element: }, + { path: '/*', element: }, ]; /** @@ -482,27 +528,27 @@ export const serverSideRoutes = [ * @returns Promise(resolve, reject) that is fulfilled on script load */ export const loadComponentScript = (tag: String): Promise => { - return new Promise((resolve, reject) => { - useEffect(() => { - const script = document.createElement('script'); - script.src = `/web-component/${tag}.js`; - script.onload = function() { - resolve(); - }; - script.onerror = function(err) { - reject(err); - }; - document.head.appendChild(script); - - return () => { - document.head.removeChild(script); - } - }, []); - }); + return new Promise((resolve, reject) => { + useEffect(() => { + const script = document.createElement('script'); + script.src = `/web-component/${tag}.js`; + script.onload = function() { + resolve(); + }; + script.onerror = function(err) { + reject(err); + }; + document.head.appendChild(script); + + return () => { + document.head.removeChild(script); + } + }, []); + }); }; interface Properties { - [key: string]: string; + [key: string]: string; } /** @@ -514,18 +560,18 @@ interface Properties { * @param onerror optional callback for error loading the script */ export const reactElement = (tag: string, props?: Properties, onload?: () => void, onerror?: (err:any) => void) => { - loadComponentScript(tag).then(() => onload?.(), (err) => { - if(onerror) { - onerror(err); - } else { - console.error(`Failed to load script for ${tag}.`, err); - } - }); - - if(props) { - return React.createElement(tag, props); + loadComponentScript(tag).then(() => onload?.(), (err) => { + if(onerror) { + onerror(err); + } else { + console.error(`Failed to load script for ${tag}.`, err); } - return React.createElement(tag); + }); + + if(props) { + return React.createElement(tag, props); + } + return React.createElement(tag); }; export default Flow; From 6dcb9e123783c34a232cb472d74120dd28c36a1d Mon Sep 17 00:00:00 2001 From: Vlad Rindevich Date: Mon, 23 Dec 2024 14:12:41 +0200 Subject: [PATCH 05/11] fix(server, react): improve race condition resolution fix approach --- .../vaadin/flow/server/frontend/ReactAdapter.template | 9 +++------ .../resources/com/vaadin/flow/server/frontend/Flow.tsx | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) 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 680637370ff..8012ad174a7 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 @@ -136,7 +136,6 @@ export abstract class ReactAdapterElement extends HTMLElement { readonly #Wrapper: () => ReactElement | null; #unmounting = Promise.resolve(); - #resolveUnmounting = () => {}; constructor() { super(); @@ -196,7 +195,9 @@ export abstract class ReactAdapterElement extends HTMLElement { } })); this.#unmounting = new Promise((resolve) => { - this.#resolveUnmounting = resolve; + this.addEventListener('flow-portal-remove-done', () => { + resolve(); + }, {once: true}); }); await this.#unmounting; this.#root?.unmount(); @@ -205,10 +206,6 @@ export abstract class ReactAdapterElement extends HTMLElement { this.#rendering = undefined; } - rendered() { - this.#resolveUnmounting(); - } - /** * A hook API for using stateful JS properties of the Web Component from * the React `render()`. diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx index 91af6f466d1..ce5d0959817 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx @@ -196,7 +196,7 @@ function FlowPortal({children, domNode, onRemove}: FlowPortalProps) { }, []); useEffect(() => { - domNode.rendered(); + domNode.dispatchEvent(new Event('flow-portal-remove-done')); }, [rendered]); return createPortal(children, domNode); From 88e155662349d49590b5390757ae826304e0fbb0 Mon Sep 17 00:00:00 2001 From: Vlad Rindevich Date: Mon, 23 Dec 2024 16:08:42 +0200 Subject: [PATCH 06/11] refactor(server, react): simplify approach --- .../server/frontend/ReactAdapter.template | 38 ++++++++++--------- .../com/vaadin/flow/server/frontend/Flow.tsx | 7 ---- 2 files changed, 20 insertions(+), 25 deletions(-) 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 8012ad174a7..776212dba8d 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 @@ -149,7 +149,6 @@ export abstract class ReactAdapterElement extends HTMLElement { } public async connectedCallback() { - await this.#unmounting; this.#rendering = createElement(this.#Wrapper); const createNewRoot = this.dispatchEvent(new CustomEvent('flow-portal-add', { bubbles: true, @@ -165,6 +164,8 @@ export abstract class ReactAdapterElement extends HTMLElement { return; } + await this.#unmounting; + this.#root = createRoot(this); this.#maybeRenderRoot(); this.#root.render(this.#rendering); @@ -185,23 +186,24 @@ export abstract class ReactAdapterElement extends HTMLElement { } public async disconnectedCallback() { - this.dispatchEvent(new CustomEvent('flow-portal-remove', { - bubbles: true, - cancelable: true, - composed: true, - detail: { - children: this.#rendering, - domNode: this, - } - })); - this.#unmounting = new Promise((resolve) => { - this.addEventListener('flow-portal-remove-done', () => { - resolve(); - }, {once: true}); - }); - await this.#unmounting; - this.#root?.unmount(); - this.#root = undefined; + if (!this.#root) { + this.dispatchEvent(new CustomEvent('flow-portal-remove', { + bubbles: true, + cancelable: true, + composed: true, + detail: { + children: this.#rendering, + domNode: this, + } + })); + } else { + this.#unmounting = Promise.resolve(); + await this.#unmounting; + + this.#root?.unmount(); + this.#root = undefined; + } + this.#rootRendered = false; this.#rendering = undefined; } diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx index ce5d0959817..5b308a6ce9b 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx @@ -185,20 +185,13 @@ type FlowPortalProps = React.PropsWithChildren>; function FlowPortal({children, domNode, onRemove}: FlowPortalProps) { - const [rendered, setRendered] = useState(false); - useEffect(() => { domNode.addEventListener('flow-portal-remove', (event) => { event.preventDefault(); onRemove(); - setRendered(true); }, {once: true}); }, []); - useEffect(() => { - domNode.dispatchEvent(new Event('flow-portal-remove-done')); - }, [rendered]); - return createPortal(children, domNode); } From 6ee4e015f7319bbe9b96474e1bbde0a4599b9691 Mon Sep 17 00:00:00 2001 From: Vlad Rindevich Date: Mon, 23 Dec 2024 16:44:06 +0200 Subject: [PATCH 07/11] Revert "test(react-adapter): assert new adapter DOM structure" This reverts commit 15da758f8ba19434bb42c43b79f0b6b783023d17. --- .../vaadin/flow/FlowInReactComponentIT.java | 32 +++++++++---------- .../java/com/vaadin/flow/ReactAdapterIT.java | 7 ---- 2 files changed, 16 insertions(+), 23 deletions(-) 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 7df24ebf741..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 @@ -9,6 +9,7 @@ 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; @@ -30,9 +31,10 @@ public void validateComponentPlacesAndFunction() { Assert.assertTrue("No react component displayed", $("react-layout").first().isDisplayed()); - List list = getReactLayoutOutlet() + List list = $("react-layout").first() .findElements(By.xpath("./child::*")); - Assert.assertEquals("React component child count wrong", 6, list.size()); + Assert.assertEquals("React component child count wrong", list.size(), + 6); Assert.assertEquals("span", list.get(0).getTagName()); Assert.assertEquals("flow-content-container", list.get(1).getTagName()); @@ -41,9 +43,9 @@ public void validateComponentPlacesAndFunction() { Assert.assertEquals("div", list.get(4).getTagName()); Assert.assertEquals("flow-content-container", list.get(5).getTagName()); - TestBenchElement content = getReactLayoutOutlet() + TestBenchElement content = $("react-layout").first() .findElement(By.name(MAIN_CONTENT)); - TestBenchElement secondary = getReactLayoutOutlet() + TestBenchElement secondary = $("react-layout").first() .findElement(By.name(SECONDARY_CONTENT)); list = content.findElements(By.xpath("./child::*")); @@ -53,18 +55,19 @@ public void validateComponentPlacesAndFunction() { $(NativeButtonElement.class).id(ADD_MAIN).click(); Assert.assertEquals(1, content.$(DivElement.class).all().size()); - list = getReactLayoutOutlet().findElements(By.xpath("./child::*")); + list = $("react-layout").first().findElements(By.xpath("./child::*")); Assert.assertEquals( "Adding flow component should not add to main react component", - 6, list.size()); + list.size(), 6); list = secondary.findElements(By.xpath("./child::*")); Assert.assertEquals( "Adding flow component should not add to secondary flow content", - 3, list.size()); + list.size(), 3); list = content.findElements(By.xpath("./child::*")); - Assert.assertEquals("Flow content container count wrong", 4, list.size()); + Assert.assertEquals("Flow content container count wrong", list.size(), + 4); $(NativeButtonElement.class).id(ADD_MAIN).click(); Assert.assertEquals(2, content.$(DivElement.class).all().size()); @@ -82,19 +85,16 @@ public void validateComponentPlacesAndFunction() { Assert.assertEquals(1, secondary.$(DivElement.class).all().size()); list = content.findElements(By.xpath("./child::*")); - Assert.assertEquals("Flow content container count wrong", 3, list.size()); + Assert.assertEquals("Flow content container count wrong", list.size(), + 3); - list = getReactLayoutOutlet().findElements(By.xpath("./child::*")); + list = $("react-layout").first().findElements(By.xpath("./child::*")); Assert.assertEquals( - "Adding flow component should not add to main react component", 6, - list.size()); + "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()); } - private TestBenchElement getReactLayoutOutlet() { - return $("react-layout").first().$("flow-portal-outlet").first(); - } - } diff --git a/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/ReactAdapterIT.java b/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/ReactAdapterIT.java index 5cd2a93ea3f..e8d23267dc9 100644 --- a/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/ReactAdapterIT.java +++ b/flow-tests/test-react-adapter/src/test/java/com/vaadin/flow/ReactAdapterIT.java @@ -7,7 +7,6 @@ import org.junit.Test; import com.vaadin.flow.testutil.ChromeBrowserTest; -import org.openqa.selenium.By; public class ReactAdapterIT extends ChromeBrowserTest { @@ -23,12 +22,6 @@ public void validateInitialState() { $(NativeButtonElement.class).id("getValueButton").click(); Assert.assertEquals("initialValue", $(SpanElement.class).id("getOutput").getText()); - - var adapterFirstChild = getAdapterElement().findElement(By.xpath("./child::*")); - Assert.assertEquals("Missing React root wrapper", "flow-portal-outlet", adapterFirstChild.getTagName()); - var nativeInputElement = adapterFirstChild.findElement(By.xpath("./child::*")); - Assert.assertNotNull(nativeInputElement); - Assert.assertEquals("Unexpected first child", getReactElement(), nativeInputElement); } @Test From 19a4309ac062faacd7b2ca52d7487e8172a44fef Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Tue, 7 Jan 2025 13:57:09 +0200 Subject: [PATCH 08/11] refactor: remove dependencies --- .../main/resources/com/vaadin/flow/server/frontend/Flow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx index 5b308a6ce9b..a9292f4e5fa 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx @@ -14,8 +14,6 @@ * the License. */ /// -import { nanoid } from 'nanoid'; -import type { ReactAdapterElement } from 'Frontend/generated/flow/ReactAdapter.js'; import { Flow as _Flow } from "Frontend/generated/jar-resources/Flow.js"; import React, { useCallback, @@ -174,6 +172,8 @@ const prevent = () => postpone; type RouterContainer = Awaited>; +type ReactAdapterElement = HTMLElement; + type PortalEntry = { readonly children: ReactNode, readonly domNode: ReactAdapterElement, @@ -320,7 +320,7 @@ function Flow() { const addPortalEventHandler = useCallback((event: CustomEvent) => { event.preventDefault(); - const key = nanoid(); + const key = Math.random().toString(36).slice(2); dispatchPortalAction( addFlowPortal( Date: Tue, 14 Jan 2025 12:40:21 +0200 Subject: [PATCH 09/11] fix(server, react): improve race condition resolution [2] --- .../server/frontend/ReactAdapter.template | 546 +++++++++--------- .../com/vaadin/flow/server/frontend/Flow.tsx | 428 ++++++++------ 2 files changed, 513 insertions(+), 461 deletions(-) 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 776212dba8d..ecfa5ca622d 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 @@ -15,38 +15,38 @@ */ import {createRoot, Root} from "react-dom/client"; import { - createElement, - type Dispatch, - type ReactElement, - type ReactNode, - useEffect, - useReducer, + createElement, + type Dispatch, + type ReactElement, + type ReactNode, + useEffect, + useReducer, } from "react"; type FlowStateKeyChangedAction = Readonly<{ - type: 'stateKeyChanged', - key: K, - value: V, + type: 'stateKeyChanged', + key: K, + value: V, }>; type FlowStateReducerAction = FlowStateKeyChangedAction; function stateReducer>>(state: S, action: FlowStateReducerAction): S { - switch (action.type) { - case "stateKeyChanged": - const {key, value} = action; - return { - ...state, - key: value - } as S; - default: - return state; - } + switch (action.type) { + case "stateKeyChanged": + const {value} = action; + return { + ...state, + key: value + } as S; + default: + return state; + } } type DispatchEvent = T extends undefined - ? () => boolean - : (value: T) => boolean; + ? () => boolean + : (value: T) => boolean; const emptyAction: Dispatch = () => {}; @@ -55,63 +55,63 @@ const emptyAction: Dispatch = () => {}; * implementation. */ export type RenderHooks = { - /** - * A hook API for using stateful JS properties of the Web Component from - * the React `render()`. - * - * @typeParam T - Type of the state value - * - * @param key - Web Component property name, which is used for two-way - * value propagation from the server and back. - * @param initialValue - Fallback initial value (optional). Only applies if - * the Java component constructor does not invoke `setState`. - * @returns A tuple with two values: - * 1. The current state. - * 2. The `set` function for changing the state and triggering render - * @protected - */ - readonly useState: ReactAdapterElement["useState"] - - /** - * A hook helper to simplify dispatching a `CustomEvent` on the Web - * Component from React. - * - * @typeParam T - The type for `event.detail` value (optional). - * - * @param type - The `CustomEvent` type string. - * @param options - The settings for the `CustomEvent`. - * @returns The `dispatch` function. The function parameters change - * depending on the `T` generic type: - * - For `undefined` type (default), has no parameters. - * - For other types, has one parameter for the `event.detail` value of that type. - * @protected - */ - readonly useCustomEvent: ReactAdapterElement["useCustomEvent"] - - /** - * A hook helper to generate the content element with name attribute to bind - * the server-side Flow element for this component. - * - * This is used together with {@link ReactAdapterComponent::getContentElement} - * to have server-side component attach to the correct client element. - * - * Usage as follows: - * - * const content = hooks.useContent('content'); - * return <> - * {content} - * ; - * - * Note! Not adding the 'content' element into the dom will have the - * server throw a IllegalStateException for element with tag name not found. - * - * @param name - The name attribute of the element - */ - readonly useContent: ReactAdapterElement["useContent"] + /** + * A hook API for using stateful JS properties of the Web Component from + * the React `render()`. + * + * @typeParam T - Type of the state value + * + * @param key - Web Component property name, which is used for two-way + * value propagation from the server and back. + * @param initialValue - Fallback initial value (optional). Only applies if + * the Java component constructor does not invoke `setState`. + * @returns A tuple with two values: + * 1. The current state. + * 2. The `set` function for changing the state and triggering render + * @protected + */ + readonly useState: ReactAdapterElement["useState"] + + /** + * A hook helper to simplify dispatching a `CustomEvent` on the Web + * Component from React. + * + * @typeParam T - The type for `event.detail` value (optional). + * + * @param type - The `CustomEvent` type string. + * @param options - The settings for the `CustomEvent`. + * @returns The `dispatch` function. The function parameters change + * depending on the `T` generic type: + * - For `undefined` type (default), has no parameters. + * - For other types, has one parameter for the `event.detail` value of that type. + * @protected + */ + readonly useCustomEvent: ReactAdapterElement["useCustomEvent"] + + /** + * A hook helper to generate the content element with name attribute to bind + * the server-side Flow element for this component. + * + * This is used together with {@link ReactAdapterComponent::getContentElement} + * to have server-side component attach to the correct client element. + * + * Usage as follows: + * + * const content = hooks.useContent('content'); + * return <> + * {content} + * ; + * + * Note! Not adding the 'content' element into the dom will have the + * server throw a IllegalStateException for element with tag name not found. + * + * @param name - The name attribute of the element + */ + readonly useContent: ReactAdapterElement["useContent"] }; interface ReadyCallbackFunction { - (): void; + (): void; } /** @@ -120,214 +120,212 @@ interface ReadyCallbackFunction { * `ReactAdapterComponent` Flow Java class. */ export abstract class ReactAdapterElement extends HTMLElement { - #root: Root | undefined = undefined; - #rootRendered: boolean = false; - #rendering: ReactNode | undefined = undefined; - - #state: Record = Object.create(null); - #stateSetters = new Map>(); - #customEvents = new Map>(); - #dispatchFlowState: Dispatch = emptyAction; - - #readyCallback = new Map(); - - readonly #renderHooks: RenderHooks; - - readonly #Wrapper: () => ReactElement | null; - - #unmounting = Promise.resolve(); - - constructor() { - super(); - this.#renderHooks = { - useState: this.useState.bind(this), - useCustomEvent: this.useCustomEvent.bind(this), - useContent: this.useContent.bind(this) - }; - this.#Wrapper = this.#renderWrapper.bind(this); - this.#markAsUsed(); + #root: Root | undefined = undefined; + #rootRendered: boolean = false; + #rendering: ReactNode | undefined = undefined; + + #state: Record = Object.create(null); + #stateSetters = new Map>(); + #customEvents = new Map>(); + #dispatchFlowState: Dispatch = emptyAction; + + #readyCallback = new Map(); + + readonly #renderHooks: RenderHooks; + + readonly #Wrapper: () => ReactElement | null; + + #unmounting?: Promise; + + constructor() { + super(); + this.#renderHooks = { + useState: this.useState.bind(this), + useCustomEvent: this.useCustomEvent.bind(this), + useContent: this.useContent.bind(this) + }; + this.#Wrapper = this.#renderWrapper.bind(this); + this.#markAsUsed(); + } + + public async connectedCallback() { + this.#rendering = createElement(this.#Wrapper); + const createNewRoot = this.dispatchEvent(new CustomEvent('flow-portal-add', { + bubbles: true, + cancelable: true, + composed: true, + detail: { + children: this.#rendering, + domNode: this, + } + })); + + if (!createNewRoot || this.#root) { + return; } - public async connectedCallback() { - this.#rendering = createElement(this.#Wrapper); - const createNewRoot = this.dispatchEvent(new CustomEvent('flow-portal-add', { - bubbles: true, - cancelable: true, - composed: true, - detail: { - children: this.#rendering, - domNode: this, - } - })); - - if (!createNewRoot || this.#root) { - return; + await this.#unmounting; + + this.#root = createRoot(this); + this.#maybeRenderRoot(); + this.#root.render(this.#rendering); + } + + /** + * 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); + } + + public async disconnectedCallback() { + if (!this.#root) { + this.dispatchEvent(new CustomEvent('flow-portal-remove', { + bubbles: true, + cancelable: true, + composed: true, + detail: { + children: this.#rendering, + domNode: this, } - - await this.#unmounting; - - this.#root = createRoot(this); - this.#maybeRenderRoot(); - this.#root.render(this.#rendering); + })); + } else { + this.#unmounting = Promise.resolve(); + await this.#unmounting; + this.#root.unmount(); + this.#root = undefined; } - - /** - * 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); - } - - public async disconnectedCallback() { - if (!this.#root) { - this.dispatchEvent(new CustomEvent('flow-portal-remove', { - bubbles: true, - cancelable: true, - composed: true, - detail: { - children: this.#rendering, - domNode: this, - } - })); - } else { - this.#unmounting = Promise.resolve(); - await this.#unmounting; - - this.#root?.unmount(); - this.#root = undefined; - } - - this.#rootRendered = false; - this.#rendering = undefined; + this.#rootRendered = false; + this.#rendering = undefined; + } + + /** + * A hook API for using stateful JS properties of the Web Component from + * the React `render()`. + * + * @typeParam T - Type of the state value + * + * @param key - Web Component property name, which is used for two-way + * value propagation from the server and back. + * @param initialValue - Fallback initial value (optional). Only applies if + * the Java component constructor does not invoke `setState`. + * @returns A tuple with two values: + * 1. The current state. + * 2. The `set` function for changing the state and triggering render + * @protected + */ + protected useState(key: string, initialValue?: T): [value: T, setValue: Dispatch] { + if (this.#stateSetters.has(key)) { + return [this.#state[key] as T, this.#stateSetters.get(key)!]; } - /** - * A hook API for using stateful JS properties of the Web Component from - * the React `render()`. - * - * @typeParam T - Type of the state value - * - * @param key - Web Component property name, which is used for two-way - * value propagation from the server and back. - * @param initialValue - Fallback initial value (optional). Only applies if - * the Java component constructor does not invoke `setState`. - * @returns A tuple with two values: - * 1. The current state. - * 2. The `set` function for changing the state and triggering render - * @protected - */ - protected useState(key: string, initialValue?: T): [value: T, setValue: Dispatch] { - if (this.#stateSetters.has(key)) { - return [this.#state[key] as T, this.#stateSetters.get(key)!]; - } - - const value = ((this as Record)[key] as T) ?? initialValue!; - this.#state[key] = value; - Object.defineProperty(this, key, { - enumerable: true, - get(): T { - return this.#state[key]; - }, - set(nextValue: T) { - this.#state[key] = nextValue; - this.#dispatchFlowState({type: 'stateKeyChanged', key, value}); - } - }); - - const dispatchChangedEvent = this.useCustomEvent<{value: T}>(`${key}-changed`, {detail: {value}}); - const setValue = (value: T) => { - this.#state[key] = value; - dispatchChangedEvent({value}); - this.#dispatchFlowState({type: 'stateKeyChanged', key, value}); + const value = ((this as Record)[key] as T) ?? initialValue!; + this.#state[key] = value; + Object.defineProperty(this, key, { + enumerable: true, + get(): T { + return this.#state[key]; + }, + set(nextValue: T) { + this.#state[key] = nextValue; + this.#dispatchFlowState({type: 'stateKeyChanged', key, value}); + } + }); + + const dispatchChangedEvent = this.useCustomEvent<{value: T}>(`${key}-changed`, {detail: {value}}); + const setValue = (value: T) => { + this.#state[key] = value; + dispatchChangedEvent({value}); + this.#dispatchFlowState({type: 'stateKeyChanged', key, value}); + }; + this.#stateSetters.set(key, setValue as Dispatch); + return [value, setValue]; + } + + /** + * A hook helper to simplify dispatching a `CustomEvent` on the Web + * Component from React. + * + * @typeParam T - The type for `event.detail` value (optional). + * + * @param type - The `CustomEvent` type string. + * @param options - The settings for the `CustomEvent`. + * @returns The `dispatch` function. The function parameters change + * depending on the `T` generic type: + * - For `undefined` type (default), has no parameters. + * - For other types, has one parameter for the `event.detail` value of that type. + * @protected + */ + protected useCustomEvent(type: string, options: CustomEventInit = {}): DispatchEvent { + if (!this.#customEvents.has(type)) { + const dispatch = ((detail?: T) => { + const eventInitDict = detail === undefined ? options : { + ...options, + detail }; - this.#stateSetters.set(key, setValue as Dispatch); - return [value, setValue]; - } - - /** - * A hook helper to simplify dispatching a `CustomEvent` on the Web - * Component from React. - * - * @typeParam T - The type for `event.detail` value (optional). - * - * @param type - The `CustomEvent` type string. - * @param options - The settings for the `CustomEvent`. - * @returns The `dispatch` function. The function parameters change - * depending on the `T` generic type: - * - For `undefined` type (default), has no parameters. - * - For other types, has one parameter for the `event.detail` value of that type. - * @protected - */ - protected useCustomEvent(type: string, options: CustomEventInit = {}): DispatchEvent { - if (!this.#customEvents.has(type)) { - const dispatch = ((detail?: T) => { - const eventInitDict = detail === undefined ? options : { - ...options, - detail - }; - const event = new CustomEvent(type, eventInitDict); - return this.dispatchEvent(event); - }) as DispatchEvent; - this.#customEvents.set(type, dispatch as DispatchEvent); - return dispatch; - } - return this.#customEvents.get(type)! as DispatchEvent; - } - - /** - * The Web Component render function. To be implemented by users with React. - * - * @param hooks - the adapter APIs exposed for the implementation. - * @protected - */ - protected abstract render(hooks: RenderHooks): ReactElement | null; - - /** - * Prepare content container for Flow to bind server Element to. - * - * @param name container name attribute matching server name attribute - * @protected - */ - protected useContent(name: string): ReactElement | null { - useEffect(() => { - this.#readyCallback.get(name)?.(); - }, []); - return createElement('flow-content-container', {name, style: {display: 'contents'}}); + const event = new CustomEvent(type, eventInitDict); + return this.dispatchEvent(event); + }) as DispatchEvent; + this.#customEvents.set(type, dispatch as DispatchEvent); + return dispatch; } - - #maybeRenderRoot() { - if (this.#rootRendered || !this.#root) { - return; - } - - this.#root.render(createElement(this.#Wrapper)); - this.#rootRendered = true; + return this.#customEvents.get(type)! as DispatchEvent; + } + + /** + * The Web Component render function. To be implemented by users with React. + * + * @param hooks - the adapter APIs exposed for the implementation. + * @protected + */ + protected abstract render(hooks: RenderHooks): ReactElement | null; + + /** + * Prepare content container for Flow to bind server Element to. + * + * @param name container name attribute matching server name attribute + * @protected + */ + protected useContent(name: string): ReactElement | null { + useEffect(() => { + this.#readyCallback.get(name)?.(); + }, []); + return createElement('flow-content-container', {name, style: {display: 'contents'}}); + } + + #maybeRenderRoot() { + if (this.#rootRendered || !this.#root) { + return; } - #renderWrapper(): ReactElement | null { - const [state, dispatchFlowState] = useReducer(stateReducer, this.#state); - this.#state = state; - this.#dispatchFlowState = dispatchFlowState; - return this.render(this.#renderHooks); - } - - #markAsUsed() : void { - // @ts-ignore - let vaadinObject = window.Vaadin || {}; - // @ts-ignore - if (vaadinObject.developmentMode) { - vaadinObject.registrations = vaadinObject.registrations || []; - vaadinObject.registrations.push({ - is: 'ReactAdapterElement', - version: '{{VAADIN_VERSION}}' - }); - } + this.#root.render(createElement(this.#Wrapper)); + this.#rootRendered = true; + } + + #renderWrapper(): ReactElement | null { + const [state, dispatchFlowState] = useReducer(stateReducer, this.#state); + this.#state = state; + this.#dispatchFlowState = dispatchFlowState; + return this.render(this.#renderHooks); + } + + #markAsUsed() : void { + // @ts-ignore + let vaadinObject = window.Vaadin || {}; + // @ts-ignore + if (vaadinObject.developmentMode) { + vaadinObject.registrations = vaadinObject.registrations || []; + vaadinObject.registrations.push({ + is: 'ReactAdapterElement', + version: '{{VAADIN_VERSION}}' + }); } + } } diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx index 5b308a6ce9b..b8f849438a5 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx @@ -15,27 +15,13 @@ */ /// import { nanoid } from 'nanoid'; -import type { ReactAdapterElement } from 'Frontend/generated/flow/ReactAdapter.js'; -import { Flow as _Flow } from "Frontend/generated/jar-resources/Flow.js"; -import React, { - useCallback, - useEffect, - useReducer, - useRef, - useState, - type ReactNode -} from "react"; -import { - matchRoutes, - useBlocker, - useLocation, - useNavigate, - type NavigateOptions, useHref, -} from "react-router"; -import { createPortal } from "react-dom"; +import { Flow as _Flow } from 'Frontend/generated/jar-resources/Flow.js'; +import React, { useCallback, useEffect, useReducer, useRef, useState, type ReactNode } from 'react'; +import { matchRoutes, useBlocker, useLocation, useNavigate, type NavigateOptions, useHref } from 'react-router'; +import { createPortal } from 'react-dom'; const flow = new _Flow({ - imports: () => import("Frontend/generated/flow/generated-flow-imports.js") + imports: () => import('Frontend/generated/flow/generated-flow-imports.js') }); const router = { @@ -53,9 +39,10 @@ function getAnchorOrigin(anchor) { const protocol = anchor.protocol; const defaultHttp = protocol === 'http:' && port === '80'; const defaultHttps = protocol === 'https:' && port === '443'; - const host = (defaultHttp || defaultHttps) - ? anchor.hostname // does not include the port number (e.g. www.example.org) - : anchor.host; // does include the port number (e.g. www.example.org:80) + const host = + defaultHttp || defaultHttps + ? anchor.hostname // does not include the port number (e.g. www.example.org) + : anchor.host; // does include the port number (e.g. www.example.org:80) return `${protocol}//${host}`; } @@ -89,8 +76,8 @@ function extractPath(event: MouseEvent): void | string { let maybeAnchor = event.target; const path = event.composedPath ? event.composedPath() - // @ts-ignore - : (event.path || []); + : // @ts-ignore + event.path || []; // example to check: `for...of` loop here throws the "Not yet implemented" error for (let i = 0; i < path.length; i++) { @@ -153,43 +140,49 @@ function extractPath(event: MouseEvent): void | string { * @param pathname pathname of navigation * @param search search of navigation */ -function fireNavigated(pathname:string, search: string) { +function fireNavigated(pathname: string, search: string) { setTimeout(() => { - window.dispatchEvent(new CustomEvent('vaadin-navigated', { + window.dispatchEvent( + new CustomEvent('vaadin-navigated', { detail: { pathname, search } - })); - // @ts-ignore - delete window.Vaadin.Flow.navigation; - } - ) + }) + ); + // @ts-ignore + delete window.Vaadin.Flow.navigation; + }); } -function postpone() { -} +function postpone() {} const prevent = () => postpone; -type RouterContainer = Awaited>; +type RouterContainer = Awaited>; type PortalEntry = { - readonly children: ReactNode, - readonly domNode: ReactAdapterElement, + readonly children: ReactNode; + readonly domNode: HTMLElement; }; -type FlowPortalProps = React.PropsWithChildren>; +type FlowPortalProps = React.PropsWithChildren< + Readonly<{ + domNode: HTMLElement; + onRemove(): void; + }> +>; -function FlowPortal({children, domNode, onRemove}: FlowPortalProps) { +function FlowPortal({ children, domNode, onRemove }: FlowPortalProps) { useEffect(() => { - domNode.addEventListener('flow-portal-remove', (event) => { - event.preventDefault(); - onRemove(); - }, {once: true}); + domNode.addEventListener( + 'flow-portal-remove', + (event: Event) => { + event.preventDefault(); + onRemove(); + }, + { once: true } + ); }, []); return createPortal(children, domNode); @@ -198,49 +191,49 @@ function FlowPortal({children, domNode, onRemove}: FlowPortalProps) { const ADD_FLOW_PORTAL = 'ADD_FLOW_PORTAL'; type AddFlowPortalAction = Readonly<{ - type: typeof ADD_FLOW_PORTAL, + type: typeof ADD_FLOW_PORTAL; portal: React.ReactElement; }>; function addFlowPortal(portal: React.ReactElement): AddFlowPortalAction { return { type: ADD_FLOW_PORTAL, - portal, + portal }; } const REMOVE_FLOW_PORTAL = 'REMOVE_FLOW_PORTAL'; type RemoveFlowPortalAction = Readonly<{ - type: typeof REMOVE_FLOW_PORTAL, + type: typeof REMOVE_FLOW_PORTAL; key: string; }>; function removeFlowPortal(key: string): RemoveFlowPortalAction { return { type: REMOVE_FLOW_PORTAL, - key, + key }; } -function flowPortalsReducer(portals: readonly React.ReactElement[], action: AddFlowPortalAction | RemoveFlowPortalAction) { +function flowPortalsReducer( + portals: readonly React.ReactElement[], + action: AddFlowPortalAction | RemoveFlowPortalAction +) { switch (action.type) { case ADD_FLOW_PORTAL: - return [ - ...portals, - action.portal, - ]; + return [...portals, action.portal]; case REMOVE_FLOW_PORTAL: - return portals.filter(({key}) => key !== action.key); + return portals.filter(({ key }) => key !== action.key); default: return portals; } } -type NavigateOpts = { - to: string, - callback: boolean, - opts?: NavigateOptions +type NavigateOpts = { + to: string; + callback: boolean; + opts?: NavigateOptions; }; type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => void; @@ -250,7 +243,10 @@ type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => voi * with React Router API that has more consistent history updates. Uses internal * queue for processing navigate calls. */ -function useQueuedNavigate(waitReference: React.MutableRefObject | undefined>, navigated: React.MutableRefObject): NavigateFn { +function useQueuedNavigate( + waitReference: React.MutableRefObject | undefined>, + navigated: React.MutableRefObject +): NavigateFn { const navigate = useNavigate(); const navigateQueue = useRef([]).current; const [navigateQueueLength, setNavigateQueueLength] = useState(0); @@ -270,7 +266,7 @@ function useQueuedNavigate(waitReference: React.MutableRefObject | navigated.current = !navigateArgs.callback; navigate(navigateArgs.to, navigateArgs.opts); setNavigateQueueLength(navigateQueue.length); - } + }; blockingNavigate(); }, [navigate, setNavigateQueueLength]); @@ -278,22 +274,28 @@ function useQueuedNavigate(waitReference: React.MutableRefObject | queueMicrotask(dequeueNavigation); }, [dequeueNavigation]); - const enqueueNavigation = useCallback((to: string, callback: boolean, opts?: NavigateOptions) => { - navigateQueue.push({to: to, callback: callback, opts: opts}); - setNavigateQueueLength(navigateQueue.length); - if (navigateQueue.length === 1) { - // The first navigation can be started right after any pending sync - // jobs, which could add more navigations to the queue. + const enqueueNavigation = useCallback( + (to: string, callback: boolean, opts?: NavigateOptions) => { + navigateQueue.push({ to: to, callback: callback, opts: opts }); + setNavigateQueueLength(navigateQueue.length); + if (navigateQueue.length === 1) { + // The first navigation can be started right after any pending sync + // jobs, which could add more navigations to the queue. + dequeueNavigationAfterCurrentTask(); + } + }, + [setNavigateQueueLength, dequeueNavigationAfterCurrentTask] + ); + + useEffect( + () => () => { + // The Flow component has rendered, but history might not be + // updated yet, as React Router does it asynchronously. + // Use microtask callback for history consistency. dequeueNavigationAfterCurrentTask(); - } - }, [setNavigateQueueLength, dequeueNavigationAfterCurrentTask]); - - useEffect(() => () => { - // The Flow component has rendered, but history might not be - // updated yet, as React Router does it asynchronously. - // Use microtask callback for history consistency. - dequeueNavigationAfterCurrentTask(); - }, [navigateQueueLength, dequeueNavigationAfterCurrentTask]); + }, + [navigateQueueLength, dequeueNavigationAfterCurrentTask] + ); return enqueueNavigation; } @@ -302,7 +304,11 @@ function Flow() { const ref = useRef(null); const navigate = useNavigate(); const blocker = useBlocker(({ currentLocation, nextLocation }) => { - navigated.current = navigated.current || (nextLocation.pathname === currentLocation.pathname && nextLocation.search === currentLocation.search && nextLocation.hash === currentLocation.hash); + navigated.current = + navigated.current || + (nextLocation.pathname === currentLocation.pathname && + nextLocation.search === currentLocation.search && + nextLocation.hash === currentLocation.hash); return true; }); const location = useLocation(); @@ -317,65 +323,81 @@ function Flow() { // portalsReducer function is used as state outside the Flow component. const [portals, dispatchPortalAction] = useReducer(flowPortalsReducer, []); - const addPortalEventHandler = useCallback((event: CustomEvent) => { - event.preventDefault(); - - const key = nanoid(); - dispatchPortalAction( - addFlowPortal( - dispatchPortalAction(removeFlowPortal(key))}> - {event.detail.children} - - ) - ); - }, [dispatchPortalAction]); + const addPortalEventHandler = useCallback( + (event: CustomEvent) => { + event.preventDefault(); - const navigateEventHandler = useCallback((event: MouseEvent) => { - const path = extractPath(event); - if (!path) { - return; - } + const key = nanoid(); + dispatchPortalAction( + addFlowPortal( + dispatchPortalAction(removeFlowPortal(key))} + > + {event.detail.children} + + ) + ); + }, + [dispatchPortalAction] + ); + + const navigateEventHandler = useCallback( + (event: MouseEvent) => { + const path = extractPath(event); + if (!path) { + return; + } - if (event && event.preventDefault) { - event.preventDefault(); - } + if (event && event.preventDefault) { + event.preventDefault(); + } - navigated.current = false; - // When navigation is triggered by click on a link, fromAnchor is set to true - // in order to get a server round-trip even when navigating to the same URL again - fromAnchor.current = true; - navigate(path); - // Dispatch close event for overlay drawer on click navigation. - window.dispatchEvent(new CustomEvent('close-overlay-drawer')); - }, [navigate]); - - const vaadinRouterGoEventHandler = useCallback((event: CustomEvent) => { - const url = event.detail; - const path = normalizeURL(url); - if (!path) { - return; - } + navigated.current = false; + // When navigation is triggered by click on a link, fromAnchor is set to true + // in order to get a server round-trip even when navigating to the same URL again + fromAnchor.current = true; + navigate(path); + // Dispatch close event for overlay drawer on click navigation. + window.dispatchEvent(new CustomEvent('close-overlay-drawer')); + }, + [navigate] + ); + + const vaadinRouterGoEventHandler = useCallback( + (event: CustomEvent) => { + const url = event.detail; + const path = normalizeURL(url); + if (!path) { + return; + } - event.preventDefault(); - navigate(path); - }, [navigate]); + event.preventDefault(); + navigate(path); + }, + [navigate] + ); - const vaadinNavigateEventHandler = useCallback((event: CustomEvent<{state: unknown, url: string, replace?: boolean, callback: boolean}>) => { - // @ts-ignore - window.Vaadin.Flow.navigation = true; - const path = '/' + event.detail.url; - fromAnchor.current = false; - queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace }); - }, [navigate]); - - const redirect = useCallback((path: string) => { - return (() => { - navigate(path, {replace: true}); - }); - }, [navigate]); + const vaadinNavigateEventHandler = useCallback( + (event: CustomEvent<{ state: unknown; url: string; replace?: boolean; callback: boolean }>) => { + // @ts-ignore + window.Vaadin.Flow.navigation = true; + const path = '/' + event.detail.url; + fromAnchor.current = false; + queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace }); + }, + [navigate] + ); + + const redirect = useCallback( + (path: string) => { + return () => { + navigate(path, { replace: true }); + }; + }, + [navigate] + ); useEffect(() => { // @ts-ignore @@ -401,21 +423,29 @@ function Flow() { useEffect(() => { if (blocker.state === 'blocked') { - if(blockerHandled.current) { + if (blockerHandled.current) { // Blocker is handled and the new navigation // gets queued to be executed after the current handling ends. - const {pathname, state} = blocker.location; + const { pathname, state } = blocker.location; // Clear base name to not get /baseName/basename/path const pathNoBase = pathname.substring(basename.length); // path should always start with / else react-router will append to current url - queuedNavigate(pathNoBase.startsWith('/') ? pathNoBase : '/'+pathNoBase, true, { state: state, replace: true }); + queuedNavigate(pathNoBase.startsWith('/') ? pathNoBase : '/' + pathNoBase, true, { + state: state, + replace: true + }); return; } blockerHandled.current = true; let blockingPromise: any; - roundTrip.current = new Promise((resolve,reject) => blockingPromise = {resolve:resolve,reject:reject}); + roundTrip.current = new Promise( + (resolve, reject) => (blockingPromise = { resolve: resolve, reject: reject }) + ); // Release blocker handling after promise is fulfilled - roundTrip.current.then(() => blockerHandled.current = false, () => blockerHandled.current = false); + roundTrip.current.then( + () => (blockerHandled.current = false), + () => (blockerHandled.current = false) + ); // Proceed to the blocked location, unless the navigation originates from a click on a link. // In that case continue with function execution and perform a server round-trip @@ -425,15 +455,17 @@ function Flow() { return; } fromAnchor.current = false; - const {pathname, search} = blocker.location; + const { pathname, search } = blocker.location; const routes = ((window as any)?.Vaadin?.routesConfig || []) as any[]; let matched = matchRoutes(Array.from(routes), pathname); // Navigation between server routes // @ts-ignore - if (matched && matched.filter(path => path.route?.element?.type?.name === Flow.name).length != 0) { - containerRef.current?.onBeforeEnter?.call(containerRef?.current, - {pathname, search}, { + if (matched && matched.filter((path) => path.route?.element?.type?.name === Flow.name).length != 0) { + containerRef.current?.onBeforeEnter?.call( + containerRef?.current, + { pathname, search }, + { prevent() { blocker.reset(); blockingPromise.resolve(); @@ -444,32 +476,40 @@ function Flow() { blocker.proceed(); blockingPromise.resolve(); } - }, router); + }, + router + ); navigated.current = true; } else { // For covering the 'server -> client' use case - Promise.resolve(containerRef.current?.onBeforeLeave?.call(containerRef?.current, { - pathname, - search - }, {prevent}, router)) - .then((cmd: unknown) => { - if (cmd === postpone && containerRef.current) { - // postponed navigation: expose existing blocker to Flow - containerRef.current.serverConnected = (cancel) => { - if (cancel) { - blocker.reset(); - blockingPromise.resolve(); - } else { - blocker.proceed(); - blockingPromise.resolve(); - } + Promise.resolve( + containerRef.current?.onBeforeLeave?.call( + containerRef?.current, + { + pathname, + search + }, + { prevent }, + router + ) + ).then((cmd: unknown) => { + if (cmd === postpone && containerRef.current) { + // postponed navigation: expose existing blocker to Flow + containerRef.current.serverConnected = (cancel) => { + if (cancel) { + blocker.reset(); + blockingPromise.resolve(); + } else { + blocker.proceed(); + blockingPromise.resolve(); } - } else { - // permitted navigation: proceed with the blocker - blocker.proceed(); - blockingPromise.resolve(); - } - }); + }; + } else { + // permitted navigation: proceed with the blocker + blocker.proceed(); + blockingPromise.resolve(); + } + }); } } }, [blocker.state, blocker.location]); @@ -480,38 +520,49 @@ function Flow() { } if (navigated.current) { navigated.current = false; - fireNavigated(location.pathname,location.search); + fireNavigated(location.pathname, location.search); return; } - flow.serverSideRoutes[0].action({pathname: location.pathname, search: location.search}) + flow.serverSideRoutes[0] + .action({ pathname: location.pathname, search: location.search }) .then((container) => { const outlet = ref.current?.parentNode; if (outlet && outlet !== container.parentNode) { outlet.append(container); container.addEventListener('flow-portal-add', addPortalEventHandler as EventListener); - window.addEventListener('click', navigateEventHandler); - containerRef.current = container + window.addEventListener('click', navigateEventHandler); + containerRef.current = container; } - return container.onBeforeEnter?.call(container, {pathname: location.pathname, search: location.search}, {prevent, redirect, continue() { - fireNavigated(location.pathname,location.search);}}, router); + return container.onBeforeEnter?.call( + container, + { pathname: location.pathname, search: location.search }, + { + prevent, + redirect, + continue() { + fireNavigated(location.pathname, location.search); + } + }, + router + ); }) .then((result: unknown) => { - if (typeof result === "function") { + if (typeof result === 'function') { result(); } }); }, [location]); - return <> - - {portals} - ; + return ( + <> + + {portals} + + ); } Flow.type = 'FlowContainer'; // This is for copilot to recognize this -export const serverSideRoutes = [ - { path: '/*', element: }, -]; +export const serverSideRoutes = [{ path: '/*', element: }]; /** * Load the script for an exported WebComponent with the given tag @@ -525,17 +576,17 @@ export const loadComponentScript = (tag: String): Promise => { useEffect(() => { const script = document.createElement('script'); script.src = `/web-component/${tag}.js`; - script.onload = function() { + script.onload = function () { resolve(); }; - script.onerror = function(err) { + script.onerror = function (err) { reject(err); }; document.head.appendChild(script); return () => { document.head.removeChild(script); - } + }; }, []); }); }; @@ -552,16 +603,19 @@ interface Properties { * @param onload optional callback to be called for script onload * @param onerror optional callback for error loading the script */ -export const reactElement = (tag: string, props?: Properties, onload?: () => void, onerror?: (err:any) => void) => { - loadComponentScript(tag).then(() => onload?.(), (err) => { - if(onerror) { - onerror(err); - } else { - console.error(`Failed to load script for ${tag}.`, err); +export const reactElement = (tag: string, props?: Properties, onload?: () => void, onerror?: (err: any) => void) => { + loadComponentScript(tag).then( + () => onload?.(), + (err) => { + if (onerror) { + onerror(err); + } else { + console.error(`Failed to load script for ${tag}.`, err); + } } - }); + ); - if(props) { + if (props) { return React.createElement(tag, props); } return React.createElement(tag); From cdc8d92a8574f7d52ec002e18d3f0ec1e00b8da9 Mon Sep 17 00:00:00 2001 From: Vlad Rindevich Date: Tue, 14 Jan 2025 14:48:03 +0200 Subject: [PATCH 10/11] style(server, react): fix indenting --- .../server/frontend/ReactAdapter.template | 550 +++++----- .../com/vaadin/flow/server/frontend/Flow.tsx | 983 +++++++++--------- 2 files changed, 766 insertions(+), 767 deletions(-) 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 ecfa5ca622d..6c22e1ad74f 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 @@ -13,40 +13,31 @@ * License for the specific language governing permissions and limitations under * the License. */ -import {createRoot, Root} from "react-dom/client"; -import { - createElement, - type Dispatch, - type ReactElement, - type ReactNode, - useEffect, - useReducer, -} from "react"; +import { createRoot, Root } from 'react-dom/client'; +import { createElement, type Dispatch, type ReactElement, type ReactNode, useEffect, useReducer } from 'react'; type FlowStateKeyChangedAction = Readonly<{ - type: 'stateKeyChanged', - key: K, - value: V, + type: 'stateKeyChanged'; + key: K; + value: V; }>; type FlowStateReducerAction = FlowStateKeyChangedAction; function stateReducer>>(state: S, action: FlowStateReducerAction): S { - switch (action.type) { - case "stateKeyChanged": - const {value} = action; - return { - ...state, - key: value - } as S; - default: - return state; - } + switch (action.type) { + case 'stateKeyChanged': + const { value } = action; + return { + ...state, + key: value + } as S; + default: + return state; + } } -type DispatchEvent = T extends undefined - ? () => boolean - : (value: T) => boolean; +type DispatchEvent = T extends undefined ? () => boolean : (value: T) => boolean; const emptyAction: Dispatch = () => {}; @@ -55,63 +46,63 @@ const emptyAction: Dispatch = () => {}; * implementation. */ export type RenderHooks = { - /** - * A hook API for using stateful JS properties of the Web Component from - * the React `render()`. - * - * @typeParam T - Type of the state value - * - * @param key - Web Component property name, which is used for two-way - * value propagation from the server and back. - * @param initialValue - Fallback initial value (optional). Only applies if - * the Java component constructor does not invoke `setState`. - * @returns A tuple with two values: - * 1. The current state. - * 2. The `set` function for changing the state and triggering render - * @protected - */ - readonly useState: ReactAdapterElement["useState"] - - /** - * A hook helper to simplify dispatching a `CustomEvent` on the Web - * Component from React. - * - * @typeParam T - The type for `event.detail` value (optional). - * - * @param type - The `CustomEvent` type string. - * @param options - The settings for the `CustomEvent`. - * @returns The `dispatch` function. The function parameters change - * depending on the `T` generic type: - * - For `undefined` type (default), has no parameters. - * - For other types, has one parameter for the `event.detail` value of that type. - * @protected - */ - readonly useCustomEvent: ReactAdapterElement["useCustomEvent"] - - /** - * A hook helper to generate the content element with name attribute to bind - * the server-side Flow element for this component. - * - * This is used together with {@link ReactAdapterComponent::getContentElement} - * to have server-side component attach to the correct client element. - * - * Usage as follows: - * - * const content = hooks.useContent('content'); - * return <> - * {content} - * ; - * - * Note! Not adding the 'content' element into the dom will have the - * server throw a IllegalStateException for element with tag name not found. - * - * @param name - The name attribute of the element - */ - readonly useContent: ReactAdapterElement["useContent"] + /** + * A hook API for using stateful JS properties of the Web Component from + * the React `render()`. + * + * @typeParam T - Type of the state value + * + * @param key - Web Component property name, which is used for two-way + * value propagation from the server and back. + * @param initialValue - Fallback initial value (optional). Only applies if + * the Java component constructor does not invoke `setState`. + * @returns A tuple with two values: + * 1. The current state. + * 2. The `set` function for changing the state and triggering render + * @protected + */ + readonly useState: ReactAdapterElement['useState']; + + /** + * A hook helper to simplify dispatching a `CustomEvent` on the Web + * Component from React. + * + * @typeParam T - The type for `event.detail` value (optional). + * + * @param type - The `CustomEvent` type string. + * @param options - The settings for the `CustomEvent`. + * @returns The `dispatch` function. The function parameters change + * depending on the `T` generic type: + * - For `undefined` type (default), has no parameters. + * - For other types, has one parameter for the `event.detail` value of that type. + * @protected + */ + readonly useCustomEvent: ReactAdapterElement['useCustomEvent']; + + /** + * A hook helper to generate the content element with name attribute to bind + * the server-side Flow element for this component. + * + * This is used together with {@link ReactAdapterComponent::getContentElement} + * to have server-side component attach to the correct client element. + * + * Usage as follows: + * + * const content = hooks.useContent('content'); + * return <> + * {content} + * ; + * + * Note! Not adding the 'content' element into the dom will have the + * server throw a IllegalStateException for element with tag name not found. + * + * @param name - The name attribute of the element + */ + readonly useContent: ReactAdapterElement['useContent']; }; interface ReadyCallbackFunction { - (): void; + (): void; } /** @@ -120,212 +111,219 @@ interface ReadyCallbackFunction { * `ReactAdapterComponent` Flow Java class. */ export abstract class ReactAdapterElement extends HTMLElement { - #root: Root | undefined = undefined; - #rootRendered: boolean = false; - #rendering: ReactNode | undefined = undefined; - - #state: Record = Object.create(null); - #stateSetters = new Map>(); - #customEvents = new Map>(); - #dispatchFlowState: Dispatch = emptyAction; - - #readyCallback = new Map(); - - readonly #renderHooks: RenderHooks; - - readonly #Wrapper: () => ReactElement | null; - - #unmounting?: Promise; - - constructor() { - super(); - this.#renderHooks = { - useState: this.useState.bind(this), - useCustomEvent: this.useCustomEvent.bind(this), - useContent: this.useContent.bind(this) - }; - this.#Wrapper = this.#renderWrapper.bind(this); - this.#markAsUsed(); - } - - public async connectedCallback() { - this.#rendering = createElement(this.#Wrapper); - const createNewRoot = this.dispatchEvent(new CustomEvent('flow-portal-add', { - bubbles: true, - cancelable: true, - composed: true, - detail: { - children: this.#rendering, - domNode: this, - } - })); - - if (!createNewRoot || this.#root) { - return; + #root: Root | undefined = undefined; + #rootRendered: boolean = false; + #rendering: ReactNode | undefined = undefined; + + #state: Record = Object.create(null); + #stateSetters = new Map>(); + #customEvents = new Map>(); + #dispatchFlowState: Dispatch = emptyAction; + + #readyCallback = new Map(); + + readonly #renderHooks: RenderHooks; + + readonly #Wrapper: () => ReactElement | null; + + #unmounting?: Promise; + + constructor() { + super(); + this.#renderHooks = { + useState: this.useState.bind(this), + useCustomEvent: this.useCustomEvent.bind(this), + useContent: this.useContent.bind(this) + }; + this.#Wrapper = this.#renderWrapper.bind(this); + this.#markAsUsed(); } - await this.#unmounting; - - this.#root = createRoot(this); - this.#maybeRenderRoot(); - this.#root.render(this.#rendering); - } - - /** - * 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); - } - - public async disconnectedCallback() { - if (!this.#root) { - this.dispatchEvent(new CustomEvent('flow-portal-remove', { - bubbles: true, - cancelable: true, - composed: true, - detail: { - children: this.#rendering, - domNode: this, + public async connectedCallback() { + this.#rendering = createElement(this.#Wrapper); + const createNewRoot = this.dispatchEvent( + new CustomEvent('flow-portal-add', { + bubbles: true, + cancelable: true, + composed: true, + detail: { + children: this.#rendering, + domNode: this + } + }) + ); + + if (!createNewRoot || this.#root) { + return; } - })); - } else { - this.#unmounting = Promise.resolve(); - await this.#unmounting; - this.#root.unmount(); - this.#root = undefined; + + await this.#unmounting; + + this.#root = createRoot(this); + this.#maybeRenderRoot(); + this.#root.render(this.#rendering); } - this.#rootRendered = false; - this.#rendering = undefined; - } - - /** - * A hook API for using stateful JS properties of the Web Component from - * the React `render()`. - * - * @typeParam T - Type of the state value - * - * @param key - Web Component property name, which is used for two-way - * value propagation from the server and back. - * @param initialValue - Fallback initial value (optional). Only applies if - * the Java component constructor does not invoke `setState`. - * @returns A tuple with two values: - * 1. The current state. - * 2. The `set` function for changing the state and triggering render - * @protected - */ - protected useState(key: string, initialValue?: T): [value: T, setValue: Dispatch] { - if (this.#stateSetters.has(key)) { - return [this.#state[key] as T, this.#stateSetters.get(key)!]; + + /** + * 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); + } + + public async disconnectedCallback() { + if (!this.#root) { + this.dispatchEvent( + new CustomEvent('flow-portal-remove', { + bubbles: true, + cancelable: true, + composed: true, + detail: { + children: this.#rendering, + domNode: this + } + }) + ); + } else { + this.#unmounting = Promise.resolve(); + await this.#unmounting; + this.#root.unmount(); + this.#root = undefined; + } + this.#rootRendered = false; + this.#rendering = undefined; } - const value = ((this as Record)[key] as T) ?? initialValue!; - this.#state[key] = value; - Object.defineProperty(this, key, { - enumerable: true, - get(): T { - return this.#state[key]; - }, - set(nextValue: T) { - this.#state[key] = nextValue; - this.#dispatchFlowState({type: 'stateKeyChanged', key, value}); - } - }); - - const dispatchChangedEvent = this.useCustomEvent<{value: T}>(`${key}-changed`, {detail: {value}}); - const setValue = (value: T) => { - this.#state[key] = value; - dispatchChangedEvent({value}); - this.#dispatchFlowState({type: 'stateKeyChanged', key, value}); - }; - this.#stateSetters.set(key, setValue as Dispatch); - return [value, setValue]; - } - - /** - * A hook helper to simplify dispatching a `CustomEvent` on the Web - * Component from React. - * - * @typeParam T - The type for `event.detail` value (optional). - * - * @param type - The `CustomEvent` type string. - * @param options - The settings for the `CustomEvent`. - * @returns The `dispatch` function. The function parameters change - * depending on the `T` generic type: - * - For `undefined` type (default), has no parameters. - * - For other types, has one parameter for the `event.detail` value of that type. - * @protected - */ - protected useCustomEvent(type: string, options: CustomEventInit = {}): DispatchEvent { - if (!this.#customEvents.has(type)) { - const dispatch = ((detail?: T) => { - const eventInitDict = detail === undefined ? options : { - ...options, - detail + /** + * A hook API for using stateful JS properties of the Web Component from + * the React `render()`. + * + * @typeParam T - Type of the state value + * + * @param key - Web Component property name, which is used for two-way + * value propagation from the server and back. + * @param initialValue - Fallback initial value (optional). Only applies if + * the Java component constructor does not invoke `setState`. + * @returns A tuple with two values: + * 1. The current state. + * 2. The `set` function for changing the state and triggering render + * @protected + */ + protected useState(key: string, initialValue?: T): [value: T, setValue: Dispatch] { + if (this.#stateSetters.has(key)) { + return [this.#state[key] as T, this.#stateSetters.get(key)!]; + } + + const value = ((this as Record)[key] as T) ?? initialValue!; + this.#state[key] = value; + Object.defineProperty(this, key, { + enumerable: true, + get(): T { + return this.#state[key]; + }, + set(nextValue: T) { + this.#state[key] = nextValue; + this.#dispatchFlowState({ type: 'stateKeyChanged', key, value }); + } + }); + + const dispatchChangedEvent = this.useCustomEvent<{ value: T }>(`${key}-changed`, { detail: { value } }); + const setValue = (value: T) => { + this.#state[key] = value; + dispatchChangedEvent({ value }); + this.#dispatchFlowState({ type: 'stateKeyChanged', key, value }); }; - const event = new CustomEvent(type, eventInitDict); - return this.dispatchEvent(event); - }) as DispatchEvent; - this.#customEvents.set(type, dispatch as DispatchEvent); - return dispatch; + this.#stateSetters.set(key, setValue as Dispatch); + return [value, setValue]; } - return this.#customEvents.get(type)! as DispatchEvent; - } - - /** - * The Web Component render function. To be implemented by users with React. - * - * @param hooks - the adapter APIs exposed for the implementation. - * @protected - */ - protected abstract render(hooks: RenderHooks): ReactElement | null; - - /** - * Prepare content container for Flow to bind server Element to. - * - * @param name container name attribute matching server name attribute - * @protected - */ - protected useContent(name: string): ReactElement | null { - useEffect(() => { - this.#readyCallback.get(name)?.(); - }, []); - return createElement('flow-content-container', {name, style: {display: 'contents'}}); - } - - #maybeRenderRoot() { - if (this.#rootRendered || !this.#root) { - return; + + /** + * A hook helper to simplify dispatching a `CustomEvent` on the Web + * Component from React. + * + * @typeParam T - The type for `event.detail` value (optional). + * + * @param type - The `CustomEvent` type string. + * @param options - The settings for the `CustomEvent`. + * @returns The `dispatch` function. The function parameters change + * depending on the `T` generic type: + * - For `undefined` type (default), has no parameters. + * - For other types, has one parameter for the `event.detail` value of that type. + * @protected + */ + protected useCustomEvent(type: string, options: CustomEventInit = {}): DispatchEvent { + if (!this.#customEvents.has(type)) { + const dispatch = ((detail?: T) => { + const eventInitDict = + detail === undefined + ? options + : { + ...options, + detail + }; + const event = new CustomEvent(type, eventInitDict); + return this.dispatchEvent(event); + }) as DispatchEvent; + this.#customEvents.set(type, dispatch as DispatchEvent); + return dispatch; + } + return this.#customEvents.get(type)! as DispatchEvent; } - this.#root.render(createElement(this.#Wrapper)); - this.#rootRendered = true; - } - - #renderWrapper(): ReactElement | null { - const [state, dispatchFlowState] = useReducer(stateReducer, this.#state); - this.#state = state; - this.#dispatchFlowState = dispatchFlowState; - return this.render(this.#renderHooks); - } - - #markAsUsed() : void { - // @ts-ignore - let vaadinObject = window.Vaadin || {}; - // @ts-ignore - if (vaadinObject.developmentMode) { - vaadinObject.registrations = vaadinObject.registrations || []; - vaadinObject.registrations.push({ - is: 'ReactAdapterElement', - version: '{{VAADIN_VERSION}}' - }); + /** + * The Web Component render function. To be implemented by users with React. + * + * @param hooks - the adapter APIs exposed for the implementation. + * @protected + */ + protected abstract render(hooks: RenderHooks): ReactElement | null; + + /** + * Prepare content container for Flow to bind server Element to. + * + * @param name container name attribute matching server name attribute + * @protected + */ + protected useContent(name: string): ReactElement | null { + useEffect(() => { + this.#readyCallback.get(name)?.(); + }, []); + return createElement('flow-content-container', { name, style: { display: 'contents' } }); + } + + #maybeRenderRoot() { + if (this.#rootRendered || !this.#root) { + return; + } + + this.#root.render(createElement(this.#Wrapper)); + this.#rootRendered = true; + } + + #renderWrapper(): ReactElement | null { + const [state, dispatchFlowState] = useReducer(stateReducer, this.#state); + this.#state = state; + this.#dispatchFlowState = dispatchFlowState; + return this.render(this.#renderHooks); + } + + #markAsUsed(): void { + // @ts-ignore + let vaadinObject = window.Vaadin || {}; + // @ts-ignore + if (vaadinObject.developmentMode) { + vaadinObject.registrations = vaadinObject.registrations || []; + vaadinObject.registrations.push({ + is: 'ReactAdapterElement', + version: '{{VAADIN_VERSION}}' + }); + } } - } } diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx index c3c53290a2c..a96949bfd32 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx @@ -14,124 +14,125 @@ * the License. */ /// +import { nanoid } from 'nanoid'; import { Flow as _Flow } from 'Frontend/generated/jar-resources/Flow.js'; import React, { useCallback, useEffect, useReducer, useRef, useState, type ReactNode } from 'react'; import { matchRoutes, useBlocker, useLocation, useNavigate, type NavigateOptions, useHref } from 'react-router'; import { createPortal } from 'react-dom'; const flow = new _Flow({ - imports: () => import('Frontend/generated/flow/generated-flow-imports.js') + imports: () => import('Frontend/generated/flow/generated-flow-imports.js') }); const router = { - render() { - return Promise.resolve(); - } + render() { + return Promise.resolve(); + } }; // ClickHandler for vaadin-router-go event is copied from vaadin/router click.js // @ts-ignore function getAnchorOrigin(anchor) { - // IE11: on HTTP and HTTPS the default port is not included into - // window.location.origin, so won't include it here either. - const port = anchor.port; - const protocol = anchor.protocol; - const defaultHttp = protocol === 'http:' && port === '80'; - const defaultHttps = protocol === 'https:' && port === '443'; - const host = - defaultHttp || defaultHttps - ? anchor.hostname // does not include the port number (e.g. www.example.org) - : anchor.host; // does include the port number (e.g. www.example.org:80) - return `${protocol}//${host}`; + // IE11: on HTTP and HTTPS the default port is not included into + // window.location.origin, so won't include it here either. + const port = anchor.port; + const protocol = anchor.protocol; + const defaultHttp = protocol === 'http:' && port === '80'; + const defaultHttps = protocol === 'https:' && port === '443'; + const host = + defaultHttp || defaultHttps + ? anchor.hostname // does not include the port number (e.g. www.example.org) + : anchor.host; // does include the port number (e.g. www.example.org:80) + return `${protocol}//${host}`; } function normalizeURL(url: URL): void | string { - // ignore click if baseURI does not match the document (external) - if (!url.href.startsWith(document.baseURI)) { - return; - } + // ignore click if baseURI does not match the document (external) + if (!url.href.startsWith(document.baseURI)) { + return; + } - // Normalize path against baseURI - return '/' + url.href.slice(document.baseURI.length); + // Normalize path against baseURI + return '/' + url.href.slice(document.baseURI.length); } function extractPath(event: MouseEvent): void | string { - // ignore the click if the default action is prevented - if (event.defaultPrevented) { - return; - } - - // ignore the click if not with the primary mouse button - if (event.button !== 0) { - return; - } - - // ignore the click if a modifier key is pressed - if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { - return; - } - - // find the element that the click is at (or within) - let maybeAnchor = event.target; - const path = event.composedPath - ? event.composedPath() - : // @ts-ignore - event.path || []; - - // example to check: `for...of` loop here throws the "Not yet implemented" error - for (let i = 0; i < path.length; i++) { - const target = path[i]; - if (target.nodeName && target.nodeName.toLowerCase() === 'a') { - maybeAnchor = target; - break; + // ignore the click if the default action is prevented + if (event.defaultPrevented) { + return; + } + + // ignore the click if not with the primary mouse button + if (event.button !== 0) { + return; + } + + // ignore the click if a modifier key is pressed + if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { + return; + } + + // find the element that the click is at (or within) + let maybeAnchor = event.target; + const path = event.composedPath + ? event.composedPath() + : // @ts-ignore + event.path || []; + + // example to check: `for...of` loop here throws the "Not yet implemented" error + for (let i = 0; i < path.length; i++) { + const target = path[i]; + if (target.nodeName && target.nodeName.toLowerCase() === 'a') { + maybeAnchor = target; + break; + } } - } - // @ts-ignore - while (maybeAnchor && maybeAnchor.nodeName.toLowerCase() !== 'a') { // @ts-ignore - maybeAnchor = maybeAnchor.parentNode; - } - - // ignore the click if not at an element - // @ts-ignore - if (!maybeAnchor || maybeAnchor.nodeName.toLowerCase() !== 'a') { - return; - } - - const anchor = maybeAnchor as HTMLAnchorElement; - - // ignore the click if the element has a non-default target - if (anchor.target && anchor.target.toLowerCase() !== '_self') { - return; - } - - // ignore the click if the element has the 'download' attribute - if (anchor.hasAttribute('download')) { - return; - } - - // ignore the click if the element has the 'router-ignore' attribute - if (anchor.hasAttribute('router-ignore')) { - return; - } - - // ignore the click if the target URL is a fragment on the current page - if (anchor.pathname === window.location.pathname && anchor.hash !== '') { + while (maybeAnchor && maybeAnchor.nodeName.toLowerCase() !== 'a') { + // @ts-ignore + maybeAnchor = maybeAnchor.parentNode; + } + + // ignore the click if not at an element // @ts-ignore - window.location.hash = anchor.hash; - return; - } - - // ignore the click if the target is external to the app - // In IE11 HTMLAnchorElement does not have the `origin` property - // @ts-ignore - const origin = anchor.origin || getAnchorOrigin(anchor); - if (origin !== window.location.origin) { - return; - } - - return normalizeURL(new URL(anchor.href, anchor.baseURI)); + if (!maybeAnchor || maybeAnchor.nodeName.toLowerCase() !== 'a') { + return; + } + + const anchor = maybeAnchor as HTMLAnchorElement; + + // ignore the click if the element has a non-default target + if (anchor.target && anchor.target.toLowerCase() !== '_self') { + return; + } + + // ignore the click if the element has the 'download' attribute + if (anchor.hasAttribute('download')) { + return; + } + + // ignore the click if the element has the 'router-ignore' attribute + if (anchor.hasAttribute('router-ignore')) { + return; + } + + // ignore the click if the target URL is a fragment on the current page + if (anchor.pathname === window.location.pathname && anchor.hash !== '') { + // @ts-ignore + window.location.hash = anchor.hash; + return; + } + + // ignore the click if the target is external to the app + // In IE11 HTMLAnchorElement does not have the `origin` property + // @ts-ignore + const origin = anchor.origin || getAnchorOrigin(anchor); + if (origin !== window.location.origin) { + return; + } + + return normalizeURL(new URL(anchor.href, anchor.baseURI)); } /** @@ -140,18 +141,18 @@ function extractPath(event: MouseEvent): void | string { * @param search search of navigation */ function fireNavigated(pathname: string, search: string) { - queueMicrotask(() => { - window.dispatchEvent( - new CustomEvent('vaadin-navigated', { - detail: { - pathname, - search - } - }) - ); - // @ts-ignore - delete window.Vaadin.Flow.navigation; - }); + setTimeout(() => { + window.dispatchEvent( + new CustomEvent('vaadin-navigated', { + detail: { + pathname, + search + } + }) + ); + // @ts-ignore + delete window.Vaadin.Flow.navigation; + }); } function postpone() {} @@ -161,78 +162,78 @@ const prevent = () => postpone; type RouterContainer = Awaited>; type PortalEntry = { - readonly children: ReactNode; - readonly domNode: HTMLElement; + readonly children: ReactNode; + readonly domNode: HTMLElement; }; type FlowPortalProps = React.PropsWithChildren< - Readonly<{ - domNode: HTMLElement; - onRemove(): void; - }> + Readonly<{ + domNode: HTMLElement; + onRemove(): void; + }> >; function FlowPortal({ children, domNode, onRemove }: FlowPortalProps) { - useEffect(() => { - domNode.addEventListener( - 'flow-portal-remove', - (event: Event) => { - event.preventDefault(); - onRemove(); - }, - { once: true } - ); - }, []); + useEffect(() => { + domNode.addEventListener( + 'flow-portal-remove', + (event: Event) => { + event.preventDefault(); + onRemove(); + }, + { once: true } + ); + }, []); - return createPortal(children, domNode); + return createPortal(children, domNode); } const ADD_FLOW_PORTAL = 'ADD_FLOW_PORTAL'; type AddFlowPortalAction = Readonly<{ - type: typeof ADD_FLOW_PORTAL; - portal: React.ReactElement; + type: typeof ADD_FLOW_PORTAL; + portal: React.ReactElement; }>; function addFlowPortal(portal: React.ReactElement): AddFlowPortalAction { - return { - type: ADD_FLOW_PORTAL, - portal - }; + return { + type: ADD_FLOW_PORTAL, + portal + }; } const REMOVE_FLOW_PORTAL = 'REMOVE_FLOW_PORTAL'; type RemoveFlowPortalAction = Readonly<{ - type: typeof REMOVE_FLOW_PORTAL; - key: string; + type: typeof REMOVE_FLOW_PORTAL; + key: string; }>; function removeFlowPortal(key: string): RemoveFlowPortalAction { - return { - type: REMOVE_FLOW_PORTAL, - key - }; + return { + type: REMOVE_FLOW_PORTAL, + key + }; } function flowPortalsReducer( - portals: readonly React.ReactElement[], - action: AddFlowPortalAction | RemoveFlowPortalAction + portals: readonly React.ReactElement[], + action: AddFlowPortalAction | RemoveFlowPortalAction ) { - switch (action.type) { - case ADD_FLOW_PORTAL: - return [...portals, action.portal]; - case REMOVE_FLOW_PORTAL: - return portals.filter(({ key }) => key !== action.key); - default: - return portals; - } + switch (action.type) { + case ADD_FLOW_PORTAL: + return [...portals, action.portal]; + case REMOVE_FLOW_PORTAL: + return portals.filter(({ key }) => key !== action.key); + default: + return portals; + } } type NavigateOpts = { - to: string; - callback: boolean; - opts?: NavigateOptions; + to: string; + callback: boolean; + opts?: NavigateOptions; }; type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => void; @@ -243,321 +244,321 @@ type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => voi * queue for processing navigate calls. */ function useQueuedNavigate( - waitReference: React.MutableRefObject | undefined>, - navigated: React.MutableRefObject + waitReference: React.MutableRefObject | undefined>, + navigated: React.MutableRefObject ): NavigateFn { - const navigate = useNavigate(); - const navigateQueue = useRef([]).current; - const [navigateQueueLength, setNavigateQueueLength] = useState(0); - - const dequeueNavigation = useCallback(() => { - const navigateArgs = navigateQueue.shift(); - if (navigateArgs === undefined) { - // Empty queue, do nothing. - return; - } + const navigate = useNavigate(); + const navigateQueue = useRef([]).current; + const [navigateQueueLength, setNavigateQueueLength] = useState(0); + + const dequeueNavigation = useCallback(() => { + const navigateArgs = navigateQueue.shift(); + if (navigateArgs === undefined) { + // Empty queue, do nothing. + return; + } - const blockingNavigate = async () => { - if (waitReference.current) { - await waitReference.current; - waitReference.current = undefined; - } - navigated.current = !navigateArgs.callback; - navigate(navigateArgs.to, navigateArgs.opts); - setNavigateQueueLength(navigateQueue.length); - }; - blockingNavigate(); - }, [navigate, setNavigateQueueLength]); - - const dequeueNavigationAfterCurrentTask = useCallback(() => { - queueMicrotask(dequeueNavigation); - }, [dequeueNavigation]); - - const enqueueNavigation = useCallback( - (to: string, callback: boolean, opts?: NavigateOptions) => { - navigateQueue.push({ to: to, callback: callback, opts: opts }); - setNavigateQueueLength(navigateQueue.length); - if (navigateQueue.length === 1) { - // The first navigation can be started right after any pending sync - // jobs, which could add more navigations to the queue. - dequeueNavigationAfterCurrentTask(); - } - }, - [setNavigateQueueLength, dequeueNavigationAfterCurrentTask] - ); - - useEffect( - () => () => { - // The Flow component has rendered, but history might not be - // updated yet, as React Router does it asynchronously. - // Use microtask callback for history consistency. - dequeueNavigationAfterCurrentTask(); - }, - [navigateQueueLength, dequeueNavigationAfterCurrentTask] - ); - - return enqueueNavigation; + const blockingNavigate = async () => { + if (waitReference.current) { + await waitReference.current; + waitReference.current = undefined; + } + navigated.current = !navigateArgs.callback; + navigate(navigateArgs.to, navigateArgs.opts); + setNavigateQueueLength(navigateQueue.length); + }; + blockingNavigate(); + }, [navigate, setNavigateQueueLength]); + + const dequeueNavigationAfterCurrentTask = useCallback(() => { + queueMicrotask(dequeueNavigation); + }, [dequeueNavigation]); + + const enqueueNavigation = useCallback( + (to: string, callback: boolean, opts?: NavigateOptions) => { + navigateQueue.push({ to: to, callback: callback, opts: opts }); + setNavigateQueueLength(navigateQueue.length); + if (navigateQueue.length === 1) { + // The first navigation can be started right after any pending sync + // jobs, which could add more navigations to the queue. + dequeueNavigationAfterCurrentTask(); + } + }, + [setNavigateQueueLength, dequeueNavigationAfterCurrentTask] + ); + + useEffect( + () => () => { + // The Flow component has rendered, but history might not be + // updated yet, as React Router does it asynchronously. + // Use microtask callback for history consistency. + dequeueNavigationAfterCurrentTask(); + }, + [navigateQueueLength, dequeueNavigationAfterCurrentTask] + ); + + return enqueueNavigation; } function Flow() { - const ref = useRef(null); - const navigate = useNavigate(); - const blocker = useBlocker(({ currentLocation, nextLocation }) => { - navigated.current = - navigated.current || - (nextLocation.pathname === currentLocation.pathname && - nextLocation.search === currentLocation.search && - nextLocation.hash === currentLocation.hash); - return true; - }); - const location = useLocation(); - const navigated = useRef(false); - const blockerHandled = useRef(false); - const fromAnchor = useRef(false); - const containerRef = useRef(undefined); - const roundTrip = useRef | undefined>(undefined); - const queuedNavigate = useQueuedNavigate(roundTrip, navigated); - const basename = useHref('/'); - - // portalsReducer function is used as state outside the Flow component. - const [portals, dispatchPortalAction] = useReducer(flowPortalsReducer, []); - - const addPortalEventHandler = useCallback( - (event: CustomEvent) => { - event.preventDefault(); - - const key = Math.random().toString(36).slice(2); - dispatchPortalAction( - addFlowPortal( - dispatchPortalAction(removeFlowPortal(key))} - > - {event.detail.children} - - ) - ); - }, - [dispatchPortalAction] - ); - - const navigateEventHandler = useCallback( - (event: MouseEvent) => { - const path = extractPath(event); - if (!path) { - return; - } - - if (event && event.preventDefault) { - event.preventDefault(); - } - - navigated.current = false; - // When navigation is triggered by click on a link, fromAnchor is set to true - // in order to get a server round-trip even when navigating to the same URL again - fromAnchor.current = true; - navigate(path); - // Dispatch close event for overlay drawer on click navigation. - window.dispatchEvent(new CustomEvent('close-overlay-drawer')); - }, - [navigate] - ); - - const vaadinRouterGoEventHandler = useCallback( - (event: CustomEvent) => { - const url = event.detail; - const path = normalizeURL(url); - if (!path) { - return; - } - - event.preventDefault(); - navigate(path); - }, - [navigate] - ); - - const vaadinNavigateEventHandler = useCallback( - (event: CustomEvent<{ state: unknown; url: string; replace?: boolean; callback: boolean }>) => { - // @ts-ignore - window.Vaadin.Flow.navigation = true; - const path = '/' + event.detail.url; - fromAnchor.current = false; - queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace }); - }, - [navigate] - ); - - const redirect = useCallback( - (path: string) => { - return () => { - navigate(path, { replace: true }); - }; - }, - [navigate] - ); - - useEffect(() => { - // @ts-ignore - window.addEventListener('vaadin-router-go', vaadinRouterGoEventHandler); - // @ts-ignore - window.addEventListener('vaadin-navigate', vaadinNavigateEventHandler); + const ref = useRef(null); + const navigate = useNavigate(); + const blocker = useBlocker(({ currentLocation, nextLocation }) => { + navigated.current = + navigated.current || + (nextLocation.pathname === currentLocation.pathname && + nextLocation.search === currentLocation.search && + nextLocation.hash === currentLocation.hash); + return true; + }); + const location = useLocation(); + const navigated = useRef(false); + const blockerHandled = useRef(false); + const fromAnchor = useRef(false); + const containerRef = useRef(undefined); + const roundTrip = useRef | undefined>(undefined); + const queuedNavigate = useQueuedNavigate(roundTrip, navigated); + const basename = useHref('/'); + + // portalsReducer function is used as state outside the Flow component. + const [portals, dispatchPortalAction] = useReducer(flowPortalsReducer, []); + + const addPortalEventHandler = useCallback( + (event: CustomEvent) => { + event.preventDefault(); + + const key = nanoid(); + dispatchPortalAction( + addFlowPortal( + dispatchPortalAction(removeFlowPortal(key))} + > + {event.detail.children} + + ) + ); + }, + [dispatchPortalAction] + ); - return () => { - // @ts-ignore - window.removeEventListener('vaadin-router-go', vaadinRouterGoEventHandler); - // @ts-ignore - window.removeEventListener('vaadin-navigate', vaadinNavigateEventHandler); - }; - }, [vaadinRouterGoEventHandler, vaadinNavigateEventHandler]); + const navigateEventHandler = useCallback( + (event: MouseEvent) => { + const path = extractPath(event); + if (!path) { + return; + } - useEffect(() => { - return () => { - containerRef.current?.parentNode?.removeChild(containerRef.current); - containerRef.current?.removeEventListener('flow-portal-add', addPortalEventHandler as EventListener); - containerRef.current = undefined; - }; - }, []); - - useEffect(() => { - if (blocker.state === 'blocked') { - if (blockerHandled.current) { - // Blocker is handled and the new navigation - // gets queued to be executed after the current handling ends. - const { pathname, state } = blocker.location; - // Clear base name to not get /baseName/basename/path - const pathNoBase = pathname.substring(basename.length); - // path should always start with / else react-router will append to current url - queuedNavigate(pathNoBase.startsWith('/') ? pathNoBase : '/' + pathNoBase, true, { - state: state, - replace: true - }); - return; - } - blockerHandled.current = true; - let blockingPromise: any; - roundTrip.current = new Promise( - (resolve, reject) => (blockingPromise = { resolve: resolve, reject: reject }) - ); - // Release blocker handling after promise is fulfilled - roundTrip.current.then( - () => (blockerHandled.current = false), - () => (blockerHandled.current = false) - ); - - // Proceed to the blocked location, unless the navigation originates from a click on a link. - // In that case continue with function execution and perform a server round-trip - if (navigated.current && !fromAnchor.current) { - blocker.proceed(); - blockingPromise.resolve(); - return; - } - fromAnchor.current = false; - const { pathname, search } = blocker.location; - const routes = ((window as any)?.Vaadin?.routesConfig || []) as any[]; - let matched = matchRoutes(Array.from(routes), pathname); - - // Navigation between server routes - // @ts-ignore - if (matched && matched.filter((path) => path.route?.element?.type?.name === Flow.name).length != 0) { - containerRef.current?.onBeforeEnter?.call( - containerRef?.current, - { pathname, search }, - { - prevent() { - blocker.reset(); - blockingPromise.resolve(); - navigated.current = false; - }, - redirect, - continue() { - blocker.proceed(); - blockingPromise.resolve(); + if (event && event.preventDefault) { + event.preventDefault(); } - }, - router - ); - navigated.current = true; - } else { - // For covering the 'server -> client' use case - Promise.resolve( - containerRef.current?.onBeforeLeave?.call( - containerRef?.current, - { - pathname, - search - }, - { prevent }, - router - ) - ).then((cmd: unknown) => { - if (cmd === postpone && containerRef.current) { - // postponed navigation: expose existing blocker to Flow - containerRef.current.serverConnected = (cancel) => { - if (cancel) { - blocker.reset(); - blockingPromise.resolve(); - } else { + + navigated.current = false; + // When navigation is triggered by click on a link, fromAnchor is set to true + // in order to get a server round-trip even when navigating to the same URL again + fromAnchor.current = true; + navigate(path); + // Dispatch close event for overlay drawer on click navigation. + window.dispatchEvent(new CustomEvent('close-overlay-drawer')); + }, + [navigate] + ); + + const vaadinRouterGoEventHandler = useCallback( + (event: CustomEvent) => { + const url = event.detail; + const path = normalizeURL(url); + if (!path) { + return; + } + + event.preventDefault(); + navigate(path); + }, + [navigate] + ); + + const vaadinNavigateEventHandler = useCallback( + (event: CustomEvent<{ state: unknown; url: string; replace?: boolean; callback: boolean }>) => { + // @ts-ignore + window.Vaadin.Flow.navigation = true; + const path = '/' + event.detail.url; + fromAnchor.current = false; + queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace }); + }, + [navigate] + ); + + const redirect = useCallback( + (path: string) => { + return () => { + navigate(path, { replace: true }); + }; + }, + [navigate] + ); + + useEffect(() => { + // @ts-ignore + window.addEventListener('vaadin-router-go', vaadinRouterGoEventHandler); + // @ts-ignore + window.addEventListener('vaadin-navigate', vaadinNavigateEventHandler); + + return () => { + // @ts-ignore + window.removeEventListener('vaadin-router-go', vaadinRouterGoEventHandler); + // @ts-ignore + window.removeEventListener('vaadin-navigate', vaadinNavigateEventHandler); + }; + }, [vaadinRouterGoEventHandler, vaadinNavigateEventHandler]); + + useEffect(() => { + return () => { + containerRef.current?.parentNode?.removeChild(containerRef.current); + containerRef.current?.removeEventListener('flow-portal-add', addPortalEventHandler as EventListener); + containerRef.current = undefined; + }; + }, []); + + useEffect(() => { + if (blocker.state === 'blocked') { + if (blockerHandled.current) { + // Blocker is handled and the new navigation + // gets queued to be executed after the current handling ends. + const { pathname, state } = blocker.location; + // Clear base name to not get /baseName/basename/path + const pathNoBase = pathname.substring(basename.length); + // path should always start with / else react-router will append to current url + queuedNavigate(pathNoBase.startsWith('/') ? pathNoBase : '/' + pathNoBase, true, { + state: state, + replace: true + }); + return; + } + blockerHandled.current = true; + let blockingPromise: any; + roundTrip.current = new Promise( + (resolve, reject) => (blockingPromise = { resolve: resolve, reject: reject }) + ); + // Release blocker handling after promise is fulfilled + roundTrip.current.then( + () => (blockerHandled.current = false), + () => (blockerHandled.current = false) + ); + + // Proceed to the blocked location, unless the navigation originates from a click on a link. + // In that case continue with function execution and perform a server round-trip + if (navigated.current && !fromAnchor.current) { blocker.proceed(); blockingPromise.resolve(); - } - }; - } else { - // permitted navigation: proceed with the blocker - blocker.proceed(); - blockingPromise.resolve(); - } - }); - } - } - }, [blocker.state, blocker.location]); + return; + } + fromAnchor.current = false; + const { pathname, search } = blocker.location; + const routes = ((window as any)?.Vaadin?.routesConfig || []) as any[]; + let matched = matchRoutes(Array.from(routes), pathname); + + // Navigation between server routes + // @ts-ignore + if (matched && matched.filter((path) => path.route?.element?.type?.name === Flow.name).length != 0) { + containerRef.current?.onBeforeEnter?.call( + containerRef?.current, + { pathname, search }, + { + prevent() { + blocker.reset(); + blockingPromise.resolve(); + navigated.current = false; + }, + redirect, + continue() { + blocker.proceed(); + blockingPromise.resolve(); + } + }, + router + ); + navigated.current = true; + } else { + // For covering the 'server -> client' use case + Promise.resolve( + containerRef.current?.onBeforeLeave?.call( + containerRef?.current, + { + pathname, + search + }, + { prevent }, + router + ) + ).then((cmd: unknown) => { + if (cmd === postpone && containerRef.current) { + // postponed navigation: expose existing blocker to Flow + containerRef.current.serverConnected = (cancel) => { + if (cancel) { + blocker.reset(); + blockingPromise.resolve(); + } else { + blocker.proceed(); + blockingPromise.resolve(); + } + }; + } else { + // permitted navigation: proceed with the blocker + blocker.proceed(); + blockingPromise.resolve(); + } + }); + } + } + }, [blocker.state, blocker.location]); - useEffect(() => { - if (blocker.state === 'blocked') { - return; - } - if (navigated.current) { - navigated.current = false; - fireNavigated(location.pathname, location.search); - return; - } - flow.serverSideRoutes[0] - .action({ pathname: location.pathname, search: location.search }) - .then((container) => { - const outlet = ref.current?.parentNode; - if (outlet && outlet !== container.parentNode) { - outlet.append(container); - container.addEventListener('flow-portal-add', addPortalEventHandler as EventListener); - window.addEventListener('click', navigateEventHandler); - containerRef.current = container; + useEffect(() => { + if (blocker.state === 'blocked') { + return; } - return container.onBeforeEnter?.call( - container, - { pathname: location.pathname, search: location.search }, - { - prevent, - redirect, - continue() { - fireNavigated(location.pathname, location.search); - } - }, - router - ); - }) - .then((result: unknown) => { - if (typeof result === 'function') { - result(); + if (navigated.current) { + navigated.current = false; + fireNavigated(location.pathname, location.search); + return; } - }); - }, [location]); - - return ( - <> - - {portals} - - ); + flow.serverSideRoutes[0] + .action({ pathname: location.pathname, search: location.search }) + .then((container) => { + const outlet = ref.current?.parentNode; + if (outlet && outlet !== container.parentNode) { + outlet.append(container); + container.addEventListener('flow-portal-add', addPortalEventHandler as EventListener); + window.addEventListener('click', navigateEventHandler); + containerRef.current = container; + } + return container.onBeforeEnter?.call( + container, + { pathname: location.pathname, search: location.search }, + { + prevent, + redirect, + continue() { + fireNavigated(location.pathname, location.search); + } + }, + router + ); + }) + .then((result: unknown) => { + if (typeof result === 'function') { + result(); + } + }); + }, [location]); + + return ( + <> + + {portals} + + ); } Flow.type = 'FlowContainer'; // This is for copilot to recognize this @@ -571,27 +572,27 @@ export const serverSideRoutes = [{ path: '/*', element: }]; * @returns Promise(resolve, reject) that is fulfilled on script load */ export const loadComponentScript = (tag: String): Promise => { - return new Promise((resolve, reject) => { - useEffect(() => { - const script = document.createElement('script'); - script.src = `/web-component/${tag}.js`; - script.onload = function () { - resolve(); - }; - script.onerror = function (err) { - reject(err); - }; - document.head.appendChild(script); - - return () => { - document.head.removeChild(script); - }; - }, []); - }); + return new Promise((resolve, reject) => { + useEffect(() => { + const script = document.createElement('script'); + script.src = `/web-component/${tag}.js`; + script.onload = function () { + resolve(); + }; + script.onerror = function (err) { + reject(err); + }; + document.head.appendChild(script); + + return () => { + document.head.removeChild(script); + }; + }, []); + }); }; interface Properties { - [key: string]: string; + [key: string]: string; } /** @@ -603,35 +604,35 @@ interface Properties { * @param onerror optional callback for error loading the script */ export const reactElement = (tag: string, props?: Properties, onload?: () => void, onerror?: (err: any) => void) => { - loadComponentScript(tag).then( - () => onload?.(), - (err) => { - if (onerror) { - onerror(err); - } else { - console.error(`Failed to load script for ${tag}.`, err); - } - } - ); + loadComponentScript(tag).then( + () => onload?.(), + (err) => { + if (onerror) { + onerror(err); + } else { + console.error(`Failed to load script for ${tag}.`, err); + } + } + ); - if (props) { - return React.createElement(tag, props); - } - return React.createElement(tag); + if (props) { + return React.createElement(tag, props); + } + return React.createElement(tag); }; export default Flow; // @ts-ignore if (import.meta.hot) { - // @ts-ignore - import.meta.hot.accept((newModule) => { - // A hot module replace for Flow.tsx happens when any JS/TS imported through @JsModule - // or similar is updated because this updates generated-flow-imports.js and that in turn - // is imported by this file. We have no means of hot replacing those files, e.g. some - // custom lit element so we need to reload the page. */ - if (newModule) { - window.location.reload(); - } - }); + // @ts-ignore + import.meta.hot.accept((newModule) => { + // A hot module replace for Flow.tsx happens when any JS/TS imported through @JsModule + // or similar is updated because this updates generated-flow-imports.js and that in turn + // is imported by this file. We have no means of hot replacing those files, e.g. some + // custom lit element so we need to reload the page. */ + if (newModule) { + window.location.reload(); + } + }); } From ac21c07ff6cde2388610bbc05973d6f1c6bf31e5 Mon Sep 17 00:00:00 2001 From: Vlad Rindevich Date: Wed, 15 Jan 2025 10:46:12 +0200 Subject: [PATCH 11/11] refactor(server): restore using Math.random instead of nanoid --- .../main/resources/com/vaadin/flow/server/frontend/Flow.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx index a96949bfd32..df2fa59b7c8 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx @@ -14,7 +14,6 @@ * the License. */ /// -import { nanoid } from 'nanoid'; import { Flow as _Flow } from 'Frontend/generated/jar-resources/Flow.js'; import React, { useCallback, useEffect, useReducer, useRef, useState, type ReactNode } from 'react'; import { matchRoutes, useBlocker, useLocation, useNavigate, type NavigateOptions, useHref } from 'react-router'; @@ -327,7 +326,7 @@ function Flow() { (event: CustomEvent) => { event.preventDefault(); - const key = nanoid(); + const key = Math.random().toString(36).slice(2); dispatchPortalAction( addFlowPortal(