|
1 | 1 | # @portabletext/react |
2 | 2 |
|
| 3 | +## 7.0.0 |
| 4 | + |
| 5 | +### Major Changes |
| 6 | + |
| 7 | +- [#333](https://github.com/portabletext/react-portabletext/pull/333) [`87a9b1e`](https://github.com/portabletext/react-portabletext/commit/87a9b1ec0a83638ad523d53dd41501333281fb93) Thanks [@stipsan](https://github.com/stipsan)! - `<PortableText>` now ships as two builds selected by export conditions: the `default` build is optimized with [React Compiler](https://react.dev/learn/react-compiler) auto-memoization, and an uncompiled build serves the `react-server` condition, since React Server Components refuse to load compiled output ([facebook/react#31702](https://github.com/facebook/react/issues/31702)): |
| 8 | + |
| 9 | + ```json |
| 10 | + ".": { |
| 11 | + "types": "./dist/index.d.ts", |
| 12 | + "react-server": "./dist/index.react-server.js", |
| 13 | + "default": "./dist/index.js" |
| 14 | + } |
| 15 | + ``` |
| 16 | + |
| 17 | + There are no API changes. Client components and SSR get the compiled build, with finer-grained memoization than the manual `useMemo` calls it replaces, and server components render the same source uncompiled — they render exactly once, so memoization can never pay off there. |
| 18 | + |
| 19 | + **BREAKING**: React 19 is now required (`peerDependencies.react` is `^19`) — the compiled build loads `react/compiler-runtime`, which only exists in React 19. Stay on `@portabletext/react@6` if you are on React 18. |
| 20 | + |
3 | 21 | ## 6.2.0 |
4 | 22 |
|
5 | 23 | ### Minor Changes |
|
9 | 27 | `<PortableText>` now infers the shape of every component handler from the `value` prop. When you pass a value typed by [Sanity TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen), `components.types`, `components.marks`, `components.block`, `components.list`, and `components.listItem` all receive precise `value` props for the exact content the query returned. |
10 | 28 |
|
11 | 29 | Three new utility types ship with this feature: |
| 30 | + |
12 | 31 | - `InferComponents<T>` - same inference as the inline `components` prop, for hoisting components out of JSX. |
13 | 32 | - `InferStrictComponents<T>` - strict variant that requires a handler for every inferred custom type, mark, block style, and list style, and rejects handlers that aren't in the schema (and therefore not visible to TypeGen). |
14 | 33 | - `InferValue<T>` - derives a Portable Text array value type from any TypeGen query result type, useful for re-usable wrapper components. |
|
19 | 38 |
|
20 | 39 | ```ts |
21 | 40 | // sanity.config.ts |
22 | | - import {defineArrayMember, defineConfig, defineField, defineType} from 'sanity' |
| 41 | + import { |
| 42 | + defineArrayMember, |
| 43 | + defineConfig, |
| 44 | + defineField, |
| 45 | + defineType, |
| 46 | + } from "sanity"; |
23 | 47 |
|
24 | 48 | export default defineConfig({ |
25 | | - name: 'default', |
26 | | - projectId: 'abc123', |
27 | | - dataset: 'production', |
| 49 | + name: "default", |
| 50 | + projectId: "abc123", |
| 51 | + dataset: "production", |
28 | 52 | schema: { |
29 | 53 | types: [ |
30 | 54 | defineType({ |
31 | | - name: 'post', |
32 | | - type: 'document', |
| 55 | + name: "post", |
| 56 | + type: "document", |
33 | 57 | fields: [ |
34 | | - defineField({name: 'title', type: 'string'}), |
| 58 | + defineField({ name: "title", type: "string" }), |
35 | 59 | defineField({ |
36 | | - name: 'content', |
37 | | - type: 'array', |
| 60 | + name: "content", |
| 61 | + type: "array", |
38 | 62 | of: [ |
39 | | - defineArrayMember({type: 'block'}), |
| 63 | + defineArrayMember({ type: "block" }), |
40 | 64 | defineArrayMember({ |
41 | | - type: 'image', |
42 | | - options: {hotspot: true}, |
43 | | - fields: [defineField({name: 'alt', type: 'string'})], |
| 65 | + type: "image", |
| 66 | + options: { hotspot: true }, |
| 67 | + fields: [defineField({ name: "alt", type: "string" })], |
44 | 68 | }), |
45 | 69 | ], |
46 | 70 | }), |
47 | 71 | ], |
48 | 72 | }), |
49 | 73 | ], |
50 | 74 | }, |
51 | | - }) |
| 75 | + }); |
52 | 76 | ``` |
53 | 77 |
|
54 | 78 | #### Before: hand-typing handlers |
|
57 | 81 |
|
58 | 82 | ```tsx |
59 | 83 | // app/[slug]/page.tsx |
60 | | - import {createClient} from '@sanity/client' |
61 | | - import {createImageUrlBuilder} from '@sanity/image-url' |
62 | | - import {PortableText} from '@portabletext/react' |
63 | | - import {defineQuery} from 'groq' |
| 84 | + import { createClient } from "@sanity/client"; |
| 85 | + import { createImageUrlBuilder } from "@sanity/image-url"; |
| 86 | + import { PortableText } from "@portabletext/react"; |
| 87 | + import { defineQuery } from "groq"; |
64 | 88 |
|
65 | 89 | const client = createClient({ |
66 | | - projectId: 'abc123', |
67 | | - dataset: 'production', |
| 90 | + projectId: "abc123", |
| 91 | + dataset: "production", |
68 | 92 | useCdn: true, |
69 | | - apiVersion: '2026-05-04', |
70 | | - }) |
71 | | - const builder = createImageUrlBuilder(client) |
| 93 | + apiVersion: "2026-05-04", |
| 94 | + }); |
| 95 | + const builder = createImageUrlBuilder(client); |
72 | 96 |
|
73 | | - export default async function Page({slug}: {slug: string}) { |
74 | | - const query = defineQuery(`*[_type == "post" && slug.current == $slug][0]{title,content}`) |
75 | | - const data = await client.fetch(query, {slug}) |
| 97 | + export default async function Page({ slug }: { slug: string }) { |
| 98 | + const query = defineQuery( |
| 99 | + `*[_type == "post" && slug.current == $slug][0]{title,content}` |
| 100 | + ); |
| 101 | + const data = await client.fetch(query, { slug }); |
76 | 102 |
|
77 | | - if (!data) return notFound() |
| 103 | + if (!data) return notFound(); |
78 | 104 |
|
79 | 105 | return ( |
80 | 106 | <article> |
|
87 | 113 | }: { |
88 | 114 | value: { |
89 | 115 | asset?: { |
90 | | - _ref: string |
91 | | - _type: 'reference' |
92 | | - _weak?: boolean |
93 | | - } |
| 116 | + _ref: string; |
| 117 | + _type: "reference"; |
| 118 | + _weak?: boolean; |
| 119 | + }; |
94 | 120 | hotspot?: { |
95 | | - _type: 'sanity.imageHotspot' |
96 | | - x?: number |
97 | | - y?: number |
98 | | - height?: number |
99 | | - width?: number |
100 | | - } |
| 121 | + _type: "sanity.imageHotspot"; |
| 122 | + x?: number; |
| 123 | + y?: number; |
| 124 | + height?: number; |
| 125 | + width?: number; |
| 126 | + }; |
101 | 127 | crop?: { |
102 | | - _type: 'sanity.imageCrop' |
103 | | - top?: number |
104 | | - bottom?: number |
105 | | - left?: number |
106 | | - right?: number |
107 | | - } |
108 | | - alt?: string |
109 | | - _type: 'image' |
110 | | - _key: string |
111 | | - } |
112 | | - }) => <img src={builder.image(value).url()} alt={value.alt || ''} />, |
| 128 | + _type: "sanity.imageCrop"; |
| 129 | + top?: number; |
| 130 | + bottom?: number; |
| 131 | + left?: number; |
| 132 | + right?: number; |
| 133 | + }; |
| 134 | + alt?: string; |
| 135 | + _type: "image"; |
| 136 | + _key: string; |
| 137 | + }; |
| 138 | + }) => ( |
| 139 | + <img src={builder.image(value).url()} alt={value.alt || ""} /> |
| 140 | + ), |
113 | 141 | }, |
114 | 142 | }} |
115 | 143 | value={data.content} |
116 | 144 | /> |
117 | 145 | </article> |
118 | | - ) |
| 146 | + ); |
119 | 147 | } |
120 | 148 | ``` |
121 | 149 |
|
|
125 | 153 |
|
126 | 154 | ```tsx |
127 | 155 | // app/[slug]/page.tsx |
128 | | - import {createClient} from '@sanity/client' |
129 | | - import {createImageUrlBuilder} from '@sanity/image-url' |
130 | | - import {PortableText} from '@portabletext/react' |
131 | | - import {defineQuery} from 'groq' |
| 156 | + import { createClient } from "@sanity/client"; |
| 157 | + import { createImageUrlBuilder } from "@sanity/image-url"; |
| 158 | + import { PortableText } from "@portabletext/react"; |
| 159 | + import { defineQuery } from "groq"; |
132 | 160 |
|
133 | 161 | const client = createClient({ |
134 | | - projectId: 'abc123', |
135 | | - dataset: 'production', |
| 162 | + projectId: "abc123", |
| 163 | + dataset: "production", |
136 | 164 | useCdn: true, |
137 | | - apiVersion: '2026-05-04', |
138 | | - }) |
139 | | - const builder = createImageUrlBuilder(client) |
| 165 | + apiVersion: "2026-05-04", |
| 166 | + }); |
| 167 | + const builder = createImageUrlBuilder(client); |
140 | 168 |
|
141 | | - export default async function Page({slug}: {slug: string}) { |
142 | | - const query = defineQuery(`*[_type == "post" && slug.current == $slug][0]{title,content}`) |
143 | | - const data = await client.fetch(query, {slug}) |
| 169 | + export default async function Page({ slug }: { slug: string }) { |
| 170 | + const query = defineQuery( |
| 171 | + `*[_type == "post" && slug.current == $slug][0]{title,content}` |
| 172 | + ); |
| 173 | + const data = await client.fetch(query, { slug }); |
144 | 174 |
|
145 | | - if (!data) return notFound() |
| 175 | + if (!data) return notFound(); |
146 | 176 |
|
147 | 177 | return ( |
148 | 178 | <article> |
|
151 | 181 | components={{ |
152 | 182 | types: { |
153 | 183 | // value is fully typed from the query result, no annotation needed |
154 | | - image: ({value}) => <img src={builder.image(value).url()} alt={value.alt || ''} />, |
| 184 | + image: ({ value }) => ( |
| 185 | + <img src={builder.image(value).url()} alt={value.alt || ""} /> |
| 186 | + ), |
155 | 187 | }, |
156 | 188 | }} |
157 | 189 | value={data.content} |
158 | 190 | /> |
159 | 191 | </article> |
160 | | - ) |
| 192 | + ); |
161 | 193 | } |
162 | 194 | ``` |
163 | 195 |
|
|
167 | 199 |
|
168 | 200 | ```tsx |
169 | 201 | // app/[slug]/page.tsx |
170 | | - import {createClient} from '@sanity/client' |
171 | | - import {createImageUrlBuilder} from '@sanity/image-url' |
172 | | - import {PortableText, type InferComponents} from '@portabletext/react' |
173 | | - import {defineQuery} from 'groq' |
| 202 | + import { createClient } from "@sanity/client"; |
| 203 | + import { createImageUrlBuilder } from "@sanity/image-url"; |
| 204 | + import { PortableText, type InferComponents } from "@portabletext/react"; |
| 205 | + import { defineQuery } from "groq"; |
174 | 206 |
|
175 | 207 | const client = createClient({ |
176 | | - projectId: 'abc123', |
177 | | - dataset: 'production', |
| 208 | + projectId: "abc123", |
| 209 | + dataset: "production", |
178 | 210 | useCdn: true, |
179 | | - apiVersion: '2026-05-04', |
180 | | - }) |
181 | | - const builder = createImageUrlBuilder(client) |
| 211 | + apiVersion: "2026-05-04", |
| 212 | + }); |
| 213 | + const builder = createImageUrlBuilder(client); |
182 | 214 |
|
183 | | - export default async function Page({slug}: {slug: string}) { |
184 | | - const query = defineQuery(`*[_type == "post" && slug.current == $slug][0]{title,content}`) |
185 | | - const data = await client.fetch(query, {slug}) |
| 215 | + export default async function Page({ slug }: { slug: string }) { |
| 216 | + const query = defineQuery( |
| 217 | + `*[_type == "post" && slug.current == $slug][0]{title,content}` |
| 218 | + ); |
| 219 | + const data = await client.fetch(query, { slug }); |
186 | 220 |
|
187 | | - if (!data) return notFound() |
| 221 | + if (!data) return notFound(); |
188 | 222 |
|
189 | 223 | const components = { |
190 | 224 | types: { |
191 | | - image: ({value}) => <img src={builder.image(value).url()} alt={value.alt || ''} />, |
| 225 | + image: ({ value }) => ( |
| 226 | + <img src={builder.image(value).url()} alt={value.alt || ""} /> |
| 227 | + ), |
192 | 228 | }, |
193 | | - } satisfies InferComponents<typeof data.content> |
| 229 | + } satisfies InferComponents<typeof data.content>; |
194 | 230 |
|
195 | 231 | return ( |
196 | 232 | <article> |
197 | 233 | <h1>{data.title}</h1> |
198 | 234 | <PortableText components={components} value={data.content} /> |
199 | 235 | </article> |
200 | | - ) |
| 236 | + ); |
201 | 237 | } |
202 | 238 | ``` |
203 | 239 |
|
|
207 | 243 |
|
208 | 244 | ```tsx |
209 | 245 | // app/[slug]/page.tsx |
210 | | - import {createClient, type SanityQueries} from '@sanity/client' |
211 | | - import {createImageUrlBuilder} from '@sanity/image-url' |
212 | | - import {PortableText, type InferStrictComponents, type InferValue} from '@portabletext/react' |
213 | | - import {defineQuery} from 'groq' |
| 246 | + import { createClient, type SanityQueries } from "@sanity/client"; |
| 247 | + import { createImageUrlBuilder } from "@sanity/image-url"; |
| 248 | + import { |
| 249 | + PortableText, |
| 250 | + type InferStrictComponents, |
| 251 | + type InferValue, |
| 252 | + } from "@portabletext/react"; |
| 253 | + import { defineQuery } from "groq"; |
214 | 254 |
|
215 | 255 | const client = createClient({ |
216 | | - projectId: 'abc123', |
217 | | - dataset: 'production', |
| 256 | + projectId: "abc123", |
| 257 | + dataset: "production", |
218 | 258 | useCdn: true, |
219 | | - apiVersion: '2026-05-04', |
220 | | - }) |
221 | | - const builder = createImageUrlBuilder(client) |
| 259 | + apiVersion: "2026-05-04", |
| 260 | + }); |
| 261 | + const builder = createImageUrlBuilder(client); |
222 | 262 |
|
223 | 263 | // Array value type for every Portable Text item shape across all registered queries. |
224 | | - type PortableTextValue = InferValue<SanityQueries[keyof SanityQueries]> |
| 264 | + type PortableTextValue = InferValue<SanityQueries[keyof SanityQueries]>; |
225 | 265 |
|
226 | | - function CustomPortableText({value}: {value: PortableTextValue}) { |
| 266 | + function CustomPortableText({ value }: { value: PortableTextValue }) { |
227 | 267 | const components = { |
228 | 268 | types: { |
229 | | - image: ({value}) => <img src={builder.image(value).url()} alt={value.alt || ''} />, |
| 269 | + image: ({ value }) => ( |
| 270 | + <img src={builder.image(value).url()} alt={value.alt || ""} /> |
| 271 | + ), |
230 | 272 | }, |
231 | | - } satisfies InferStrictComponents<PortableTextValue> |
| 273 | + } satisfies InferStrictComponents<PortableTextValue>; |
232 | 274 | // ^ TypeScript errors when the schema gains a custom type, mark, or list |
233 | 275 | // style without a matching handler defined here |
234 | 276 |
|
235 | | - return <PortableText components={components} value={value} /> |
| 277 | + return <PortableText components={components} value={value} />; |
236 | 278 | } |
237 | 279 |
|
238 | | - export default async function Page({slug}: {slug: string}) { |
239 | | - const query = defineQuery(`*[_type == "post" && slug.current == $slug][0]{title,content}`) |
240 | | - const data = await client.fetch(query, {slug}) |
| 280 | + export default async function Page({ slug }: { slug: string }) { |
| 281 | + const query = defineQuery( |
| 282 | + `*[_type == "post" && slug.current == $slug][0]{title,content}` |
| 283 | + ); |
| 284 | + const data = await client.fetch(query, { slug }); |
241 | 285 |
|
242 | | - if (!data) return notFound() |
| 286 | + if (!data) return notFound(); |
243 | 287 |
|
244 | 288 | return ( |
245 | 289 | <article> |
246 | 290 | <h1>{data.title}</h1> |
247 | | - {Array.isArray(data.content) && <CustomPortableText value={data.content} />} |
| 291 | + {Array.isArray(data.content) && ( |
| 292 | + <CustomPortableText value={data.content} /> |
| 293 | + )} |
248 | 294 | </article> |
249 | | - ) |
| 295 | + ); |
250 | 296 | } |
251 | 297 | ``` |
252 | 298 |
|
|
0 commit comments