Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[StoryblokRichText] Encountered two children with the same key, null. Keys should be unique so that components. #1342

Open
1 task done
christopher-ha opened this issue Feb 12, 2025 · 4 comments
Assignees
Labels
has-workaround [Issue] Temporary solutions available. p3-significant [Priority] Moderate issues, major enhancements

Comments

@christopher-ha
Copy link

christopher-ha commented Feb 12, 2025

Describe the issue you're facing

When using StoryblokRichText with Next.js 15 + React 19, I'm getting an error that says there's children being rendered with no keys.

Logging the values reveals that some elements don't have a key value. I saw in the other library that there is an option for keyed resolvers, or a set of options we can pass in, but in this component there's literally no way to override the options being passed to Storyblok Richtext.

Image

I've tried everything from forcing uuid's onto every element in the resolver, using React useId() to generate them, but it seems like its when the node gets rendered it wraps itself in a div which you can see in the error, that will always throw a null key. It's basically impossible to solve since the API doesn't let us override how these elements are rendered.

I have also even tried to force it inside of the apiOptions when I instance Storyblok to force append keys to every single element coming from the CMS, but it did not work.

The way I have my components setup, I am using a children-first approach to resolve the nodes in the parent so that my components can style the text and not just receive the [object Object] from the node.

// Forcing a rendering method in the storyblokInit() using Math.random() for key generation does not work.
typescript
export const getStoryblokApi = storyblokInit({
  accessToken,
  use: [apiPlugin],
  apiOptions: {
    region: "us",
    richTextSchema: {
      nodes: {
        render: (tag: string, attrs: any, children: any) =>
          React.createElement(tag, { ...attrs, key: getKey() }, children),
        text: (text: string) =>
          React.createElement(React.Fragment, { key: getKey() }, text),
        heading: (children: any, attrs: any) =>
          React.createElement(
            `h${attrs.level || 1}`,
            { key: getKey() },
            children,
          ),
        paragraph: (children: any) =>
          React.createElement("p", { key: getKey() }, children),
        image: (attrs: any) =>
          React.createElement("img", { ...attrs, key: getKey() }),
        code_block: (children: any, attrs: any) =>
          React.createElement(
            "pre",
            { key: getKey() },
            React.createElement(
              "code",
              { className: attrs.class, key: getKey() },
              children,
            ),
          ),
        blockquote: (children: any) =>
          React.createElement("blockquote", { key: getKey() }, children),
        bullet_list: (children: any) =>
          React.createElement("ul", { key: getKey() }, children),
        ordered_list: (children: any) =>
          React.createElement("ol", { key: getKey() }, children),
        list_item: (children: any) =>
          React.createElement("li", { key: getKey() }, children),
        horizontal_rule: () => React.createElement("hr", { key: getKey() }),
      },
      marks: {
        link: (children: any, props: any) =>
          React.createElement(
            "a",
            {
              key: getKey(),
              href: props.href,
              target: props.target,
            },
            children,
          ),
        bold: (children: any) =>
          React.createElement("strong", { key: getKey() }, children),
        italic: (children: any) =>
          React.createElement("em", { key: getKey() }, children),
        underline: (children: any) =>
          React.createElement("u", { key: getKey() }, children),
        strike: (children: any) =>
          React.createElement("s", { key: getKey() }, children),
        code: (children: any) =>
          React.createElement(
            "code",
            {
              key: getKey(),
              className: "bg-pebble-100 px-1 py-0.5 rounded",
            },
            children,
          ),
        highlight: (children: any) =>
          React.createElement("mark", { key: getKey() }, children),
        subscript: (children: any) =>
          React.createElement("sub", { key: getKey() }, children),
        superscript: (children: any) =>
          React.createElement("sup", { key: getKey() }, children),
      },
    },
  },
});

Unfortunately I don't have a reproduction URL since it's currently on a client project and they own the repo.

Reproduction

https://localhost:3000/blog/example

Steps to reproduce

