BuilderIO / builder

Visual Development for React, Vue, Svelte, Qwik, and more
https://builder.io
MIT License
7.26k stars 897 forks source link

React 18 `hydrateRoot` hydration failed with SSR #1229

Open andrelandgraf opened 2 years ago

andrelandgraf commented 2 years ago

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 thebuilderStateprop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercasebuilderstateinstead. 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

teleaziz commented 1 year ago

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

andrelandgraf commented 1 year ago

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;
}
rista404 commented 1 year ago

We're seeing the same issues, on the same stack. Hydrogen v2 (Remix) and React 18, after a mismatch and hydration builder's css gone and the page is broken. Any idea how this can be fixed?

teja-virgio commented 4 months ago

Facing same issue. Hydration error for custom components. Server content not matching with client. Any fix??