Releases: sanity-io/next-sanity
next-sanity@13.0.0-cache-components.59
Major Changes
-
#3536
7c18db6Thanks @stipsan! - Replace therevalidateSyncTagsprop on<SanityLive>withactionThe prop was initially introduced to allow overriding the default cache invalidation behavior when a live event was received. Back then the
revalidateTagfunction did not have an second argument that allows configuring the revalidatino behavior, and we did not have support for Invalidate Sync Tags Functions.
Both platforms have evolved, and the namerevalidateSyncTagsis no longer accurate, and its default behavior is not ideal.This API change is only a breaking change for you if you either:
- Passed a custom function to
revalidateSyncTagson<SanityLive> - Rely on the default behavior of how cache tags were invalidated when a live event was received
The default behavior that you might have relied on is that when a live event was received:
- if
draftMode.isEnabledis false then the cache tags were invalidated withupdateTag - if
draftMode.isEnabledis true then the cache tags were invalidated withrevalidateTagwith themaxcache profile
In practice this meant that if you published a change to your nextjs app while at least one visitor was connected to
<SanityLive>and not in draft mode, then they would be guaranteed to see the change within a few seconds.
If you were in Presentation Tool or otherwise in draft mode, and nobody else observed the change, then they would eventually see the change if they manually refresh the page a couple of times, or something else triggered arefresh()event after the cache is updated. This is a side-effect of usingrevalidateTagwith themaxcache profile instead ofupdateTag, and was a change we made in #3432 to avoid the impact of the Next.js regression reported by Sanity to Vercel in vercel/next.js#93210 asdraftModegenerally implies that<SanityLive>will enableincludeDraftsand thus see live events for draft content, and not just published content, and thus receive far more events than if you were not in draft mode.The new behavior further mitiates the impact we've seen in vercel/next.js#93210 by:
- using
revalidateTagwith themaxcache profile instead ofupdateTag - when in draft mode, we don't
updateTagnorrevalidateTagat all, we just callrefresh()
In practice this means that by default content changes are no longer guaranteed to be seen by all visitors within a few seconds, they may need to refresh, or trigger a navigation event, before the new content is visible. This makes the default experience "less live", and is a trade-off we're making until nextjs addresses the regression reported in vercel/next.js#93210 and gives us a path to guaranteed live content updates pushed to all visitors that is also cost efficient.
Opting in to guaranteed live content updates
The recommended way to opt in to guaranteed live content updates is to implement a Invalidate Sync Tags Function (example) that calls a
/api/revalidate-tagsendpoint in your app (example).We recommend using
revalidateTagwith themaxcache profile to invalidate the cache tags, and use dedicated client components if you want to drive specific experiences that have to be real-time (example), but if you want to have the same instant experience asnext-sanity@12you can set{expire: 0}in your route handler:export async function POST(request: Request) { const { tags } = (await request.json()) as { tags?: string[] }; for (const tag of tags) { // `sanityFetch` returned by `defineLive` from `next-sanity/live` prefixes its `cacheTag` calls with `sanity:`, so we need to add the same prefix here revalidateTag(`sanity:${tag}`, { expire: 0 }); } return Response.json({ revalidated: tags }); }
You then set
waitFor="function"on your<SanityLive>component, when you are on a deployment that also handles incoming events to/api/revalidate-tags:export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> {children} <SanityLive waitFor={ process.env.VERCEL_ENV === "production" ? "function" : undefined } /> </body> </html> ); }
Restore the previous default behavior
If you want to restore the previous default behavior without implementing an Invalidate Sync Tags Function, you may do so by setting a custom
actionprop to<SanityLive>:import { revalidateTag, updateTag } from "next/cache"; import { draftMode } from "next/headers"; import { parseTags } from "next-sanity/live"; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> {children} <SanityLive action={async (unsafeTags) => { "use server"; const { isEnabled: isDraftMode } = await draftMode(); const { tags } = parseTags(unsafeTags); const logTags: string[] = []; for (const tag of tags) { if (isDraftMode) { revalidateTag(tag, "max"); } else { updateTag(tag); } logTags.push(tag); } console.log( `<SanityLive /> ${ isDraftMode ? `revalidated tags: ${logTags.join( ", " )} with cache profile "max" ` : `updated tags: ${logTags.join( ", " )} and revalidated tag: "sanity:fetch-sync-tags" with cache profile "max"` }` ); if (isDraftMode) { return "refresh"; } }} /> </body> </html> ); }
waitFor="function" no longer ignores custom actions
Previously setting
waitFor="function"would ignore any customrevalidateSyncTagsfunction and always callrouter.refresh()internally.
That's not the case withactionanymore, so if your implementation looks like this today:export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> {children} <SanityLive revalidateSyncTags={async (tags) => { "use server"; console.log( "this only logs if `waitFor` is not set to `function`", { tags } ); }} waitFor={ process.env.VERCEL_ENV === "production" ? "function" : undefined } /> </body> </html> ); }
You need to conditionally set
actionto your own function, or the string'refresh', if you want it to work the same way, asactionis always respected:import { parseTags } from "next-sanity/live"; export default function RootLayout({ children, }: { children: React.ReactNode; }) { const isProduction = process.env.VERCEL_ENV === "production"; return ( <html lang="en"> <body> {children} <SanityLive action={ isProduction ? "refresh" : async (unsafeTags) => { "use server"; const { tagsWithoutPrefix } = parseTags(unsafeTags); console.log( "this only logs if `waitFor` is not set to `function`", { tags } ); } } waitFor={isProduction ? "function" : undefined} /> </body> </html> ); }
- Passed a custom function to
Patch Changes
next-sanity@13.0.0-cache-components.58
next-sanity@13.0.0-cache-components.57
Major Changes
-
#3527
3572c9aThanks @stipsan! - Remove thestegaoption fromdefineLiveWhen the
clientgiven todefineLivehas astega.studioUrlconfigured, anddraftMode().isEnabledistrue, thensanityFetchcalls would usetrueas the default value for itsstegaoption.To opt-out of
stegabeing set by default in draft mode, you have 3 options:- Do not define
stega.studioUrlin theclientconfig - Set
stega: falsein thesanityFetchcall itself - Set
stega: falsein thedefineLivecall.
With this change you no longer have option 3, and you have to use option 2 or 1.
- Do not define
-
#3526
4ea57cbThanks @stipsan! - Remove the deprecatedfetchOptionsoption fromdefineLiveThis option was used to set a time-based revalidation as a fallback strategy for when content might change in the dataset without an active browser session connected to
<SanityLive>, thus making the cached content stale.
The downside to this approach was that time-based revalidation ended up causing many unnecessary ISR writes, since they trigger based on a fixed interval rather than on the actual content changes.Now that we have Invalidate Sync Tags support in Sanity Functions, the preferred fallback approach is to use it to call a
/api/revalidate-tagsendpoint in your app so that the content is eventually fresh even if the content change happened without an active browser session connected to<SanityLive>.
12.4.5
Patch Changes
next-sanity@13.0.0-cache-components.56
Major Changes
-
#3518
ee00392Thanks @stipsan! - Throw errors during render ifonErroris not defined on<SanityLive>If you were already handling errors in your own
onErrorcallback, then this is not a breaking change for you.The new behavior is to avoid silent failures and instead throw errors during render so they can be caught by the nearest React error boundary.
Restore previous behavior
The previous behavior would
console.warnorconsole.errorthe error, and continue rendering the component. If you prefer this behavior you can restore it this way:Create a
client-functions.tsfile with'use client'and export aonErrorfunction like so:// app/client-functions.ts "use client"; import { isCorsOriginError, type SanityLiveOnError } from "next-sanity/live"; export const onError: SanityLiveOnError = ( error, { includeDrafts, waitFor } ) => { if (isCorsOriginError(error)) { console.warn( `Sanity Live is unable to connect to the Sanity API as the current origin - ${window.origin} - is not in the list of allowed CORS origins for this Sanity Project.`, error.addOriginUrl && `Add it here:`, error.addOriginUrl?.toString() ); } else { console.error("Sanity Live encountered an error:", error, { includeDrafts, waitFor, }); } };
Then in your
layout.tsxfile, import theonErrorfunction and pass it to<SanityLive>:// app/layout.tsx import { onError } from "./client-functions"; import { SanityLive } from "#sanity/live"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <> {children} <SanityLive onError={onError} /> </> ); }
Customize the error boundary
Using the unstable_catchError API you can create an error boundary that can handle errors and offer retry logic. Here's an example that uses the
sonnerlibrary to show error toasts that adapt to the error type:// app/SanityLiveErrorBoundary.tsx "use client"; import { isCorsOriginError } from "next-sanity/live"; import { unstable_catchError, type ErrorInfo } from "next/error"; import { useEffect } from "react"; import { toast } from "sonner"; function SanityLiveErrorBoundary( _props: {}, { error, unstable_retry }: ErrorInfo ) { useEffect(() => { let toastId: string | number | undefined; if (isCorsOriginError(error)) { const { addOriginUrl } = error; toastId = toast.warning(`Sanity Live couldn't connect`, { description: `${ new URL(window.origin).host } is blocked by CORS policy`, richColors: true, duration: Infinity, action: addOriginUrl ? { label: "Manage", onClick: (event) => { event.preventDefault(); window.open(addOriginUrl.toString(), "_blank"); }, } : { label: "Retry", onClick: () => unstable_retry() }, cancel: addOriginUrl ? { label: "Retry", onClick: () => unstable_retry() } : undefined, }); } else if (error instanceof Error) { console.error(error); toastId = toast.error(error.message, { richColors: true, duration: Infinity, action: { label: "Retry", onClick: () => unstable_retry() }, }); } else { console.error(error); toastId = toast.error("Unknown error", { description: "Check the console for more details", richColors: true, duration: Infinity, action: { label: "Retry", onClick: () => unstable_retry() }, }); } return () => { toast.dismiss(toastId); }; }, [error, unstable_retry]); return null; } export default unstable_catchError(SanityLiveErrorBoundary);
Then in your
layout.tsxfile wrap theSanityLivecomponent in the error boundary:// app/layout.tsx import SanityLiveErrorBoundary from "./SanityLiveErrorBoundary"; import { SanityLive } from "#sanity/live"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <> {children} <SanityLiveErrorBoundary> <SanityLive /> </SanityLiveErrorBoundary> </> ); }
-
#3518
ee00392Thanks @stipsan! - Renamed 3 type exports onnext-sanity/live:DefinedSanityFetchTypetoDefinedFetchTypeDefinedSanityLivePropstoDefinedLivePropsDefineSanityLiveOptionstoDefineLiveOptions
12.4.5
Patch Changes
next-sanity@13.0.0-cache-components.55
Minor Changes
-
#3511
e05f69dThanks @stipsan! - AddonReconnect&onRestartprops on<SanityLive>, by default they callrouter.refresh() -
#3515
2096572Thanks @stipsan! - AddonWelcomeprop to<SanityLive>The default behavior is to log a welcome message to the console, here's how to customize it:
// app/client-functions.ts "use client"; import type { SanityLiveOnWelcome } from "next-sanity/live"; export const onWelcome: SanityLiveOnWelcome = ( event, { includeDrafts, waitFor } ) => { console.info( `<SanityLive${ includeDrafts ? " includeDrafts" : "" }> is connected and listening for live events to ${ includeDrafts ? "all content including drafts and version documents in content releases" : "published content" }.${ waitFor === "function" ? " Events will be delayed until after a Sanity Function has processed them." : "" }` ); };
// app/layout.tsx import { onWelcome } from "./client-functions"; import { SanityLive } from "#sanity/live"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <> {children} <SanityLive onWelcome={onWelcome} /> </> ); }
To disable default welcome message, pass
onWelcome={false}.
12.4.5
Patch Changes
next-sanity@13.0.0-cache-components.54
next-sanity@13.0.0-cache-components.53
next-sanity@13.0.0-cache-components.52
Patch Changes
-
#3109
a4b1edeThanks @stipsan! - Simplify Sanity Live cache tag handling to use a singlecacheTagPrefixacross published and draft flows.parseTags()now follows the single-prefix model and includestagsWithoutPrefixas a convenience in the parsed result. -
#3109
fbdeeefThanks @stipsan! - No longer auto refresh in when editing documents
next-sanity@13.0.0-cache-components.51
Major Changes
-
#3493
0f1d162Thanks @stipsan! - RemoverefreshOnMountprop from<SanityLive>This prop was
falseby default, so if you weren't using it you won't be affected by this change.If you were using it, here's how you can add back the same functionality:
Create a new
RefreshOnMountcomponent:// app/RefreshOnMount.tsx import { useRouter } from "next/navigation"; import { useEffect, useReducer, startTransition } from "react"; /** * Handles refreshing the page when the page is mounted, * in case the content changes at a high enough frequency that by * the time the page started streaming, and the <SanityLive> component sets * up the EventSource connection, content might have changed. */ export function RefreshOnMount() { const router = useRouter(); const [mounted, mount] = useReducer(() => true, false); useEffect(() => { if (!mounted) { startTransition(() => { mount(); router.refresh(); }); } }, [mounted, router]); return null; }
Then update your layout to include it:
// app/layout.tsx import {SanityLive} from '#sanity/live' +import {DebugStatus} from './RefreshOnMount' export default function Layout({children}: {children: React.ReactNode}) { return ( <> {children} - <SanityLive refreshOnMount /> + <SanityLive /> + <RefreshOnMount /> </> ) } -
#3495
4630270Thanks @stipsan! - RemoverefreshOnFocusprop from<SanityLive>This prop was enabled by default for published content in top-level windows, so if you relied on it you can add it back like this:
Create a new
RefreshOnFocuscomponent:// app/RefreshOnFocus.tsx "use client"; import { useRouter } from "next/navigation"; import { startTransition, useEffect } from "react"; const focusThrottleInterval = 5_000; export function RefreshOnFocus() { const router = useRouter(); useEffect(() => { // If inside an iframe then don't refresh on focus if (window.self !== window.top) return; const controller = new AbortController(); let nextFocusRevalidatedAt = 0; const callback = () => { const now = Date.now(); if ( now > nextFocusRevalidatedAt && document.visibilityState !== "hidden" ) { startTransition(() => router.refresh()); nextFocusRevalidatedAt = now + focusThrottleInterval; } }; const { signal } = controller; document.addEventListener("visibilitychange", callback, { passive: true, signal, }); window.addEventListener("focus", callback, { passive: true, signal }); return () => controller.abort(); }, [router]); return null; }
Then update your layout to include it:
// app/layout.tsx import {SanityLive} from '#sanity/live' +import {RefreshOnFocus} from './RefreshOnFocus' export default function Layout({children}: {children: React.ReactNode}) { return ( <> {children} <SanityLive /> + <RefreshOnFocus /> </> ) }The motivation for removing this feature is that most users saw this as unexpected behavior, especially since when using browser debug tools window focus events trigger often and paired with how v16 behaves differently with link prefetching and the
router.refresh()call it's bedt to remove it. -
#3494
3ad0422Thanks @stipsan! - RemoverefreshOnReconnectprop from<SanityLive>It was removed as Next.js itself is working first class support for handling network connectivity changes, it can already be tested using
experimental.useOffline, and so it no longer makes sense for us to implement it ourselves.This prop was
trueby default, and only used when not in draft mode, so if you relied on this behavior you can add it back like this:Create a new
RefreshOnReconnectcomponent:// app/RefreshOnReconnect.tsx "use client"; import { useRouter } from "next/navigation"; import { startTransition, useEffect } from "react"; export function RefreshOnReconnect() { const router = useRouter(); useEffect(() => { const controller = new AbortController(); const { signal } = controller; window.addEventListener( "online", () => startTransition(() => router.refresh()), { passive: true, signal, } ); return () => controller.abort(); }, [router]); return null; }
Then update your layout to include it:
// app/layout.tsx import {SanityLive} from '#sanity/live' +import {RefreshOnReconnect} from './RefreshOnReconnect' export default async function Layout({children}: {children: React.ReactNode}) { const isDraftMode = (await draftMode()).isEnabled return ( <> {children} <SanityLive includeDrafts={isDraftMode} /> + {!isDraftMode && <RefreshOnReconnect />} </> ) }