From 6310fdc503268c626bd3e03b0018984e70f67d00 Mon Sep 17 00:00:00 2001 From: Cody Olsen Date: Mon, 21 Oct 2024 15:31:51 +0200 Subject: [PATCH 1/5] chore: release `@sanity/next-loader` --- packages/@repo/env/index.ts | 2 +- .../live/PresentationComlink.tsx | 2 - .../client-components/live/RefreshOnFocus.tsx | 2 - .../client-components/live/RefreshOnMount.tsx | 2 - .../live/RefreshOnReconnect.tsx | 10 +---- .../src/client-components/live/SanityLive.tsx | 44 +++++++++++++------ packages/next-loader/src/defineLive.tsx | 42 +++++++++++++----- .../next-loader/src/server-actions/index.ts | 18 ++++---- .../src/parsePreviewUrl.test.ts | 11 ++++- .../preview-url-secret/src/parsePreviewUrl.ts | 9 +++- packages/preview-url-secret/src/types.ts | 7 +++ .../src/validatePreviewUrl.ts | 3 +- 12 files changed, 98 insertions(+), 54 deletions(-) diff --git a/packages/@repo/env/index.ts b/packages/@repo/env/index.ts index f48d6ed01..8d66c57a0 100644 --- a/packages/@repo/env/index.ts +++ b/packages/@repo/env/index.ts @@ -15,7 +15,7 @@ export const datasets = { 'blog': 'blog', } as const -export const apiVersion = 'X' as const +export const apiVersion = '2024-10-21' as const export const workspaces = { 'astro': { diff --git a/packages/next-loader/src/client-components/live/PresentationComlink.tsx b/packages/next-loader/src/client-components/live/PresentationComlink.tsx index 6c64c2d6a..ea3574f6a 100644 --- a/packages/next-loader/src/client-components/live/PresentationComlink.tsx +++ b/packages/next-loader/src/client-components/live/PresentationComlink.tsx @@ -39,8 +39,6 @@ function PresentationComlink(props: { setPerspectiveCookie(perspective) .then(() => { if (signal.aborted) return - // eslint-disable-next-line no-console - console.log('refresh on perspective change', perspective) router.refresh() }) // eslint-disable-next-line no-console diff --git a/packages/next-loader/src/client-components/live/RefreshOnFocus.tsx b/packages/next-loader/src/client-components/live/RefreshOnFocus.tsx index c0dd4fd9e..f7aeee9a6 100644 --- a/packages/next-loader/src/client-components/live/RefreshOnFocus.tsx +++ b/packages/next-loader/src/client-components/live/RefreshOnFocus.tsx @@ -12,8 +12,6 @@ export default function RefreshOnFocus(): null { const callback = () => { const now = Date.now() if (now > nextFocusRevalidatedAt && document.visibilityState !== 'hidden') { - // eslint-disable-next-line no-console - console.log('refreshing on focus') router.refresh() nextFocusRevalidatedAt = now + focusThrottleInterval } diff --git a/packages/next-loader/src/client-components/live/RefreshOnMount.tsx b/packages/next-loader/src/client-components/live/RefreshOnMount.tsx index 1196e242a..9890f066f 100644 --- a/packages/next-loader/src/client-components/live/RefreshOnMount.tsx +++ b/packages/next-loader/src/client-components/live/RefreshOnMount.tsx @@ -15,8 +15,6 @@ export default function RefreshOnMount(): null { useEffect(() => { if (!mounted) { mount() - // eslint-disable-next-line no-console - console.log('refreshing on mount') router.refresh() } }, [mounted, router]) diff --git a/packages/next-loader/src/client-components/live/RefreshOnReconnect.tsx b/packages/next-loader/src/client-components/live/RefreshOnReconnect.tsx index 80ee8ba4f..31d82d738 100644 --- a/packages/next-loader/src/client-components/live/RefreshOnReconnect.tsx +++ b/packages/next-loader/src/client-components/live/RefreshOnReconnect.tsx @@ -7,15 +7,7 @@ export default function RefreshOnReconnect(): null { useEffect(() => { const controller = new AbortController() const {signal} = controller - window.addEventListener( - 'online', - () => { - // eslint-disable-next-line no-console - console.log('refreshing on reconnect') - router.refresh() - }, - {passive: true, signal}, - ) + window.addEventListener('online', () => router.refresh(), {passive: true, signal}) return () => controller.abort() }, [router]) diff --git a/packages/next-loader/src/client-components/live/SanityLive.tsx b/packages/next-loader/src/client-components/live/SanityLive.tsx index 3fe20d20f..152a2f96f 100644 --- a/packages/next-loader/src/client-components/live/SanityLive.tsx +++ b/packages/next-loader/src/client-components/live/SanityLive.tsx @@ -1,7 +1,15 @@ -import {createClient, type ClientPerspective, type InitializedClientConfig} from '@sanity/client' +import { + createClient, + type ClientPerspective, + type InitializedClientConfig, + type LiveEventMessage, + type LiveEventRestart, +} from '@sanity/client' import {revalidateSyncTags} from '@sanity/next-loader/server-actions' import dynamic from 'next/dynamic' +import {useRouter} from 'next/navigation.js' import {useEffect, useMemo, useRef, useState} from 'react' +import {useEffectEvent} from 'use-effect-event' import {setEnvironment, setPerspective} from '../../hooks/context' const PresentationComlink = dynamic(() => import('./PresentationComlink'), {ssr: false}) @@ -103,6 +111,9 @@ export function SanityLive(props: SanityLiveProps): React.JSX.Element | null { ], ) + /** + * 2. Validate CORS before setting up the Event Source for the Server Sent Events + */ useEffect(() => { // @TODO move this validation logic to `@sanity/client` // and include CORS detection https://github.com/sanity-io/sanity/blob/9848f2069405e5d06f82a61a902f141e53099493/packages/sanity/src/core/store/_legacy/authStore/createAuthStore.ts#L92-L102 @@ -168,26 +179,31 @@ export function SanityLive(props: SanityLiveProps): React.JSX.Element | null { return () => controller.abort() }, [tag, client, requestTagPrefix, token]) + /** + * 3. Handle Live Events and call revalidateTag or router.refresh when needed + */ + const router = useRouter() + const handleLiveEvent = useEffectEvent((event: LiveEventMessage | LiveEventRestart) => { + if (event.type === 'message') { + revalidateSyncTags(event.tags) + } else if (event.type === 'restart') { + router.refresh() + } + }) useEffect(() => { const subscription = client.live.events({includeDrafts: !!token, tag}).subscribe({ next: (event) => { - if (event.type === 'message') { - revalidateSyncTags(event.tags) - } else if (event.type === 'reconnect') { - // eslint-disable-next-line no-console - console.log('TODO: handle reconnect') - } else if (event.type === 'restart') { - // eslint-disable-next-line no-console - console.log('TODO: handle restart') + if (event.type === 'message' || event.type === 'restart') { + handleLiveEvent(event) } }, error: setError, }) return () => subscription.unsubscribe() - }, [client, tag, token]) + }, [client.live, handleLiveEvent, tag, token]) /** - * 2. Notify what perspective we're in, when in Draft Mode + * 4. Notify what perspective we're in, when in Draft Mode */ useEffect(() => { if (draftModeEnabled && draftModePerspective) { @@ -199,7 +215,7 @@ export function SanityLive(props: SanityLiveProps): React.JSX.Element | null { const [loadComlink, setLoadComlink] = useState(false) /** - * 3. Notify what environment we're in, when in Draft Mode + * 5. Notify what environment we're in, when in Draft Mode */ useEffect(() => { if (draftModeEnabled && loadComlink) { @@ -212,7 +228,7 @@ export function SanityLive(props: SanityLiveProps): React.JSX.Element | null { }, [draftModeEnabled, loadComlink, token]) /** - * 4. If Presentation Tool is detected, load up the comlink and integrate with it + * 6. If Presentation Tool is detected, load up the comlink and integrate with it */ useEffect(() => { if (window === parent && !opener) return @@ -238,7 +254,7 @@ export function SanityLive(props: SanityLiveProps): React.JSX.Element | null { }, []) /** - * 5. Warn if draft mode is being disabled + * 7. Warn if draft mode is being disabled * @TODO move logic into PresentationComlink, or maybe VisualEditing? */ const draftModeEnabledWarnRef = useRef | undefined>(undefined) diff --git a/packages/next-loader/src/defineLive.tsx b/packages/next-loader/src/defineLive.tsx index cb4ec73e9..50048f853 100644 --- a/packages/next-loader/src/defineLive.tsx +++ b/packages/next-loader/src/defineLive.tsx @@ -126,8 +126,19 @@ export interface DefineSanityLiveOptions { * @public */ export function defineLive(config: DefineSanityLiveOptions): { + /** + * Use this function to fetch data from Sanity in your React Server Components. + * @public + */ sanityFetch: DefinedSanityFetchType + /** + * Render this in your root layout.tsx to make your page revalidate on new content live, automatically. + * @public + */ SanityLive: React.ComponentType + /** + * @alpha experimental, it may change or even be removed at any time + */ SanityLiveStream: DefinedSanityLiveStreamType // verifyPreviewSecret: VerifyPreviewSecretType } { @@ -137,14 +148,14 @@ export function defineLive(config: DefineSanityLiveOptions): { throw new Error('`client` is required for `defineLive` to function') } - if (!serverToken) { + if (process.env.NODE_ENV !== 'production' && !serverToken) { // eslint-disable-next-line no-console console.warn( 'No `serverToken` provided to `defineLive`. This means that only published content will be fetched and respond to live events', ) } - if (!browserToken) { + if (process.env.NODE_ENV !== 'production' && !browserToken) { // eslint-disable-next-line no-console console.warn( 'No `browserToken` provided to `defineLive`. This means that live previewing drafts will only work when using the Presentation Tool in your Sanity Studio. To support live previewing drafts stand-alone, provide a `browserToken`. It is shared with the browser so it should only have Viewer rights or lower', @@ -237,8 +248,21 @@ export function defineLive(config: DefineSanityLiveOptions): { refreshOnReconnect, tag = 'next-loader.live', } = props - const {projectId, dataset, apiHost, apiVersion, useProjectHostname, requestTagPrefix} = - client.config() + const { + projectId, + dataset, + apiHost, + apiVersion: _apiVersion, + useProjectHostname, + requestTagPrefix, + } = client.config() + const {isEnabled: isDraftModeEnabled} = await draftMode() + + let apiVersion = _apiVersion + // @TODO temporarily handle the Live Draft Content API only being available on vX + if (typeof browserToken === 'string' && isDraftModeEnabled) { + apiVersion = 'vX' + } return ( { - 'use server' - await Promise.allSettled([ - (await draftMode()).disable(), - // Simulate a delay to show the loading state - new Promise((resolve) => setTimeout(resolve, 1000)), - ]) -} +// export async function disableDraftMode(): Promise { +// 'use server' +// await Promise.allSettled([ +// (await draftMode()).disable(), +// // Simulate a delay to show the loading state +// new Promise((resolve) => setTimeout(resolve, 1000)), +// ]) +// } export async function revalidateSyncTags(tags: SyncTag[]): Promise { for (const _tag of tags) { const tag = `sanity:${_tag}` await revalidateTag(tag) // eslint-disable-next-line no-console - console.log(`Revalidated tag: ${tag}`) + console.log(` revalidated tag: ${tag}`) } } diff --git a/packages/preview-url-secret/src/parsePreviewUrl.test.ts b/packages/preview-url-secret/src/parsePreviewUrl.test.ts index 03f95ee2e..5e9404b19 100644 --- a/packages/preview-url-secret/src/parsePreviewUrl.test.ts +++ b/packages/preview-url-secret/src/parsePreviewUrl.test.ts @@ -1,5 +1,9 @@ import {expect, test} from 'vitest' -import {urlSearchParamPreviewPathname, urlSearchParamPreviewSecret} from './constants' +import { + urlSearchParamPreviewPathname, + urlSearchParamPreviewPerspective, + urlSearchParamPreviewSecret, +} from './constants' import {parsePreviewUrl} from './parsePreviewUrl' test('handles absolute URLs', () => { @@ -9,6 +13,7 @@ test('handles absolute URLs', () => { expect(parsePreviewUrl(unsafe.toString())).toEqual({ redirectTo: '/preview?foo=bar', secret: 'abc123', + studioPreviewPerspective: null, }) }) @@ -16,9 +21,11 @@ test('handles relative URLs', () => { const unsafe = new URL('/api/draft', 'http://localhost') unsafe.searchParams.set(urlSearchParamPreviewSecret, 'abc123') unsafe.searchParams.set(urlSearchParamPreviewPathname, '/preview?foo=bar') + unsafe.searchParams.set(urlSearchParamPreviewPerspective, 'published') expect(parsePreviewUrl(`${unsafe.pathname}${unsafe.search}`)).toEqual({ redirectTo: '/preview?foo=bar', secret: 'abc123', + studioPreviewPerspective: 'published', }) }) @@ -29,6 +36,7 @@ test('includes hash', () => { expect(parsePreviewUrl(unsafe.toString())).toEqual({ redirectTo: '/preview?foo=bar#heading1', secret: 'abc123', + studioPreviewPerspective: null, }) }) @@ -42,5 +50,6 @@ test('strips origin from redirect', () => { expect(parsePreviewUrl(unsafe.toString())).toEqual({ redirectTo: '/preview?foo=bar', secret: 'abc123', + studioPreviewPerspective: null, }) }) diff --git a/packages/preview-url-secret/src/parsePreviewUrl.ts b/packages/preview-url-secret/src/parsePreviewUrl.ts index 230d06d2c..ea257644d 100644 --- a/packages/preview-url-secret/src/parsePreviewUrl.ts +++ b/packages/preview-url-secret/src/parsePreviewUrl.ts @@ -1,4 +1,8 @@ -import {urlSearchParamPreviewPathname, urlSearchParamPreviewSecret} from './constants' +import { + urlSearchParamPreviewPathname, + urlSearchParamPreviewPerspective, + urlSearchParamPreviewSecret, +} from './constants' import type {ParsedPreviewUrl} from './types' /** @@ -10,11 +14,12 @@ export function parsePreviewUrl(unsafeUrl: string): ParsedPreviewUrl { if (!secret) { throw new Error('Missing secret') } + const studioPreviewPerspective = url.searchParams.get(urlSearchParamPreviewPerspective) let redirectTo = undefined const unsafeRedirectTo = url.searchParams.get(urlSearchParamPreviewPathname) if (unsafeRedirectTo) { const {pathname, search, hash} = new URL(unsafeRedirectTo, 'http://localhost') redirectTo = `${pathname}${search}${hash}` } - return {secret, redirectTo} + return {secret, redirectTo, studioPreviewPerspective} } diff --git a/packages/preview-url-secret/src/types.ts b/packages/preview-url-secret/src/types.ts index 987edc1d6..cb344fa5f 100644 --- a/packages/preview-url-secret/src/types.ts +++ b/packages/preview-url-secret/src/types.ts @@ -49,12 +49,19 @@ export interface PreviewUrlValidateUrlResult { * If the URL is valid, and the studio URL is known and valid, then its origin will be here */ studioOrigin?: string + /** + * The initial perspective the Studio was using when starting to load the preview. + * It can change over time and should also be handled with `postMessage` listeners. + * The value can be arbitrary and has to be validated to make sure it's a valid perspective. + */ + studioPreviewPerspective?: string | null } /** @internal */ export interface ParsedPreviewUrl { secret: string redirectTo?: string + studioPreviewPerspective: string | null } /** @public */ diff --git a/packages/preview-url-secret/src/validatePreviewUrl.ts b/packages/preview-url-secret/src/validatePreviewUrl.ts index 0a7aaca68..39d69601d 100644 --- a/packages/preview-url-secret/src/validatePreviewUrl.ts +++ b/packages/preview-url-secret/src/validatePreviewUrl.ts @@ -37,6 +37,7 @@ export async function validatePreviewUrl( disableCacheNoStore, ) const redirectTo = isValid ? parsedPreviewUrl.redirectTo : undefined + const studioPreviewPerspective = isValid ? parsedPreviewUrl.studioPreviewPerspective : undefined let studioOrigin: string | undefined if (isValid) { try { @@ -52,7 +53,7 @@ export async function validatePreviewUrl( } } - return {isValid, redirectTo, studioOrigin} + return {isValid, redirectTo, studioOrigin, studioPreviewPerspective} } export type {PreviewUrlValidateUrlResult, SanityClientLike} From 7bf63fbb3067d38ecb4845dc7fb59d6c35cb8c43 Mon Sep 17 00:00:00 2001 From: Cody Olsen <81981+stipsan@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:30:15 +0200 Subject: [PATCH 2/5] Update parsePreviewUrl.test.ts --- .../preview-url-secret/src/parsePreviewUrl.test.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/preview-url-secret/src/parsePreviewUrl.test.ts b/packages/preview-url-secret/src/parsePreviewUrl.test.ts index 5e9404b19..03f95ee2e 100644 --- a/packages/preview-url-secret/src/parsePreviewUrl.test.ts +++ b/packages/preview-url-secret/src/parsePreviewUrl.test.ts @@ -1,9 +1,5 @@ import {expect, test} from 'vitest' -import { - urlSearchParamPreviewPathname, - urlSearchParamPreviewPerspective, - urlSearchParamPreviewSecret, -} from './constants' +import {urlSearchParamPreviewPathname, urlSearchParamPreviewSecret} from './constants' import {parsePreviewUrl} from './parsePreviewUrl' test('handles absolute URLs', () => { @@ -13,7 +9,6 @@ test('handles absolute URLs', () => { expect(parsePreviewUrl(unsafe.toString())).toEqual({ redirectTo: '/preview?foo=bar', secret: 'abc123', - studioPreviewPerspective: null, }) }) @@ -21,11 +16,9 @@ test('handles relative URLs', () => { const unsafe = new URL('/api/draft', 'http://localhost') unsafe.searchParams.set(urlSearchParamPreviewSecret, 'abc123') unsafe.searchParams.set(urlSearchParamPreviewPathname, '/preview?foo=bar') - unsafe.searchParams.set(urlSearchParamPreviewPerspective, 'published') expect(parsePreviewUrl(`${unsafe.pathname}${unsafe.search}`)).toEqual({ redirectTo: '/preview?foo=bar', secret: 'abc123', - studioPreviewPerspective: 'published', }) }) @@ -36,7 +29,6 @@ test('includes hash', () => { expect(parsePreviewUrl(unsafe.toString())).toEqual({ redirectTo: '/preview?foo=bar#heading1', secret: 'abc123', - studioPreviewPerspective: null, }) }) @@ -50,6 +42,5 @@ test('strips origin from redirect', () => { expect(parsePreviewUrl(unsafe.toString())).toEqual({ redirectTo: '/preview?foo=bar', secret: 'abc123', - studioPreviewPerspective: null, }) }) From 2f997eb2b7916e0f8aa313698b24adce394dad1b Mon Sep 17 00:00:00 2001 From: Cody Olsen <81981+stipsan@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:30:43 +0200 Subject: [PATCH 3/5] Update validatePreviewUrl.ts --- packages/preview-url-secret/src/validatePreviewUrl.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/preview-url-secret/src/validatePreviewUrl.ts b/packages/preview-url-secret/src/validatePreviewUrl.ts index 39d69601d..0a7aaca68 100644 --- a/packages/preview-url-secret/src/validatePreviewUrl.ts +++ b/packages/preview-url-secret/src/validatePreviewUrl.ts @@ -37,7 +37,6 @@ export async function validatePreviewUrl( disableCacheNoStore, ) const redirectTo = isValid ? parsedPreviewUrl.redirectTo : undefined - const studioPreviewPerspective = isValid ? parsedPreviewUrl.studioPreviewPerspective : undefined let studioOrigin: string | undefined if (isValid) { try { @@ -53,7 +52,7 @@ export async function validatePreviewUrl( } } - return {isValid, redirectTo, studioOrigin, studioPreviewPerspective} + return {isValid, redirectTo, studioOrigin} } export type {PreviewUrlValidateUrlResult, SanityClientLike} From b4a1085df1fd588503766151fdc65f51e0721327 Mon Sep 17 00:00:00 2001 From: Cody Olsen <81981+stipsan@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:31:38 +0200 Subject: [PATCH 4/5] Update types.ts --- packages/preview-url-secret/src/types.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/preview-url-secret/src/types.ts b/packages/preview-url-secret/src/types.ts index cb344fa5f..987edc1d6 100644 --- a/packages/preview-url-secret/src/types.ts +++ b/packages/preview-url-secret/src/types.ts @@ -49,19 +49,12 @@ export interface PreviewUrlValidateUrlResult { * If the URL is valid, and the studio URL is known and valid, then its origin will be here */ studioOrigin?: string - /** - * The initial perspective the Studio was using when starting to load the preview. - * It can change over time and should also be handled with `postMessage` listeners. - * The value can be arbitrary and has to be validated to make sure it's a valid perspective. - */ - studioPreviewPerspective?: string | null } /** @internal */ export interface ParsedPreviewUrl { secret: string redirectTo?: string - studioPreviewPerspective: string | null } /** @public */ From fc45071569365cc3dba176be48872d901fabd1fc Mon Sep 17 00:00:00 2001 From: Cody Olsen <81981+stipsan@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:32:04 +0200 Subject: [PATCH 5/5] Update parsePreviewUrl.ts --- packages/preview-url-secret/src/parsePreviewUrl.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/preview-url-secret/src/parsePreviewUrl.ts b/packages/preview-url-secret/src/parsePreviewUrl.ts index ea257644d..230d06d2c 100644 --- a/packages/preview-url-secret/src/parsePreviewUrl.ts +++ b/packages/preview-url-secret/src/parsePreviewUrl.ts @@ -1,8 +1,4 @@ -import { - urlSearchParamPreviewPathname, - urlSearchParamPreviewPerspective, - urlSearchParamPreviewSecret, -} from './constants' +import {urlSearchParamPreviewPathname, urlSearchParamPreviewSecret} from './constants' import type {ParsedPreviewUrl} from './types' /** @@ -14,12 +10,11 @@ export function parsePreviewUrl(unsafeUrl: string): ParsedPreviewUrl { if (!secret) { throw new Error('Missing secret') } - const studioPreviewPerspective = url.searchParams.get(urlSearchParamPreviewPerspective) let redirectTo = undefined const unsafeRedirectTo = url.searchParams.get(urlSearchParamPreviewPathname) if (unsafeRedirectTo) { const {pathname, search, hash} = new URL(unsafeRedirectTo, 'http://localhost') redirectTo = `${pathname}${search}${hash}` } - return {secret, redirectTo, studioPreviewPerspective} + return {secret, redirectTo} }