formio / react

JSON powered forms for React.js

Home Page:https://form.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[BUG] Custom component unmounting

FractalStranger opened this issue · comments

Hi,

I have a React18 project and wanted to make custom react-formio component (code below) but this.container.unmount in detachReact method shows this warning:

Warning: Attempted to synchronously unmount a root while React was already rendering. React cannot finish unmounting the root until the current render has completed, which may lead to a race condition.

is there any way to unmount the element without the warning? (And is react-formio compatible with React 18 overall?)

Thanks.

Code:

export default class Grid extends ReactComponent {
  ...
  ...

  attachReact(element) {
    this.container = createRoot(element)
    return this.container.render(
      <HistoryRouter history={history}>
        <GridComponent {...{ ...this }} />
      </HistoryRouter>
    )
  }

  detachReact(element) {
    if (element) {
      this.container.unmount() // this creates the race condition warning
    }
  }
}

Environment

  • Hosting type
    • Form.io
    • Local deployment
      • Version:
  • Formio.js version: 5.0.0-m.3
  • Frontend framework: React 18
commented

Do u have any example of custom component with react typescript if yes please share.

Typescript? Does react-formio support Typescript? It doesn't seem so.

Here is the code of custom component:
https://jsfiddle.net/kv0tq9p8/

Hey I am a newcomer in this project and I would like to contribute

Hi,

I have a React18 project and wanted to make custom react-formio component (code below) but this.container.unmount in detachReact method shows this warning:

Warning: Attempted to synchronously unmount a root while React was already rendering. React cannot finish unmounting the root until the current render has completed, which may lead to a race condition.

is there any way to unmount the element without the warning? (And is react-formio compatible with React 18 overall?)

Thanks.

Code:

export default class Grid extends ReactComponent {
  ...
  ...

  attachReact(element) {
    this.container = createRoot(element)
    return this.container.render(
      <HistoryRouter history={history}>
        <GridComponent {...{ ...this }} />
      </HistoryRouter>
    )
  }

  detachReact(element) {
    if (element) {
      this.container.unmount() // this creates the race condition warning
    }
  }
}

Environment

  • Hosting type

    • Form.io

    • Local deployment

      • Version:
  • Formio.js version: 5.0.0-m.3

  • Frontend framework: React 18

I wonder if you can share your GridComponent code? I am trying to implement it myself but I am not sure how to start.

@FractalStranger did you figure any way to get around this?

@FractalStranger
Hello, did you find someway to figure this out yet? Same issue I also encoutered.

Hey @YoungDriverOfTech @lotorvik @FractalStranger thanks for chiming in here. A couple of things to consider:

  1. I would consider this functionality (in which a react rendering context/instance is "embedded" into a @formio/js render cycle) to really be experimental; my guess is that we're not going to fully support this at any point in the near future. It requires a pretty in-depth knowledge of form.io internals at the moment - for example, we use the "ref" attribute for different reasons than React does so it becomes necessary to change the name of our "ref" attribute so React doesn't complain - and I'm not aware of any plans to clean up the APIs so that something like this would be more easily possible.
  2. We've made some updates in the development branch (in our org, that is master) that refactor the @formio/react library for Typescript and React 18 support. They'll be released with the 5.x version of @formio/js, which is not that far off but still is in development.
  3. I've done some of my own experimenting with this, and here's a file that shows how one might accomplish something like this in React 18 using some of the new changes to @formio/react (and the beta 5.x version of the @formio/js renderer). Keep in mind that there are no guarantees here (see above) and that this is all just experimentation.
import React from 'react';
import { Components } from '@formio/js';
import { Root, createRoot } from 'react-dom/client';
import { TextFieldWrapper } from './TextFieldWrapper';

type JSON = string | number | boolean | null | { [x: string]: JSON } | JSON[];

const Field = Components.components.field;
export class MUITextField extends Field {
    reactRendered: boolean;
    reactRoot: Root | null;
    /**
     * This is the first phase of component building where the component is instantiated.
     *
     * @param component - The component definition created from the settings form.
     * @param options - Any options passed into the renderer.
     * @param data - The submission data object.
     */
    constructor(component: any, options: any, data: any) {
        super(component, options, data);
        this.reactRendered = false;
        this.reactRoot = null;
        this._referenceAttributeName = 'data-formioref';
    }