import { StoryblokRichText, MarkTypes, BlockTypes } from "@storyblok/react/rsc";
import type { StoryblokRichTextNode } from "@storyblok/react";
import type { ReactElement } from "react";
import type { BlogContentProps } from "@/types/blog";
import {
  BlogPullQuote,
  BlogPullQuoteContent,
  BlogPullQuoteAttribution,
} from "@/components/blog/blocks/blog-pull-quote";
import BlogHeading from "@/components/blog/blocks/blog-heading";
import BlogParagraph from "@/components/blog/blocks/blog-paragraph";
import BlogCodeBlock from "@/components/blog/blocks/blog-code-block";
import BlogHeader from "@/components/blog/blog-header";
import BlogScrollSpy from "@/components/blog/blog-scroll-spy";
import React, { Suspense } from "react";

export default function BlogContent({ article }: BlogContentProps) {
  const resolvers = {
    [BlockTypes.HEADING]: (node: StoryblokRichTextNode<ReactElement>) => {
      switch (node.attrs?.level) {
        case 1:
          return <BlogHeading level="large">{node.children}</BlogHeading>;
        case 2:
          return <BlogHeading level="small">{node.children}</BlogHeading>;
        default:
          return <BlogHeading level="small">{node.children}</BlogHeading>;
      }
    },
    [BlockTypes.PARAGRAPH]: (node: StoryblokRichTextNode<ReactElement>) => (
      <BlogParagraph>{node.children}</BlogParagraph>
    ),
    [BlockTypes.COMPONENT]: (node: StoryblokRichTextNode<ReactElement>) => {
      const blok = node.attrs?.body?.[0];
      if (blok?.component === "pull-quote") {
        return (
          <BlogPullQuote>
            <BlogPullQuoteContent>{blok.quote}</BlogPullQuoteContent>
            {blok.attribution && (
              <BlogPullQuoteAttribution>
                {blok.attribution}
              </BlogPullQuoteAttribution>
            )}
          </BlogPullQuote>
        );
      }
      return <></>;
    },
    [BlockTypes.CODE_BLOCK]: (node: StoryblokRichTextNode<ReactElement>) => {
      const languageClass = node.attrs?.class || "";
      const language = languageClass.replace("language-", "");
      return (
        <Suspense
          fallback={
            <div className="bg-pebble-100 h-32 animate-pulse rounded-[6px]" />
          }
        >
          <BlogCodeBlock language={language}>{node.children}</BlogCodeBlock>
        </Suspense>
      );
    },
    [MarkTypes.LINK]: (node: StoryblokRichTextNode<ReactElement>) => (
      <a href={node.attrs?.href} target={node.attrs?.target}>
        {node.children}
      </a>
    ),
  };

  if (!article?.content) {
    return null;
  }

  return (
    <>
      <BlogHeader
        title={article.content.title ?? ""}
        subtitle={article.content.subheading ?? ""}
        date={new Date(article.content.date ?? "")}
        author={article.content.author ?? ""}
        bio="Adaline is the single platform to iterate, evaluate, and monitor LLMs."
        breadcrumbPath={[{ text: "Blog", href: "/blog" }]}
        heroImage={article.content.heroImage?.filename}
      />

      <main className="relative mx-auto px-[var(--grid-margin-min)]">
        <BlogScrollSpy />
        {article.content.content && (
          <StoryblokRichText
            doc={article.content.content as StoryblokRichTextNode<ReactElement>}
            resolvers={resolvers}
          />
        )}
      </main>
    </>
  );
}

System Info

System:
    OS: macOS 15.0.1
    CPU: (10) arm64 Apple M1 Max
    Memory: 160.72 MB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.14.0 - ~/.nvm/versions/node/v20.14.0/bin/node
    npm: 10.7.0 - ~/.nvm/versions/node/v20.14.0/bin/npm
    pnpm: 10.2.1 - ~/.nvm/versions/node/v20.14.0/bin/pnpm
    bun: 1.0.0 - ~/.bun/bin/bun
  Browsers:
    Chrome: 133.0.6943.54
    Safari: 18.0.1

Used Package Manager

npm

Error logs (Optional)

Encountered two children with the same key, null. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.

