Skip to content

Plugin override components remount on every overrides prop change #1625

@imironyak

Description

@imironyak

Description

When a plugin provides an override, any change to the prop-level overrides object passed to <Puck> causes the plugin's override subtree to fully unmount and remount - even if the plugin itself or its override components haven't changed.

Environment

  • Puck version: 0.21.2

Steps to reproduce

import { useEffect, useState } from "react";
  import { Puck, type Config, type Plugin } from "@puckeditor/core";

  const config: Config = {
    components: {
      Text: {
        render: () => <p>Hello</p>,
      },
    },
  };

  const data = { content: [], root: { props: {} } };

  const myPlugin: Plugin = {
    overrides: {
      puck: ({ children }) => {
        useEffect(() => {
          console.log("puck override MOUNTED");
          return () => console.log("puck override UNMOUNTED");
        }, []);
        return <>{children}</>;
      },
    },
  };

  export default function App() {
    const [count, setCount] = useState(0);

    return (
      <>
        <button onClick={() => setCount((c) => c + 1)}>
          Re-render ({count})
        </button>
        <Puck
          config={config}
          data={data}
          plugins={[myPlugin]}
          overrides={{
            headerActions: ({ children }) => (
              <>
                {children}
                <span>Count: {count}</span>
              </>
            ),
          }}
        />
      </>
    );
  }
  1. Open the console
  2. Click the "Re-render" button

What happens

Every click logs "puck override UNMOUNTED" followed by "puck override MOUNTED". The entire subtree wrapped by the plugin's puck override is destroyed and recreated, and the editor blinks.

What I expect to happen

The plugin override stays mounted because the plugin hasn't changed.

Debugging

loadOverrides (packages/core/lib/load-overrides.ts:39-44) wraps every override in a new inline arrow function on each invocation:

const childNode = collected[overridesType];
const Comp = (props: any) =>
  plugin.overrides![overridesType]!({
    ...props,
    children: childNode ? childNode(props) : props.children,
  });
collected[overridesType] = Comp;

useLoadedOverrides re-runs loadOverrides whenever the overrides or plugins prop changes, which creates a new function reference for every override:

return useMemo(() => loadOverrides({ overrides, plugins }), [plugins, overrides]);

As a result, React sees each override as a different component and unmounts and remounts the entire tree.

Any consumer that passes an override prop using a closure for rendering will trigger a full remount of all overrides on every change, even when the overrides themselves have not changed or are provided as plugins.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions