From 96d82f2873d358353102181a10913d34949d645f Mon Sep 17 00:00:00 2001 From: Cody Olsen Date: Wed, 8 Nov 2023 15:45:32 +0100 Subject: [PATCH] feat: support SSR-only mode --- apps/remix/app/VisualEditing.tsx | 5 +- apps/remix/app/routes/_index.tsx | 76 ------- apps/remix/app/routes/shoes.$slug.tsx | 55 ++--- apps/remix/app/routes/shoes._index.tsx | 57 ++--- apps/remix/app/sanity.loader.server.ts | 6 + apps/remix/app/sanity.loader.ts | 8 + apps/remix/app/sanity.ts | 55 +++-- apps/remix/app/useQuery.ts | 6 - apps/remix/app/utils.ts | 30 --- packages/core-loader/src/env.ts | 2 + packages/core-loader/src/index.ts | 207 ++++++++++++------ .../src/live-mode/enableLiveMode.ts | 29 ++- packages/core-loader/src/live-mode/index.ts | 24 +- packages/core-loader/src/types.ts | 16 +- packages/react-loader/src/index.ts | 15 +- 15 files changed, 307 insertions(+), 284 deletions(-) create mode 100644 apps/remix/app/sanity.loader.server.ts create mode 100644 apps/remix/app/sanity.loader.ts delete mode 100644 apps/remix/app/useQuery.ts delete mode 100644 apps/remix/app/utils.ts create mode 100644 packages/core-loader/src/env.ts diff --git a/apps/remix/app/VisualEditing.tsx b/apps/remix/app/VisualEditing.tsx index 92b1f5b36..68502a2d4 100644 --- a/apps/remix/app/VisualEditing.tsx +++ b/apps/remix/app/VisualEditing.tsx @@ -2,7 +2,8 @@ import { useLocation, useNavigate } from '@remix-run/react' import { enableOverlays, type HistoryUpdate } from '@sanity/overlays' import { studioUrl } from 'apps-common/env' import { useEffect, useRef } from 'react' -import { useLiveMode } from './useQuery' +import { useLiveMode } from '~/sanity.loader' +import { client } from '~/sanity' export default function VisualEditing() { const navigateRemix = useNavigate() @@ -41,7 +42,7 @@ export default function VisualEditing() { } }, [location.hash, location.pathname, location.search]) - useLiveMode({ allowStudioOrigin: studioUrl }) + useLiveMode({ allowStudioOrigin: studioUrl, client }) return null } diff --git a/apps/remix/app/routes/_index.tsx b/apps/remix/app/routes/_index.tsx index b3b9918fe..586d0c920 100644 --- a/apps/remix/app/routes/_index.tsx +++ b/apps/remix/app/routes/_index.tsx @@ -1,84 +1,8 @@ -import { studioUrl, workspaces } from 'apps-common/env' -import { vercelStegaCombine } from '@vercel/stega' import { Link } from '@remix-run/react' -import { encodeSanityNodeData } from '~/sanity' export default function Index() { return (
-

- encodeSanityNodeData -

-

- {vercelStegaCombine('vercelStegaCombine', { - origin: 'sanity.io', - href: `${studioUrl}/intent/edit/id=0e6fa235-3bd5-41cc-9f25-53dc0a5ff7d2;path=title`, - })} -

-

- encodeSanityNodeData but minimal -

-

- encodeSanityNodeData but slug -

-

- encodeSanityNodeData but slug.current -

-

- encodeSanityNodeData without a path -

-

- encodeSanityNodeData but details.lifespan -

-

- Cross Dataset Reference -


Shoes
diff --git a/apps/remix/app/routes/shoes.$slug.tsx b/apps/remix/app/routes/shoes.$slug.tsx index e0c0a4793..f49f708ea 100644 --- a/apps/remix/app/routes/shoes.$slug.tsx +++ b/apps/remix/app/routes/shoes.$slug.tsx @@ -1,18 +1,14 @@ import { PortableText } from '@portabletext/react' import { Link, useLoaderData, useParams } from '@remix-run/react' -import { unwrapData, wrapData, sanity } from '@sanity/react-loader/jsx' -import { studioUrl, workspaces } from 'apps-common/env' import { type ShoeParams, type ShoeResult, shoe } from 'apps-common/queries' import { formatCurrency } from 'apps-common/utils' -import { urlFor, urlForCrossDatasetReference } from '~/utils' -import { useMemo } from 'react' -import { query, useQuery } from '~/useQuery' +import { urlFor, urlForCrossDatasetReference } from '~/sanity' import { json, type LoaderFunction } from '@remix-run/node' +import { query } from '~/sanity.loader.server' +import { useQuery } from '~/sanity.loader' export const loader: LoaderFunction = async ({ params }) => { - return json({ - initialData: await query(shoe, params), - }) + return json(await query(shoe, params)) } export default function ShoePage() { @@ -23,12 +19,11 @@ export default function ShoePage() { throw new Error('No slug, 404?') } - const { initialData } = useLoaderData() + const { data: initialData } = useLoaderData() const { - data, + data: product, error, loading: _loading, - sourceMap, } = useQuery( shoe, { @@ -36,13 +31,7 @@ export default function ShoePage() { } satisfies ShoeParams, { initialData }, ) - const loading = !data && _loading - - const product = useMemo( - () => - wrapData({ ...workspaces['remix'], baseUrl: studioUrl }, data, sourceMap), - [data, sourceMap], - ) + const loading = !product && _loading if (error) { throw error @@ -82,7 +71,7 @@ export default function ShoePage() { > {loading ? 'Loading' - : {product?.title} || 'Untitled'} + : {product?.title} || 'Untitled'} @@ -94,13 +83,13 @@ export default function ShoePage() {
{coverImage.alt?.value
)} @@ -119,13 +108,13 @@ export default function ShoePage() { > {image.alt?.value ) @@ -137,19 +126,19 @@ export default function ShoePage() { {/* Product info */}
- {product.title} - +
{/* Options */}

Product information

- {product.price ? formatCurrency(product.price.value) : 'FREE'} + {product.price ? formatCurrency(product.price) : 'FREE'}

{product.brand?.name && ( @@ -160,23 +149,21 @@ export default function ShoePage() { className="h-10 w-10 rounded-full bg-gray-50" src={ product.brand?.logo?.asset - ? urlForCrossDatasetReference( - unwrapData(product.brand.logo), - ) + ? urlForCrossDatasetReference(product.brand.logo) .width(48) .height(48) .url() : `https://source.unsplash.com/featured/48x48?${encodeURIComponent( - product.brand.name.value, + product.brand.name, )}` } width={24} height={24} - alt={product.brand?.logo?.alt?.value || ''} + alt={product.brand?.logo?.alt || ''} /> - + {product.brand.name} - +
)} @@ -198,7 +185,7 @@ export default function ShoePage() {
{product.description ? ( - + ) : ( 'No description' )} diff --git a/apps/remix/app/routes/shoes._index.tsx b/apps/remix/app/routes/shoes._index.tsx index 6299cf1c2..483d30239 100644 --- a/apps/remix/app/routes/shoes._index.tsx +++ b/apps/remix/app/routes/shoes._index.tsx @@ -1,32 +1,28 @@ import { Link, useLoaderData } from '@remix-run/react' -import { unwrapData, wrapData, sanity } from '@sanity/react-loader/jsx' -import { studioUrl, workspaces } from 'apps-common/env' import { formatCurrency } from 'apps-common/utils' import { shoesList, type ShoesListResult } from 'apps-common/queries' -import { urlFor, urlForCrossDatasetReference } from '~/utils' -import { useMemo } from 'react' -import { useQuery, query } from '~/useQuery' import { json } from '@remix-run/node' +import { useQuery } from '~/sanity.loader' +import { query } from '~/sanity.loader.server' +import { urlFor, urlForCrossDatasetReference } from '~/sanity' export const loader = async () => { return json(await query(shoesList)) } export default function ShoesPage() { - const { data: initialData } = useLoaderData() + const { data: initialData, sourceMap: initialSourceMap } = + useLoaderData() const { - data, + data: products, error, loading: _loading, - sourceMap, - } = useQuery(shoesList, {}, { initialData }) - const loading = !data?.length && _loading - - const products = useMemo( - () => - wrapData({ ...workspaces['remix'], baseUrl: studioUrl }, data, sourceMap), - [data, sourceMap], + } = useQuery( + shoesList, + {}, + { initialData, initialSourceMap }, ) + const loading = !products?.length && _loading if (error) { throw error @@ -59,8 +55,8 @@ export default function ShoesPage() {
{products?.map?.((product, i) => (
@@ -68,27 +64,22 @@ export default function ShoesPage() { className="h-full w-full object-cover object-center group-hover:opacity-75" src={ product.media?.asset - ? urlFor(unwrapData(product.media)) - .width(1440) - .height(1440) - .url() + ? urlFor(product.media).width(1440).height(1440).url() : `https://source.unsplash.com/featured/720x720?shoes&r=${i}` } width={720} height={720} - alt={product.media?.alt?.value || ''} + alt={product.media?.alt || ''} />
- {product.title} - +

- {product.price?.value - ? formatCurrency(product.price.value) - : 'FREE'} + {product.price ? formatCurrency(product.price) : 'FREE'}

{product.brand && (
@@ -96,25 +87,23 @@ export default function ShoesPage() { className="h-6 w-6 rounded-full bg-gray-50" src={ product.brand?.logo?.asset - ? urlForCrossDatasetReference( - unwrapData(product.brand.logo), - ) + ? urlForCrossDatasetReference(product.brand.logo) .width(48) .height(48) .url() : `https://source.unsplash.com/featured/48x48?${ product.brand.name - ? encodeURIComponent(product.brand.name.value) + ? encodeURIComponent(product.brand.name) : `brand&r=${i}` }` } width={24} height={24} - alt={product.brand?.logo?.alt?.value || ''} + alt={product.brand?.logo?.alt || ''} /> - + {product.brand.name} - +
)} diff --git a/apps/remix/app/sanity.loader.server.ts b/apps/remix/app/sanity.loader.server.ts new file mode 100644 index 000000000..d7b6a40d1 --- /dev/null +++ b/apps/remix/app/sanity.loader.server.ts @@ -0,0 +1,6 @@ +import { client } from './sanity' +import { server__query, setServerClient } from './sanity.loader' + +setServerClient(client) + +export { server__query as query } diff --git a/apps/remix/app/sanity.loader.ts b/apps/remix/app/sanity.loader.ts new file mode 100644 index 000000000..93bbef054 --- /dev/null +++ b/apps/remix/app/sanity.loader.ts @@ -0,0 +1,8 @@ +import { createQueryStore } from '@sanity/react-loader' + +export const { + query: server__query, + useQuery, + setServerClient, + useLiveMode, +} = createQueryStore({ client: false, ssr: true }) diff --git a/apps/remix/app/sanity.ts b/apps/remix/app/sanity.ts index 654feebee..8516611f2 100644 --- a/apps/remix/app/sanity.ts +++ b/apps/remix/app/sanity.ts @@ -1,22 +1,39 @@ -import { studioUrl, workspaces } from 'apps-common/env' -import { - type SanityNode, - encodeSanityNodeData as _encodeSanityNodeData, -} from '@sanity/react-loader/jsx' +import { createClient } from '@sanity/client/stega' +import { apiVersion, studioUrl as baseUrl, workspaces } from 'apps-common/env' +import imageUrlBuilder from '@sanity/image-url' +const { projectId, dataset } = workspaces['remix'] -const { projectId, dataset, tool, workspace } = workspaces['remix'] +export const client = createClient({ + projectId, + dataset, + useCdn: false, + apiVersion, + stega: { + enabled: true, + studioUrl: (sourceDocument) => { + if ( + sourceDocument._projectId === + workspaces['cross-dataset-references'].projectId && + sourceDocument._dataset === + workspaces['cross-dataset-references'].dataset + ) { + const { workspace, tool } = workspaces['cross-dataset-references'] + return { baseUrl, workspace, tool } + } + return { baseUrl } + }, + }, +}) -// @TODO replace with the reused utils -export function encodeSanityNodeData( - node: Partial & Pick, -) { - return _encodeSanityNodeData({ - projectId, - dataset, - // @TODO temporary workaround as overlays fails to find the right workspace - baseUrl: `${studioUrl}/${workspace}`, - workspace, - tool, - ...node, - }) +const builder = imageUrlBuilder({ projectId, dataset }) +export function urlFor(source: any) { + return builder.image(source).auto('format').fit('max') +} + +const crossDatasetBuilder = imageUrlBuilder({ + projectId: workspaces['cross-dataset-references'].projectId, + dataset: workspaces['cross-dataset-references'].dataset, +}) +export function urlForCrossDatasetReference(source: any) { + return crossDatasetBuilder.image(source).auto('format').fit('max') } diff --git a/apps/remix/app/useQuery.ts b/apps/remix/app/useQuery.ts deleted file mode 100644 index d0f2bdf11..000000000 --- a/apps/remix/app/useQuery.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createQueryStore } from '@sanity/react-loader' -import { getClient } from '~/utils' - -const client = getClient() - -export const { query, useQuery, useLiveMode } = createQueryStore({ client }) diff --git a/apps/remix/app/utils.ts b/apps/remix/app/utils.ts deleted file mode 100644 index d2f0911d4..000000000 --- a/apps/remix/app/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createClient } from '@sanity/client/stega' -import { workspaces, studioUrl as baseUrl, apiVersion } from 'apps-common/env' -import imageUrlBuilder from '@sanity/image-url' -const { projectId, dataset } = workspaces['remix'] - -export function getClient() { - return createClient({ - projectId, - dataset, - useCdn: false, - apiVersion, - stega: { - enabled: true, - studioUrl: baseUrl, - }, - }) -} - -const builder = imageUrlBuilder({ projectId, dataset }) -export function urlFor(source: any) { - return builder.image(source).auto('format').fit('max') -} - -const crossDatasetBuilder = imageUrlBuilder({ - projectId: workspaces['cross-dataset-references'].projectId, - dataset: workspaces['cross-dataset-references'].dataset, -}) -export function urlForCrossDatasetReference(source: any) { - return crossDatasetBuilder.image(source).auto('format').fit('max') -} diff --git a/packages/core-loader/src/env.ts b/packages/core-loader/src/env.ts new file mode 100644 index 000000000..76ea1c59f --- /dev/null +++ b/packages/core-loader/src/env.ts @@ -0,0 +1,2 @@ +/** @internal */ +export const runtime = typeof document === 'undefined' ? 'server' : 'browser' diff --git a/packages/core-loader/src/index.ts b/packages/core-loader/src/index.ts index 252d1b920..109193a24 100644 --- a/packages/core-loader/src/index.ts +++ b/packages/core-loader/src/index.ts @@ -5,15 +5,9 @@ import type { } from '@sanity/client' import type { SanityStegaClient } from '@sanity/client/stega' import { type Cache, createCache } from 'async-cache-dedupe' -import { - atom, - computed, - map, - type MapStore, - onMount, - startTask, -} from 'nanostores' +import { atom, map, type MapStore, onMount, startTask } from 'nanostores' +import { runtime } from './env' import { defineEnableLiveMode } from './live-mode' import type { EnableLiveMode, Fetcher, QueryStoreState } from './types' @@ -24,7 +18,20 @@ export type { WritableAtom } from 'nanostores' /** @public */ export interface CreateQueryStoreOptions { - client: SanityClient | SanityStegaClient + /** + * The Sanity client to use for fetching data, or `false` if `ssr: true` and it's set with `setServerClient` later + * You may use any client that is an `instanceof SanityClient` or `instanceof SanityStegaClient`. + * @example `import {createClient} from '@sanity/client'` + * @example `import {createClient} from '@sanity/client/stega'` + * @example `import {createClient} from '@sanity/preview-kit/client'` + * @example `import {createClient} from 'next-sanity'` + */ + client: SanityClient | SanityStegaClient | false + /** + * If you want all data fetching to be done server-side in production, set this to `true` and `client: false`. + * Then, in your server entry file, you can set the Sanity client with `setServerClient`. + */ + ssr?: boolean } /** @public */ @@ -35,6 +42,11 @@ export interface QueryStore { initialData?: Response, initialSourceMap?: ContentSourceMap, ) => MapStore> + /** + * When `ssr: true` you call this in your server entry point that imports the result of `createQueryStore` instance. + * It's required to call it before any data fetching is done, and it can only be called once. + */ + setServerClient: (client: SanityClient | SanityStegaClient) => void enableLiveMode: EnableLiveMode /** @internal */ unstable__cache: Cache & { @@ -45,24 +57,44 @@ export interface QueryStore { } } +function cloneClientWithConfig( + newClient: SanityClient | SanityStegaClient, +): SanityClient | SanityStegaClient { + return newClient.withConfig({ + allowReconfigure: false, + perspective: 'published', + resultSourceMap: 'withKeyArraySelector', + }) +} + /** @public */ export const createQueryStore = ( options: CreateQueryStoreOptions, ): QueryStore => { - const { client } = options - const { projectId, dataset, resultSourceMap, perspective } = client.config() - if (!projectId) throw new Error('Missing projectId') - if (!dataset) throw new Error('Missing dataset') - if (!resultSourceMap) { - // Enable source maps if not already enabled - client.config({ resultSourceMap: 'withKeyArraySelector' }) + const { ssr = false } = options + if (ssr && options.client) { + throw new TypeError( + '`client` option is not allowed when `ssr: true`, use `setServerClient` from your server entry point instead', + ) } - // Handle the perspective setting, it has to be 'published' or 'previewDrafts' - if (perspective !== 'published' && perspective !== 'previewDrafts') { - client.config({ perspective: 'published' }) + if (!ssr && options.client === false) { + throw new TypeError( + `You must set \`ssr: true\` when \`client: false\` is used`, + ) + } + if (!ssr && !options.client) { + throw new TypeError(`\`client\` is required`) } + let client = ssr + ? undefined + : cloneClientWithConfig(options.client as SanityClient | SanityStegaClient) const cache = createCache().define('fetch', async (key: string) => { + if (!client) { + throw new Error( + `You have to set the Sanity client with \`setServerClient\` before any data fetching is done`, + ) + } const { query, params = {} } = JSON.parse(key) const { result, resultSourceMap } = await client.fetch(query, params, { filterResponse: false, @@ -70,76 +102,127 @@ export const createQueryStore = ( return { result, resultSourceMap } }) - const $defaultFetcher = atom({ - hydrate: (_query, _params, initialData, initialSourceMap) => ({ - loading: true, - error: undefined, - data: initialData, - sourceMap: initialSourceMap, - }), - fetch: (query, params, $fetch, controller) => { - if (controller.signal.aborted) return - - const finishTask = startTask() - - $fetch.setKey('loading', true) - $fetch.setKey('error', undefined) - cache - .fetch(JSON.stringify({ query, params })) - .then((response) => { - if (controller.signal.aborted) return - $fetch.setKey('data', response.result) - $fetch.setKey('sourceMap', response.resultSourceMap) - }) - .catch((reason) => { - $fetch.setKey('error', reason) - }) - .finally(() => { - $fetch.setKey('loading', false) - finishTask() - }) - }, - }) - const $liveModeFetcher = atom(undefined) - const $fetcher = computed( - [$liveModeFetcher, $defaultFetcher], - (liveModeFetcher, defaultFetcher) => liveModeFetcher || defaultFetcher, + let defaultFetcherCreated = false + function createDefaultFetcher(): Fetcher { + if (defaultFetcherCreated) { + throw new Error('Default fetcher can only be created once') + } + defaultFetcherCreated = true + + return { + hydrate: (_query, _params, initialData, initialSourceMap) => ({ + loading: true, + error: undefined, + data: initialData, + sourceMap: initialSourceMap, + }), + fetch: (query, params, $fetch, controller) => { + if (controller.signal.aborted) return + + const finishTask = startTask() + + $fetch.setKey('loading', true) + $fetch.setKey('error', undefined) + cache + .fetch(JSON.stringify({ query, params })) + .then((response) => { + if (controller.signal.aborted) return + $fetch.setKey('data', response.result) + $fetch.setKey('sourceMap', response.resultSourceMap) + }) + .catch((reason) => { + $fetch.setKey('error', reason) + }) + .finally(() => { + $fetch.setKey('loading', false) + finishTask() + }) + }, + } satisfies Fetcher + } + + const $fetcher = atom( + client ? createDefaultFetcher() : undefined, ) const enableLiveMode = defineEnableLiveMode({ - client, + client: client || undefined, + ssr, setFetcher: (fetcher) => { - $liveModeFetcher.set(fetcher) - return () => $liveModeFetcher.set(undefined) + const originalFetcher = $fetcher.get() + $fetcher.set(fetcher) + return () => $fetcher.set(originalFetcher) }, }) const createFetcherStore: QueryStore['createFetcherStore'] = < Response, - Error, + ErrorType, >( query: string, params: QueryParams = {}, initialData?: Response, initialSourceMap?: ContentSourceMap, - ): MapStore> => { - const $fetch = map>( - $fetcher.get().hydrate(query, params, initialData, initialSourceMap), + ): MapStore> => { + const fetcher = $fetcher.get() + const $fetch = map>( + fetcher + ? fetcher.hydrate(query, params, initialData, initialSourceMap) + : { + loading: false, + error: + typeof initialData === 'undefined' + ? (new Error( + `The \`initialData\` option is required when \`ssr: true\``, + ) as ErrorType) + : undefined, + data: initialData, + sourceMap: initialSourceMap, + }, ) onMount($fetch, () => { + let controller = new AbortController() const unsubscribe = $fetcher.subscribe((fetcher) => { - const controller = new AbortController() + if (!fetcher || controller.signal.aborted) return + controller.abort() + controller = new AbortController() fetcher.fetch(query, params, $fetch, controller) }) return () => { + controller.abort() unsubscribe() } }) return $fetch } + let serverClientCalled = false + const setServerClient: QueryStore['setServerClient'] = (newClient) => { + if (runtime !== 'server') { + throw new Error( + '`setServerClient` can only be called in server environments, detected: ' + + JSON.stringify(runtime), + ) + } + if (!ssr) { + throw new Error('`setServerClient` can only be called when `ssr: true`') + } + if (serverClientCalled) { + throw new Error('`setServerClient` can only be called once') + } + serverClientCalled = true + client = cloneClientWithConfig(newClient) + $fetcher.set(createDefaultFetcher()) + } - return { createFetcherStore, enableLiveMode, unstable__cache: cache } + return { + createFetcherStore, + enableLiveMode, + setServerClient, + unstable__cache: cache, + } } + +export { runtime } from './env' diff --git a/packages/core-loader/src/live-mode/enableLiveMode.ts b/packages/core-loader/src/live-mode/enableLiveMode.ts index c26e0585f..19eefa50a 100644 --- a/packages/core-loader/src/live-mode/enableLiveMode.ts +++ b/packages/core-loader/src/live-mode/enableLiveMode.ts @@ -1,7 +1,7 @@ -import type { - ClientPerspective, - ContentSourceMapDocuments, - QueryParams, +import { + type ClientPerspective, + type ContentSourceMapDocuments, + type QueryParams, SanityClient, } from '@sanity/client' import { SanityStegaClient, stegaEncodeSourceMap } from '@sanity/client/stega' @@ -16,7 +16,7 @@ import { EnableLiveModeOptions, QueryStoreState, SetFetcher } from '../types' /** @internal */ export interface LazyEnableLiveModeOptions extends EnableLiveModeOptions { - client: SanityClient | SanityStegaClient + ssr: boolean setFetcher: SetFetcher } @@ -28,6 +28,13 @@ export function enableLiveMode(options: LazyEnableLiveModeOptions): () => void { onConnect, onDisconnect, } = options + if (!client || !(client instanceof SanityClient)) { + throw new Error( + `Expected \`client\` to be an instance of SanityClient or SanityStegaClient: ${JSON.stringify( + client, + )}`, + ) + } const { projectId, dataset } = client.config() const $perspective = atom>('previewDrafts') const $connected = atom(false) @@ -74,8 +81,8 @@ export function enableLiveMode(options: LazyEnableLiveModeOptions): () => void { ) { const { perspective, query, params } = data if ( - client instanceof SanityStegaClient && - client.config().stega.enabled && + isStegaClient(client) && + (client as SanityStegaClient).config().stega?.enabled && data.resultSourceMap ) { cache.set(JSON.stringify({ perspective, query, params }), { @@ -83,7 +90,7 @@ export function enableLiveMode(options: LazyEnableLiveModeOptions): () => void { result: stegaEncodeSourceMap( data.result, data.resultSourceMap, - client.config().stega, + (client as SanityStegaClient).config().stega, { projectId: data.projectId, dataset: data.dataset }, ), }) @@ -222,3 +229,9 @@ export function enableLiveMode(options: LazyEnableLiveModeOptions): () => void { channel.disconnect() } } + +function isStegaClient( + client: SanityClient | SanityStegaClient, +): client is SanityStegaClient { + return client instanceof SanityStegaClient +} diff --git a/packages/core-loader/src/live-mode/index.ts b/packages/core-loader/src/live-mode/index.ts index 08d0ba020..04b28af96 100644 --- a/packages/core-loader/src/live-mode/index.ts +++ b/packages/core-loader/src/live-mode/index.ts @@ -1,23 +1,31 @@ -import { EnableLiveMode, EnableLiveModeOptions } from '../types' +import { runtime } from '../env' +import type { EnableLiveMode, EnableLiveModeOptions } from '../types' import type { LazyEnableLiveModeOptions } from './enableLiveMode' export const defineEnableLiveMode: ( - config: Omit, + config: Omit< + LazyEnableLiveModeOptions, + Exclude + >, ) => EnableLiveMode = (config) => { - const { client, setFetcher } = config + const { ssr, setFetcher } = config return (options) => { - if (typeof document === 'undefined') { - return () => { - // Do nothing if not in browser - } + if (runtime === 'server') { + throw new Error('Live mode is not supported in server environments') + } + if (ssr && !options.client) { + throw new Error( + 'The `client` option in `enableLiveMode` is required when `ssr: true`', + ) } + const client = options.client || config.client || undefined const controller = new AbortController() let disableLiveMode: (() => void) | undefined import('./enableLiveMode').then(({ enableLiveMode }) => { if (controller.signal.aborted) return - disableLiveMode = enableLiveMode({ ...options, client, setFetcher }) + disableLiveMode = enableLiveMode({ ...options, client, setFetcher, ssr }) }) return () => { controller.abort() diff --git a/packages/core-loader/src/types.ts b/packages/core-loader/src/types.ts index df1654908..b6f3c0af5 100644 --- a/packages/core-loader/src/types.ts +++ b/packages/core-loader/src/types.ts @@ -1,4 +1,9 @@ -import type { ContentSourceMap, QueryParams } from '@sanity/client' +import type { + ContentSourceMap, + QueryParams, + SanityClient, +} from '@sanity/client' +import type { SanityStegaClient } from '@sanity/client/stega' import type { MapStore } from 'nanostores' export type { ContentSourceMap, MapStore, QueryParams } @@ -20,6 +25,15 @@ export interface EnableLiveModeOptions { * @defaultValue `location.origin` */ allowStudioOrigin: string + /** + * You may use any client that is an `instanceof SanityClient` or `instanceof SanityStegaClient`. + * Required when `ssr: true`, optional otherwise. + * @example `import {createClient} from '@sanity/client'` + * @example `import {createClient} from '@sanity/client/stega'` + * @example `import {createClient} from '@sanity/preview-kit/client'` + * @example `import {createClient} from 'next-sanity'` + */ + client?: SanityClient | SanityStegaClient /** * Fires when a connection is established to a parent Studio window. */ diff --git a/packages/react-loader/src/index.ts b/packages/react-loader/src/index.ts index d96fa2e78..74bf0c4b8 100644 --- a/packages/react-loader/src/index.ts +++ b/packages/react-loader/src/index.ts @@ -32,6 +32,7 @@ export interface QueryStore { query: string, params?: QueryParams, ) => Promise<{ data: Response; sourceMap: ContentSourceMap | undefined }> + setServerClient: ReturnType['setServerClient'] useQuery: UseQueryHook useLiveMode: UseLiveModeHook } @@ -39,8 +40,12 @@ export interface QueryStore { export const createQueryStore = ( options: CreateQueryStoreOptions, ): QueryStore => { - const { createFetcherStore, enableLiveMode, unstable__cache } = - createCoreQueryStore(options) + const { + createFetcherStore, + setServerClient, + enableLiveMode, + unstable__cache, + } = createCoreQueryStore(options) const initialFetch = { loading: true, data: undefined, @@ -86,17 +91,19 @@ export const createQueryStore = ( const useLiveMode: UseLiveModeHook = ({ allowStudioOrigin, + client, onConnect, onDisconnect, }) => { useEffect(() => { const disableLiveMode = enableLiveMode({ allowStudioOrigin, + client, onConnect, onDisconnect, }) return () => disableLiveMode() - }, [allowStudioOrigin, onConnect, onDisconnect]) + }, [allowStudioOrigin, client, onConnect, onDisconnect]) } const query = async ( query: string, @@ -113,5 +120,5 @@ export const createQueryStore = ( return { data: result, sourceMap: resultSourceMap } } - return { query, useQuery, useLiveMode } + return { query, useQuery, setServerClient, useLiveMode } }