Skip to content

Commit

Permalink
fix(flow-react): avoid Flow-React portal outlet removal conflicts (#2…
Browse files Browse the repository at this point in the history
…0770)

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

---------

Co-authored-by: Vlad Rindevich <vladrin@vaadin.com>

(cherry picked from commit 66443d4)
  • Loading branch information
platosha authored and Lodin committed Jan 16, 2025
1 parent c72e2a4 commit ce9b032
Show file tree
Hide file tree
Showing 2 changed files with 341 additions and 249 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,21 @@
* 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,
Ref,
useEffect,
useReducer,
useRef
} from "react";
import { createRoot, Root } from 'react-dom/client';
import { createElement, type Dispatch, type ReactElement, type ReactNode, useEffect, useReducer } from 'react';

type FlowStateKeyChangedAction<K extends string, V> = Readonly<{
type: 'stateKeyChanged',
key: K,
value: V,
type: 'stateKeyChanged';
key: K;
value: V;
}>;

type FlowStateReducerAction = FlowStateKeyChangedAction<string, unknown>;

function stateReducer<S extends Readonly<Record<string, unknown>>>(state: S, action: FlowStateReducerAction): S {
switch (action.type) {
case "stateKeyChanged":
const {key, value} = action;
case 'stateKeyChanged':
const { value } = action;
return {
...state,
key: value
Expand All @@ -46,9 +37,7 @@ function stateReducer<S extends Readonly<Record<string, unknown>>>(state: S, act
}
}

type DispatchEvent<T> = T extends undefined
? () => boolean
: (value: T) => boolean;
type DispatchEvent<T> = T extends undefined ? () => boolean : (value: T) => boolean;

const emptyAction: Dispatch<unknown> = () => {};

Expand All @@ -72,7 +61,7 @@ export type RenderHooks = {
* 2. The `set` function for changing the state and triggering render
* @protected
*/
readonly useState: ReactAdapterElement["useState"]
readonly useState: ReactAdapterElement['useState'];

/**
* A hook helper to simplify dispatching a `CustomEvent` on the Web
Expand All @@ -88,7 +77,7 @@ export type RenderHooks = {
* - For other types, has one parameter for the `event.detail` value of that type.
* @protected
*/
readonly useCustomEvent: ReactAdapterElement["useCustomEvent"]
readonly useCustomEvent: ReactAdapterElement['useCustomEvent'];

/**
* A hook helper to generate the content element with name attribute to bind
Expand All @@ -109,7 +98,7 @@ export type RenderHooks = {
*
* @param name - The name attribute of the element
*/
readonly useContent: ReactAdapterElement["useContent"]
readonly useContent: ReactAdapterElement['useContent'];
};

interface ReadyCallbackFunction {
Expand Down Expand Up @@ -137,7 +126,7 @@ export abstract class ReactAdapterElement extends HTMLElement {

readonly #Wrapper: () => ReactElement | null;

#unmountComplete = Promise.resolve();
#unmounting?: Promise<void>;

constructor() {
super();
Expand All @@ -151,22 +140,25 @@ export abstract class ReactAdapterElement extends HTMLElement {
}

public async connectedCallback() {
await this.#unmountComplete;
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,
}
}));
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);
Expand All @@ -187,19 +179,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.#unmountComplete = Promise.resolve();
await this.#unmountComplete;
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;
}
Expand Down Expand Up @@ -233,15 +230,15 @@ export abstract class ReactAdapterElement extends HTMLElement {
},
set(nextValue: T) {
this.#state[key] = nextValue;
this.#dispatchFlowState({type: 'stateKeyChanged', key, value});
this.#dispatchFlowState({ type: 'stateKeyChanged', key, value });
}
});

const dispatchChangedEvent = this.useCustomEvent<{value: T}>(`${key}-changed`, {detail: {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});
dispatchChangedEvent({ value });
this.#dispatchFlowState({ type: 'stateKeyChanged', key, value });
};
this.#stateSetters.set(key, setValue as Dispatch<unknown>);
return [value, setValue];
Expand All @@ -264,10 +261,13 @@ export abstract class ReactAdapterElement extends HTMLElement {
protected useCustomEvent<T = undefined>(type: string, options: CustomEventInit<T> = {}): DispatchEvent<T> {
if (!this.#customEvents.has(type)) {
const dispatch = ((detail?: T) => {
const eventInitDict = detail === undefined ? options : {
...options,
detail
};
const eventInitDict =
detail === undefined
? options
: {
...options,
detail
};
const event = new CustomEvent(type, eventInitDict);
return this.dispatchEvent(event);
}) as DispatchEvent<T>;
Expand Down Expand Up @@ -295,7 +295,7 @@ export abstract class ReactAdapterElement extends HTMLElement {
useEffect(() => {
this.#readyCallback.get(name)?.();
}, []);
return createElement('flow-content-container', {name, style: {display: 'contents'}});
return createElement('flow-content-container', { name, style: { display: 'contents' } });
}

#maybeRenderRoot() {
Expand All @@ -314,7 +314,7 @@ export abstract class ReactAdapterElement extends HTMLElement {
return this.render(this.#renderHooks);
}

#markAsUsed() : void {
#markAsUsed(): void {
// @ts-ignore
let vaadinObject = window.Vaadin || {};
// @ts-ignore
Expand Down
Loading

0 comments on commit ce9b032

Please sign in to comment.