-
Notifications
You must be signed in to change notification settings - Fork 40
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
Comments
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>
</>
);
} |
Hi @christopher-ha thanks for reaching out and providing all the debugging info.
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. |
Hi @christopher-ha, basically the issue is that since you are overriding some resolvers, the 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 |
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. |
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.
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.
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
System Info
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.Validations
The text was updated successfully, but these errors were encountered: