builder icon indicating copy to clipboard operation
builder copied to clipboard

React 18 `hydrateRoot` hydration failed with SSR

Open andrelandgraf opened this issue 1 year ago • 4 comments

Hey friends! Big fan of builder! I am running into an issue with builder and React 18. I am using Remix and Tailwind but I am very certain that it's React 18 that is causing the issue.

Describe the bug

React throws the following error when builder is used with React 18 and server-side rendering:

Warning: React does not recognize the builderStateprop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercasebuilderstate instead. If you accidentally passed it from a parent component, remove it from the DOM element.

The server-side rendered HTML loads fine (builder page looks as expected and builder style tags present). After hydration, React reports: Hydration failed because the initial UI does not match what was rendered on the server. Then the builder pages lose their styling (the emotion style tags disappear).

To Reproduce

Steps to reproduce the behavior:

  1. Use React 18 and use hydrateRoot
  2. Load builder both server and client-side
  3. Throttle to slow 3G for better visibility
  4. See how the builder page breaks after attempted hyrdation

My (Remix.run) client entry file:

import { RemixBrowser } from '@remix-run/react';
import { hydrateRoot } from 'react-dom/client';
import { registerComponents } from './modules/builder-pages/registerComponents';

// registering builder components and calling builder init with public key
registerComponents();

// using React 18 hydrateRoot
hydrateRoot(document, <RemixBrowser />);

Expected behavior

Builder works as expected after React hydration and doesn't loose the CSS properties.

The following non-React 18 client entry file works:

import { RemixBrowser } from '@remix-run/react';
import { hydrate } from 'react-dom';
import { registerComponents } from './modules/builder-pages/registerComponents';

registerComponents();

hydrate(<RemixBrowser />, document);

Additional context

  • React v18.2.0
  • Remix.run v1.6.5

andrelandgraf avatar Aug 27 '22 01:08 andrelandgraf

Hi @andrelandgraf , thanks for the kind words and reporting this issue, the builderState prop is passed to all registered custom components, so this is possibly related to what you have in the registerComponents method, please share details of this method or a codesandbox/repo I can reproduce this issue on

teleaziz avatar Sep 09 '22 18:09 teleaziz

Hi @teleaziz, thanks for your support!

Sure,registerComponents:

import { Builder } from '@builder.io/react';
import { CallToActionLink } from '~/components/cta/callToActionLink';
import { StartBanner } from '~/components/start/start';
import { ButtonLink, TextLink, Image, H1, H2, H3, H4, Paragraph, ImageWithLink } from '~/components/UI';
import { getPublicBuilderInstance } from './builder';

let componentsHaveBeenRegistered = false;

export function registerComponents() {
  if (componentsHaveBeenRegistered) {
    return;
  }
  componentsHaveBeenRegistered = true;

  // init builder
  getPublicBuilderInstance();

  Builder.registerComponent(ButtonLink, {
    name: 'Button Link',
    inputs: [
      { name: 'children', type: 'text', defaultValue: 'Button Text', required: true, friendlyName: 'Text' },
      { name: 'to', type: 'text', defaultValue: '/', required: true, friendlyName: 'Link' },
      { name: 'primary', type: 'boolean', defaultValue: false, friendlyName: 'Is Primary Button?' },
    ],
  });

  Builder.registerComponent(TextLink, {
    name: 'Link in Text',
    inputs: [
      { name: 'children', type: 'text', defaultValue: 'Link Text', required: true, friendlyName: 'Text' },
      { name: 'to', type: 'text', defaultValue: '/', required: true, friendlyName: 'Link' },
    ],
  });

  Builder.registerComponent(Image, {
    name: 'Cloudinary Image',
    inputs: [
      {
        name: 'src',
        type: 'text',
        defaultValue: 'https://res.cloudinary.com/...',
        required: true,
      },
      {
        name: 'alt',
        type: 'text',
        defaultValue: '...',
        required: true,
      },
      {
        name: 'width',
        type: 'text',
        defaultValue: 'auto',
        enum: ['auto', 'max'],
      },
    ],
  });

  Builder.registerComponent(ImageWithLink, {
    name: 'Cloudinary Image with Link',
    inputs: [
      {
        name: 'src',
        type: 'text',
        defaultValue: 'https://res.cloudinary.com/...',
        required: true,
      },
      {
        name: 'alt',
        type: 'text',
        defaultValue: '...',
        required: true,
      },
      {
        name: 'width',
        type: 'text',
        defaultValue: 'auto',
        enum: ['auto', 'max'],
      },
      {
        name: 'ariaLabel',
        type: 'text',
        defaultValue: 'Link Label',
        required: true,
        friendlyName: 'Aria Label',
      },
      { name: 'to', type: 'text', defaultValue: '/', required: true, friendlyName: 'Link' },
      { name: 'withShadow', type: 'boolean', defaultValue: true, friendlyName: 'Background Shadow' },
    ],
  });

  Builder.registerComponent(H1, {
    name: 'Heading 1',
    inputs: [
      { name: 'children', type: 'text', defaultValue: 'Heading 1', required: true, friendlyName: 'Text' },
      { name: 'asH2', type: 'boolean', defaultValue: false, friendlyName: 'Als Heading 2' },
    ],
  });

  Builder.registerComponent(H2, {
    name: 'Heading 2',
    inputs: [{ name: 'children', type: 'text', defaultValue: 'Heading 2', required: true, friendlyName: 'Text' }],
  });

  Builder.registerComponent(H3, {
    name: 'Heading 3',
    inputs: [{ name: 'children', type: 'text', defaultValue: 'Heading 3', required: true, friendlyName: 'Text' }],
  });

  Builder.registerComponent(H4, {
    name: 'Heading 4',
    inputs: [{ name: 'children', type: 'text', defaultValue: 'Heading 4', required: true, friendlyName: 'Text' }],
  });

  Builder.registerComponent(StartBanner, {
    name: 'StartBanner',
    inputs: [],
  });

  Builder.registerComponent(CallToActionLink, {
    name: 'CallToActionLink',
    inputs: [],
  });

  // overrides Text builder basics component
  Builder.registerComponent(Paragraph, {
    name: 'Text',
    inputs: [
      {
        name: 'children',
        type: 'richText',
        defaultValue: '<b>Paragraph Text</b>',
        required: true,
        friendlyName: 'Text',
      },
      {
        name: 'parseAsHTML',
        type: 'boolean',
        defaultValue: true,
        showIf: () => false,
      },
      {
        name: 'As',
        type: 'text',
        defaultValue: 'div',
        showIf: () => false,
      },
    ],
  });

  Builder.registerComponent(Paragraph, {
    name: 'Paragraph',
    inputs: [
      {
        name: 'children',
        type: 'richText',
        defaultValue: '<b>Paragraph Text</b>',
        required: true,
        friendlyName: 'Text',
      },
      {
        name: 'parseAsHTML',
        type: 'boolean',
        defaultValue: true,
        showIf: () => false,
      },
      {
        name: 'As',
        type: 'text',
        defaultValue: 'div',
        showIf: () => false,
      },
    ],
  });
}

And getPublicBuilderInstance:

let isInitialized = false;
export function getPublicBuilderInstance() {
  if (!isInitialized) {
    isInitialized = true;
    // init builder page rendering with public key
    builder.init('...');
  }
  return builder;
}

andrelandgraf avatar Sep 10 '22 00:09 andrelandgraf