    /**
     * This method is called any time the component needs to be rebuilt. It is most frequently used to listen to other
     * components using the this.on() function.
     */
    init() {
        return super.init();
    }

    /**
     * This method is called before the component is going to be destroyed, which is when the component instance is
     * destroyed. This is different from detach which is when the component instance still exists but the dom instance is
     * removed.
     */
    destroy() {
        this.detachReact();
        return super.destroy();
    }

    /**
     * This method is called before a form is submitted.
     * It is used to perform any necessary actions or checks before the form data is sent.
     *
     */
    beforeSubmit() {
        return super.beforeSubmit();
    }

    /**
     * The second phase of component building where the component is rendered as an HTML string.
     *
     * @returns {string} - Returns the full string template of the component
     */
    render(): string {
        // For React components, we simply render a div which will become the React root.
        // By calling super.render(string) it will wrap the component with the needed wrappers to make it a full component.
        return super.render(`<div data-formioref="react-${this.id}"></div>`);
    }

    /**
     * The third phase of component building where the component has been attached to the DOM as 'element' and is ready
     * to have its javascript events attached.
     *
     * @param element
     * @returns {Promise<void>} - Return a promise that resolves when the attach is complete.
     */
    attach(element: HTMLElement): Promise<void> {
        // The loadRefs function will find all dom elements that have the "ref" setting that match the object property.
        // It can load a single element or multiple elements with the same ref.
        this.loadRefs(
            element,
            {
                [`react-${this.id}`]: 'single',
            },
            'data-formioref',
        );

        if (this.refs[`react-${this.id}`]) {
            this.element = this.refs[`react-${this.id}`];
            this.attachReact(this.element);
        }
        return new Promise<void>((resolve) => {
            // Wait for the react root to be fully rendered before continuing the attach phase
            this.on(`react-rendered-${this.id}`, () => {
                super.attach(element);
                resolve();
            });
        });
    }

    /**
     * The fourth phase of component building where the component is being removed from the page. This could be a redraw
     * or it is being removed from the form.
     */
    detach() {
        if (this.refs[`react-${this.id}`]) {
            this.detachReact();
        }
        super.detach();
    }

    /**
     * Callback passed to our React root (via the wrapper's useEffect) that will signal that React has finished rendering.
     */
    onReactRendered = () => {
        // verify that our Form.io-specific reference attribute is present
        this.loadRefs(
            this.element,
            {
                input: 'multiple',
            },
            'data-formioref',
        );
        if (
            !this.refs['input'] ||
            (Array.isArray(this.refs['input']) &&
                this.refs['input'].length === 0)
        ) {
            console.warn(
                "Can't find an associated input element. Has React finished rendering? Does your input element have a formio-specific ref attribute?",
            );
            return;
        }
        this.reactRendered = true;
        this.emit(`react-rendered-${this.id}`, {});
    };

    /**
     * Attaches React to the previously rendered <div /> element. The TextFieldWrapper component
     * is passed our onReactRendered callback so that we can signal when React has finished rendering.
     *
     * @param element
     *
     */
    attachReact(element: HTMLElement) {
        console.log('attaching react...');
        if (!this.reactRoot) {
            this.reactRoot = createRoot(element);
        }
        this.reactRoot.render(
            <TextFieldWrapper
                onRendered={this.onReactRendered.bind(this)}
                onChange={(event) => {
                    this.setValue(event.target.value);
                }}
                variant={'filled'}
                fullWidth
                label={this.component.label}
                inputProps={{ 'data-formioref': 'input' }}
            />,
        );
    }

    detachReact() {
        console.log('detaching react...');
        if (this.element && this.reactRoot) {
            this.reactRoot.unmount();
        }
    }

    /**
     * Something external has set a value and our component needs to be updated to reflect that. For example, loading a submission.
     *
     * @param value
     */
    setValue(value = '') {
        if (!this.reactRendered) {
            this.on(`react-rendered-${this.id}`, () => {
                super.setValue(value, { modified: true });
            });
            return false;
        }
        super.setValue(value, { modifed: true });
        return true;
    }
}