Image

Image

Image

Validations

@christopher-ha christopher-ha added pending-author [Issue] Awaiting further information or action from the issue author pending-triage [Issue] Ticket is pending to be prioritised labels Feb 12, 2025
@christopher-ha
Copy link
Author

christopher-ha commented Feb 12, 2025

I landed on a relatively complex solution using the original Storyblok Rich Text component since the React SDK doesn't let you customize the rendering logic. The key to fixing it on this version was to add a fragment wrapper with a key using React.Children.map, and also forcing unique keys on each item. Some of the code was taken from examples library on the other repo like the style string -> object function.

The code is much uglier but it renders with no errors. I believe keyedResolvers boolean handles it per node, but the parent divs that get mapped over don't get keys hence why forcing them worked.

Anyone working on the open source repo here with more knowledge want to incorporate a fix using some of these learnings?

import { MarkTypes, BlockTypes } from "@storyblok/react/rsc";
import type {
  StoryblokRichTextNode,
  StoryblokRichTextOptions,
} from "@storyblok/react";
import type { ReactElement, ReactNode, CSSProperties } from "react";
import type { BlogContentProps } from "@/types/blog";
import {
  BlogPullQuote,
  BlogPullQuoteContent,
  BlogPullQuoteAttribution,
} from "@/components/blog/blocks/blog-pull-quote";
import BlogHeading from "@/components/blog/blocks/blog-heading";
import BlogParagraph from "@/components/blog/blocks/blog-paragraph";
import BlogCodeBlock from "@/components/blog/blocks/blog-code-block";
import BlogHeader from "@/components/blog/blog-header";
import BlogScrollSpy from "@/components/blog/blog-scroll-spy";
import React, { Suspense } from "react";
import { richTextResolver } from "@storyblok/react/rsc";

interface StoryblokAttributes {
  class?: string;
  className?: string;
  style?: string | CSSProperties;
  href?: string;
  target?: string;
  [key: string]: unknown;
}

export default function BlogContent({ article }: BlogContentProps) {
  const resolvers = {
    [BlockTypes.HEADING]: (node: StoryblokRichTextNode<ReactElement>) => {
      switch (node.attrs?.level) {
        case 1:
          return <BlogHeading level="large">{node.children}</BlogHeading>;
        case 2:
          return <BlogHeading level="small">{node.children}</BlogHeading>;
        default:
          return <BlogHeading level="small">{node.children}</BlogHeading>;
      }
    },
    [BlockTypes.PARAGRAPH]: (node: StoryblokRichTextNode<ReactElement>) => (
      <BlogParagraph>{node.children}</BlogParagraph>
    ),
    [BlockTypes.COMPONENT]: (node: StoryblokRichTextNode<ReactElement>) => {
      const blok = node.attrs?.body?.[0];
      if (blok?.component === "pull-quote") {
        return (
          <BlogPullQuote>
            <BlogPullQuoteContent>{blok.quote}</BlogPullQuoteContent>
            {blok.attribution && (
              <BlogPullQuoteAttribution>
                {blok.attribution}
              </BlogPullQuoteAttribution>
            )}
          </BlogPullQuote>
        );
      }
      return <></>;
    },
    [BlockTypes.CODE_BLOCK]: (node: StoryblokRichTextNode<ReactElement>) => {
      const codeText = node.content?.[0]?.text || "";
      return (
        <Suspense
          fallback={
            <div className="bg-pebble-100 h-32 animate-pulse rounded-[6px]" />
          }
        >
          <BlogCodeBlock
            language={(node.attrs?.class || "").replace("language-", "")}
          >
            {codeText}
          </BlogCodeBlock>
        </Suspense>
      );
    },
    [MarkTypes.LINK]: (node: StoryblokRichTextNode<ReactElement>) => (
      <a href={node.attrs?.href} target={node.attrs?.target}>
        {node.children}
      </a>
    ),
  };

  const options: StoryblokRichTextOptions<ReactElement> = {
    renderFn: (
      tag: string,
      attrs: StoryblokAttributes,
      children: ReactNode,
    ) => {
      const props = { ...attrs };
      if (props.class) {
        props.className = props.class;
        delete props.class;
      }
      if (typeof props.style === "string") {
        // Convert style string to object
        props.style = props.style
          .split(";")
          .reduce((acc: Record<string, string>, prop) => {
            const [key, value] = prop.split(":");
            if (!key?.trim() || !value?.trim()) return acc;
            const camelKey = key
              .trim()
              .replace(/-([a-z])/g, (_, g) => g[1].toUpperCase());
            acc[camelKey] = value.trim();
            return acc;
          }, {});
      }
      return React.createElement(tag, props, children);
    },
    textFn: (text: string): ReactElement => <>{text}</>,
    keyedResolvers: true,
    resolvers,
  };

  const { render } = richTextResolver<ReactElement>(options);

  if (!article?.content) return null;

  return (
    <>
      <BlogHeader
        title={article.content.title ?? ""}
        subtitle={article.content.subheading ?? ""}
        date={new Date(article.content.date ?? "")}
        author={article.content.author ?? ""}
        bio="Adaline is the single platform to iterate, evaluate, and monitor LLMs."
        breadcrumbPath={[{ text: "Blog", href: "/blog" }]}
        heroImage={article.content.heroImage?.filename}
      />

      <main className="relative mx-auto px-[var(--grid-margin-min)]">
        <BlogScrollSpy />
        {article.content.content && (
          <div>
            {React.Children.map(
              render(
                article.content
                  .content as unknown as StoryblokRichTextNode<ReactElement>,
              ),
              (child, index) => (
                <React.Fragment key={`content-${index}`}>
                  {child}
                </React.Fragment>
              ),
            )}
          </div>
        )}
      </main>
    </>
  );
}

@alvarosabu
Copy link
Contributor

Hi @christopher-ha thanks for reaching out and providing all the debugging info.

Unfortunately I don't have a reproduction URL since it's currently on a client project and they own the repo.

I understand the constraints, but would help a lot next time if you could provide a minimal example using https://stackblitz.com/ or something similar.

I will try to reproduce it and get back to you.

@alvarosabu
Copy link
Contributor

Hi @christopher-ha, basically the issue is that since you are overriding some resolvers, the keyedResolvers options will not apply to them, so you need to do it like this manually, so the workaround would be:

 const resolvers = {
    [BlockTypes.HEADING]: (node: StoryblokRichTextNode<React.ReactElement>) => {
      node.attrs.key = Math.random().toString(36).substring(2, 15);
      switch (node.attrs?.level) {
        case 1:
          return <BlogHeading key={node.attrs?.key} level="large">{node.children}</BlogHeading>;
        case 2:
          return <BlogHeading key={node.attrs?.key} level="small">{node.children}</BlogHeading>;
        default:
          return <BlogHeading key={node.attrs?.key} level="small">{node.children}</BlogHeading>;
      }
    },
  };

I will discuss with the team if its something we could tackle from @storyblok/richtext or provide some factory util so you don't need to worry about it. Depending on the outcome I will update the docs accordinly

@alvarosabu alvarosabu removed the pending-author [Issue] Awaiting further information or action from the issue author label Feb 12, 2025
@alvarosabu alvarosabu self-assigned this Feb 12, 2025
@alvarosabu alvarosabu added p3-significant [Priority] Moderate issues, major enhancements has-workaround [Issue] Temporary solutions available. and removed pending-triage [Issue] Ticket is pending to be prioritised labels Feb 12, 2025
@christopher-ha
Copy link
Author

christopher-ha commented Feb 12, 2025

Appreciate the fast reply. Trust me I've tried everything including that solution. There is always a div that doesn't get a key even if all of the other errors disappear. Please place this at high priority. This is currently being used in a client project and I want the original React SDK to be used. The one I posted feels like a very hacky solution, and the idea of attaching a key with randomized values on every resolver is not ideal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
has-workaround [Issue] Temporary solutions available. p3-significant [Priority] Moderate issues, major enhancements
Projects
None yet
Development

No branches or pull requests

2 participants