Skip to content

Commit a7db0e6

Browse files
authoredMar 20, 2025··
fix: use correct ReactNode zod validation for Layout.footer/banner/editLink/feedback.content/toc.backToTop/toc.extraContent/toc.title/search and Navbar.children/projectIcon/chatIcon (#4372)
* upd * upd * upd * fix: use proper ReactNode validation for `Layout.footer` and `Navbar.children` * upd * aa * upd * upd * upd * upd * little fix
1 parent 7cc821d commit a7db0e6

File tree

14 files changed

+124
-71
lines changed

14 files changed

+124
-71
lines changed
 

Diff for: ‎.changeset/chubby-cars-play.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"nextra-theme-docs": patch
3+
"nextra": patch
4+
---
5+
6+
fix: use correct `ReactNode` zod validation for `Layout.footer/banner/editLink/feedback.content/toc.backToTop/toc.extraContent/toc.title/search` and `Navbar.children/projectIcon/chatIcon`

Diff for: ‎docs/app/docs/docs-theme/built-ins/layout/old.mdx

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## MDX Components [!TODO]
2+
3+
Provide custom [MDX components](https://mdxjs.com/table-of-components) to render
4+
the content. For example, you can use a custom `pre` component to render code
5+
blocks.

Diff for: ‎docs/app/docs/docs-theme/built-ins/layout/page.mdx

+46-20
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,36 @@ export default function MyLayout({ children, ...props }) {
3030

3131
Detailed information for each option is listed below.
3232

33+
## Page Map
34+
35+
| | | | |
36+
| --------- | -------------------- | --- | ------------------------------------------------------------- |
37+
| `pageMap` | `PageMapItem[]{:ts}` | | Page map list. Result of `getPageMap(route = '/'){:ts}` call. |
38+
39+
## Banner
40+
41+
| | | | |
42+
| -------- | ---------------- | --- | ------------------------------------------------------------------------------------------------ |
43+
| `banner` | `ReactNode{:ts}` | | Rendered [`<Banner>` component](/docs/built-ins/banner). E.g. `<Banner {...bannerProps} />{:ts}` |
44+
45+
## Navbar
46+
47+
| | | | |
48+
| -------- | ---------------- | --- | ----------------------------------------------------------------------------------------------------------- |
49+
| `navbar` | `ReactNode{:ts}` | | Rendered [`<Navbar>` component](/docs/docs-theme/built-ins/navbar). E.g. `<Navbar {...navbarProps} />{:ts}` |
50+
51+
## Footer
52+
53+
| | | | |
54+
| -------- | ---------------- | --- | ----------------------------------------------------------------------------------------------------------- |
55+
| `footer` | `ReactNode{:ts}` | | Rendered [`<Footer>` component](/docs/docs-theme/built-ins/footer). E.g. `<Footer {...footerProps} />{:ts}` |
56+
57+
## Search
58+
59+
| | | | |
60+
| -------- | ---------------- | ------------ | ------------------------------------------------------------------------------------------------ |
61+
| `search` | `ReactNode{:ts}` | `<Search />` | Rendered [`<Search>` component](/docs/built-ins/search). E.g. `<Search {...searchProps} />{:ts}` |
62+
3363
## Docs Repository
3464

3565
Set the repository URL of the documentation. It's used to generate the
@@ -64,11 +94,9 @@ Customize the theme behavior of the website.
6494
| `darkMode` | `boolean{:ts}` | `true{:ts}` | Show or hide the dark mode select button. |
6595
| `nextThemes` | `object{:ts}` | `{ attribute: 'class', defaultTheme: 'system', disableTransitionOnChange: true, storageKey: 'theme' }{:ts}` | Configuration for the [next-themes](https://github.com/pacocoursey/next-themes#themeprovider) package. |
6696

67-
## MDX Components [!TODO]
97+
import Old from './old.mdx'
6898

69-
Provide custom [MDX components](https://mdxjs.com/table-of-components) to render
70-
the content. For example, you can use a custom `pre` component to render code
71-
blocks.
99+
{process.env.NODE_ENV !== 'production' && <Old />}
72100

73101
| | | | |
74102
| ------------ | -------------------------- | --- | ---------------------- |
@@ -79,9 +107,9 @@ blocks.
79107
Show an "Edit this page" link on the page that points to the file URL on GitHub
80108
(or other places).
81109

82-
| | | | |
83-
| ---------- | ------------------------------------- | ----------------------- | ------------------------- |
84-
| `editLink` | `string \| ReactElement \| null{:ts}` | `'Edit this page'{:ts}` | Content of the edit link. |
110+
| | | | |
111+
| ---------- | ---------------- | ----------------------- | ------------------------- |
112+
| `editLink` | `ReactNode{:ts}` | `'Edit this page'{:ts}` | Content of the edit link. |
85113

86114
> [!TIP]
87115
>
@@ -94,10 +122,10 @@ documentation. By default, it's a link that points to the issue creation form of
94122
the docs repository, with the current website title prefilled:
95123
[example](https://github.com/shuding/nextra/issues/new?title=Feedback%20for%20%E2%80%9CTheme%20Configuration%E2%80%9D&labels=feedback).
96124

97-
| | | | |
98-
| ------------------ | ------------------------------------- | ----------------------------------- | -------------------------------------------------- |
99-
| `feedback.content` | `string \| ReactElement \| null{:ts}` | `'Question? Give us feedback'{:ts}` | Content of the feedback link. |
100-
| `feedback.labels` | `string{:ts}` | `'feedback'{:ts}` | Labels that can be added to the new created issue. |
125+
| | | | |
126+
| ------------------ | ---------------- | ----------------------------------- | -------------------------------------------------- |
127+
| `feedback.content` | `ReactNode{:ts}` | `'Question? Give us feedback'{:ts}` | Content of the feedback link. |
128+
| `feedback.labels` | `string{:ts}` | `'feedback'{:ts}` | Labels that can be added to the new created issue. |
101129

102130
> [!TIP]
103131
>
@@ -152,8 +180,6 @@ The above is also equivalent to `navigation: true{:js}`.
152180

153181
<ToggleVisibilitySection element="navigation" property="pagination" />
154182

155-
## Page Map [!TODO]
156-
157183
## Sidebar
158184

159185
| | | | |
@@ -243,17 +269,17 @@ You are able to customize the option names for localization or other purposes:
243269
</Layout>
244270
```
245271

246-
## Table of Contents (TOC) Sidebar
272+
## Table of Contents (TOC)
247273

248274
Show a table of contents on the right side of the page. It's useful for
249275
navigating between headings.
250276

251-
| | | | |
252-
| ---------------- | ------------------------------------- | ---------------------- | -------------------------------------------- |
253-
| toc.backToTop | `string \| ReactElement \| null{:ts}` | `'Scroll to top'{:ts}` | Text of back to top button. |
254-
| toc.extraContent | `string \| ReactElement \| null{:ts}` | | Display extra content below the TOC content. |
255-
| toc.float | `boolean{:ts}` | `true{:ts}` | Float the TOC next to the content. |
256-
| toc.title | `string \| ReactElement \| null{:ts}` | `'On This Page'{:ts}` | Title of the TOC sidebar. |
277+
| | | | |
278+
| ---------------- | ---------------- | ---------------------- | -------------------------------------------- |
279+
| toc.backToTop | `ReactNode{:ts}` | `'Scroll to top'{:ts}` | Text of back to top button. |
280+
| toc.extraContent | `ReactNode{:ts}` | | Display extra content below the TOC content. |
281+
| toc.float | `boolean{:ts}` | `true{:ts}` | Float the TOC next to the content. |
282+
| toc.title | `ReactNode{:ts}` | `'On This Page'{:ts}` | Title of the TOC sidebar. |
257283

258284
### Floating TOC
259285

Diff for: ‎docs/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"@svgr/webpack": "^8.0.1",
2323
"@tailwindcss/postcss": "^4.0.0-beta.8",
2424
"@types/node": "^22.0.0",
25-
"@types/react": "^19.0.10",
25+
"@types/react": "^19.0.12",
2626
"next-sitemap": "^4.2.3",
2727
"pagefind": "^1.3.0",
2828
"tailwindcss": "^4.0.0-beta.8"

Diff for: ‎examples/custom-theme/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"react-dom": "18.3.1"
1717
},
1818
"devDependencies": {
19-
"@types/react": "^19.0.10",
19+
"@types/react": "^19.0.12",
2020
"pagefind": "^1.3.0",
2121
"typescript": "^5.7.3"
2222
}

Diff for: ‎packages/nextra-theme-blog/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@tailwindcss/cli": "^4.0.0-beta.8",
3939
"@tailwindcss/postcss": "^4.0.0-beta.8",
4040
"@tailwindcss/typography": "^0.5.15",
41-
"@types/react": "^19.0.10",
41+
"@types/react": "^19.0.12",
4242
"esbuild-react-compiler-plugin": "workspace:*",
4343
"next": "^15.0.2",
4444
"nextra": "workspace:*",

Diff for: ‎packages/nextra-theme-docs/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"@tailwindcss/cli": "^4.0.0-beta.8",
4545
"@tailwindcss/postcss": "^4.0.0-beta.8",
4646
"@testing-library/react": "^16.0.0",
47-
"@types/react": "^19.0.10",
47+
"@types/react": "^19.0.12",
4848
"@vitejs/plugin-react": "^4.3.4",
4949
"esbuild-react-compiler-plugin": "workspace:*",
5050
"jsdom": "^26.0.0",

Diff for: ‎packages/nextra-theme-docs/src/components/navbar/index.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import { fromZodError } from 'zod-validation-error'
1010
import { ClientNavbar } from './index.client'
1111

1212
const propsSchema = z.strictObject({
13-
children: reactNode.optional(),
13+
children: reactNode,
1414
logoLink: z.union([z.string(), z.boolean()]).default(true),
1515
logo: element,
1616
projectLink: z.string().optional(),
17-
projectIcon: element.default(<GitHubIcon height="24" />),
17+
projectIcon: reactNode.default(<GitHubIcon height="24" />),
1818
chatLink: z.string().optional(),
19-
chatIcon: element.default(<DiscordIcon width="24" />),
19+
chatIcon: reactNode.default(<DiscordIcon width="24" />),
2020
className: z.string().optional(),
2121
align: z.enum(['left', 'right']).default('right')
2222
})

Diff for: ‎packages/nextra-theme-docs/src/components/sidebar.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ export const MobileNav: FC = () => {
363363

364364
let lastScrollPosition = 0
365365

366-
const handleScrollEnd: ComponentProps<'div'>['onScroll'] = event => {
366+
const handleScrollEnd: ComponentProps<'div'>['onScrollEnd'] = event => {
367367
lastScrollPosition = event.currentTarget.scrollTop
368368
}
369369

@@ -435,7 +435,6 @@ export const Sidebar: FC = () => {
435435
!isExpanded && 'no-scrollbar'
436436
)}
437437
ref={sidebarRef}
438-
// @ts-expect-error -- false positive https://github.com/DefinitelyTyped/DefinitelyTyped/pull/72078
439438
onScrollEnd={handleScrollEnd} // eslint-disable-line react/no-unknown-property
440439
>
441440
{/* without !hideSidebar check <Collapse />'s inner.clientWidth on `layout: "raw"` will be 0 and element will not have width on initial loading */}

Diff for: ‎packages/nextra-theme-docs/src/layout.tsx

+10-12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint sort-keys: error */
22
import { ThemeProvider } from 'next-themes'
33
import { Search, SkipNavLink } from 'nextra/components'
4-
import { element, stringOrElement } from 'nextra/schemas'
4+
import { element, reactNode } from 'nextra/schemas'
55
import type { FC, ReactNode } from 'react'
66
import { Fragment } from 'react'
77
import { z } from 'zod'
@@ -15,22 +15,20 @@ const attributeSchema = z.custom<'class' | `data-${string}`>(
1515
)
1616

1717
const theme = z.strictObject({
18-
banner: element.optional(),
18+
banner: reactNode,
1919
darkMode: z.boolean().default(true),
2020
docsRepositoryBase: z
2121
.string()
2222
.startsWith('https://')
2323
.default('https://github.com/shuding/nextra'),
24-
editLink: stringOrElement.or(z.null()).default('Edit this page'),
24+
editLink: reactNode.default('Edit this page'),
2525
feedback: z
2626
.strictObject({
27-
content: stringOrElement
28-
.or(z.null())
29-
.default('Question? Give us feedback'),
27+
content: reactNode.default('Question? Give us feedback'),
3028
labels: z.string().default('feedback')
3129
})
3230
.default({}),
33-
footer: element,
31+
footer: reactNode,
3432
i18n: z
3533
.array(
3634
z.strictObject({
@@ -55,7 +53,7 @@ import { Layout, LastUpdated } from 'nextra-theme-docs'
5553
\`\`\`
5654
`
5755
}),
58-
navbar: element,
56+
navbar: reactNode,
5957
navigation: z
6058
.union([
6159
z.boolean(),
@@ -78,7 +76,7 @@ import { Layout, LastUpdated } from 'nextra-theme-docs'
7876
})
7977
.default({}),
8078
pageMap: z.array(z.any({})),
81-
search: z.union([element, z.null()]).default(<Search />),
79+
search: reactNode.default(<Search />),
8280
sidebar: z
8381
.strictObject({
8482
autoCollapse: z.boolean().optional(),
@@ -96,10 +94,10 @@ import { Layout, LastUpdated } from 'nextra-theme-docs'
9694
.default({}),
9795
toc: z
9896
.strictObject({
99-
backToTop: stringOrElement.or(z.null()).default('Scroll to top'),
100-
extraContent: stringOrElement.optional(),
97+
backToTop: reactNode.default('Scroll to top'),
98+
extraContent: reactNode,
10199
float: z.boolean().default(true),
102-
title: stringOrElement.default('On This Page')
100+
title: reactNode.default('On This Page')
103101
})
104102
.default({})
105103
})

Diff for: ‎packages/nextra-theme-docs/src/stores/config.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { PageMapItem } from 'nextra'
44
import { useFSRoute } from 'nextra/hooks'
55
import { normalizePages } from 'nextra/normalize-pages'
6-
import type { FC, ReactElement, ReactNode } from 'react'
6+
import type { FC, ReactNode } from 'react'
77
import { createContext, useContext } from 'react'
88

99
const ConfigContext = createContext<ReturnType<typeof normalizePages> | null>(
@@ -25,8 +25,8 @@ export function useConfig() {
2525
export const ConfigProvider: FC<{
2626
children: ReactNode
2727
pageMap: PageMapItem[]
28-
navbar: ReactElement
29-
footer: ReactElement
28+
navbar: ReactNode
29+
footer: ReactNode
3030
}> = ({ children, pageMap, navbar, footer }) => {
3131
const pathname = useFSRoute()
3232

Diff for: ‎packages/nextra/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@
117117
"@types/hast": "^3.0.4",
118118
"@types/mdast": "^4.0.4",
119119
"@types/negotiator": "^0.6.3",
120-
"@types/react": "^19.0.10",
120+
"@types/react": "^19.0.12",
121121
"@types/webpack": "^5.28.5",
122122
"@vitejs/plugin-react": "^4.3.4",
123123
"esbuild-plugin-svgr": "^3.1.0",

Diff for: ‎packages/nextra/src/server/schemas.ts

+25-6
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,35 @@ export const nextraConfigSchema = z.strictObject({
7070

7171
export const element = z.custom<ReactElement<Record<string, unknown>>>(
7272
isValidElement,
73-
{ message: 'Must be React.ReactElement' }
73+
{ message: 'Must be a valid React element' }
7474
)
75+
/**
76+
* https://react.dev/reference/react/isValidElement#react-elements-vs-react-nodes
77+
*/
7578
export const reactNode = z.custom<ReactNode>(
76-
data =>
77-
isValidElement(data) ||
78-
(Array.isArray(data) && data.every(value => isValidElement(value))),
79-
{ message: 'Must be React.ReactNode' }
79+
function checkReactNode(data): data is ReactNode {
80+
if (
81+
// Check if it's a valid React element
82+
isValidElement(data) ||
83+
// Check if it's null or undefined
84+
data == null ||
85+
typeof data === 'string' ||
86+
typeof data === 'number' ||
87+
typeof data === 'boolean'
88+
) {
89+
return true
90+
}
91+
// Check if it's an array of React nodes
92+
if (Array.isArray(data)) {
93+
return data.every(item => checkReactNode(item))
94+
}
95+
// If it's none of the above, it's not a valid React node
96+
return false
97+
},
98+
{ message: 'Must be a valid React node' }
8099
)
81100

82-
export const stringOrElement = z.union([z.string(), element])
101+
const stringOrElement = z.union([z.string(), element])
83102

84103
const pageThemeSchema = z.strictObject({
85104
breadcrumb: z.boolean().optional(),

Diff for: ‎pnpm-lock.yaml

+20-20
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.