diff --git a/packages/core/src/store/createSanityInstance.ts b/packages/core/src/store/createSanityInstance.ts index e20fe094..eecf503e 100644 --- a/packages/core/src/store/createSanityInstance.ts +++ b/packages/core/src/store/createSanityInstance.ts @@ -120,5 +120,8 @@ export function createSanityInstance(config: SanityConfig = {}): SanityInstance }, } + // TODO: Remove automatic intent listening - now handled by global IntentResolver + // startIntentListener(instance) + return instance } diff --git a/packages/react/src/_exports/sdk-react.ts b/packages/react/src/_exports/sdk-react.ts index 5cfb573c..237ab379 100644 --- a/packages/react/src/_exports/sdk-react.ts +++ b/packages/react/src/_exports/sdk-react.ts @@ -2,6 +2,7 @@ * @module exports */ export {AuthBoundary, type AuthBoundaryProps} from '../components/auth/AuthBoundary' +export {type IntentHandlerPayload, type IntentHandlers} from '../components/IntentResolver' export {SanityApp, type SanityAppProps} from '../components/SanityApp' export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider' export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh' diff --git a/packages/react/src/components/IntentResolver.tsx b/packages/react/src/components/IntentResolver.tsx new file mode 100644 index 00000000..87c06912 --- /dev/null +++ b/packages/react/src/components/IntentResolver.tsx @@ -0,0 +1,338 @@ +import {type DocumentHandle} from '@sanity/sdk' +import React, {type ReactNode, Suspense, useEffect, useRef, useState} from 'react' +import {ErrorBoundary} from 'react-error-boundary' + +/** + * @public + */ +export interface IntentHandlerPayload { + documentHandle: DocumentHandle + params?: Record +} + +interface IntentHandler { + type: 'async' | 'component' + handler: AsyncIntentHandler | ComponentIntentHandler + hideApp?: boolean +} + +// Enhanced intent handler types +type AsyncIntentHandler = (payload: TPayload) => Promise +type ComponentIntentHandler = (props: { + payload: TPayload +}) => React.ReactElement + +/** + * @public + */ +export type IntentHandlers = { + [intentName: string]: IntentHandler +} + +interface IntentResolverProps { + handlers?: IntentHandlers + children: ReactNode +} + +interface ParsedIntent { + intentName: string + payload: IntentHandlerPayload +} + +// this one's a bit odd and maybe could rise to be a a store in ResourceProvider +let globalIntentState: ParsedIntent | null = null +let hasInterceptedUrl = false + +// Parse intent from URLs - support both /intent/ and /intents/ patterns +function parseIntentFromUrl(url: string): ParsedIntent | null { + try { + const parsedUrl = new URL(url) + const {pathname, searchParams} = parsedUrl + + const intentPatterns = ['/intent/', '/intents/'] + let matchedPattern: string | null = null + + for (const pattern of intentPatterns) { + if (pathname.startsWith(pattern)) { + matchedPattern = pattern + break + } + } + + if (!matchedPattern) { + return null + } + + const intentPath = pathname.slice(matchedPattern.length) + let intentName = intentPath.startsWith('/') ? intentPath.slice(1) : intentPath + + // Remove trailing slash if present + intentName = intentName.endsWith('/') ? intentName.slice(0, -1) : intentName + + if (!intentName) { + return null + } + + const payloadParam = searchParams.get('payload') + let payload: IntentHandlerPayload + + if (!payloadParam) { + throw new Error('No payload found in URL') + } + + const decoded = decodeURIComponent(payloadParam) + + try { + const firstParse = JSON.parse(decoded) + + // If first parse result is a string, it means we have double-encoded JSON (comes from Dashboard) + if (typeof firstParse === 'string') { + payload = JSON.parse(firstParse) + } else { + payload = firstParse + } + } catch (error) { + throw new Error( + `Invalid payload format: expected JSON but got "${payloadParam}". Error: ${error}`, + ) + } + + return { + intentName, + payload, + } + } catch { + return null + } +} + +function interceptIntentUrl(): void { + if (typeof window === 'undefined' || hasInterceptedUrl) { + return + } + + const currentUrl = window.location.href + const intentData = parseIntentFromUrl(currentUrl) + + if (intentData) { + // Store the intent data globally + globalIntentState = intentData + hasInterceptedUrl = true + + // Immediately clean up URL to prevent router conflicts + const url = new URL(currentUrl) + const searchParams = new URLSearchParams(url.search) + + // Remove intent-related parameters + searchParams.delete('intent') + searchParams.delete('payload') + + // For path-based intents, redirect to root + const isPathBasedIntent = url.pathname.startsWith('/intent') + const newPath = isPathBasedIntent ? '/' : url.pathname + + const cleanUrl = newPath + (searchParams.toString() ? '?' + searchParams.toString() : '') + + // Immediately replace the URL + window.history.replaceState({}, '', cleanUrl) + } +} + +// Run global interception immediately when this module loads +interceptIntentUrl() + +function HandlerExecutor({ + intentName, + payload, + handlers, + onAsyncComplete, +}: { + intentName: string + payload: unknown + handlers: IntentHandlers + onAsyncComplete?: () => void +}) { + const [renderResult, setRenderResult] = useState() + + useEffect(() => { + const handlerConfig = handlers[intentName] + if (!handlerConfig) { + // eslint-disable-next-line no-console + console.warn(`Intent handler '${intentName}' not found in handlers:`, Object.keys(handlers)) + return + } + + const {type, handler} = handlerConfig + + if (type === 'async') { + // Execute async handler + const asyncHandler = handler as AsyncIntentHandler + asyncHandler(payload as IntentHandlerPayload) + .then(() => { + // Notify that async operation completed + onAsyncComplete?.() + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(`Error executing intent handler '${intentName}':`, error) + // Still call completion callback even on error + onAsyncComplete?.() + }) + setRenderResult(null) + } else if (type === 'component') { + // Execute component handler using React.createElement so hooks work properly + const componentHandler = handler as ComponentIntentHandler + try { + // Pass payload as a prop to the component + const jsxResult = React.createElement(componentHandler, { + payload: payload as IntentHandlerPayload, + }) + setRenderResult(jsxResult) + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Error executing intent handler '${intentName}':`, error) + setRenderResult(null) + } + } + }, [intentName, payload, handlers, onAsyncComplete]) + + return <>{renderResult} +} + +function IntentHandlerWrapper({ + intentName, + payload, + handlers, + onAsyncComplete, +}: { + intentName: string + payload: unknown + handlers: IntentHandlers + onAsyncComplete?: () => void +}) { + // Execute in the current ResourceProvider context (from SanityApp) + // No need to create a new ResourceProvider since the app already has one configured + return ( + + ) +} + +function ErrorFallback({error}: {error: Error}) { + return ( +
+

Intent Handler Error

+
+ Error details +
{error.message}
+
+
+ ) +} + +export function IntentResolver({handlers, children}: IntentResolverProps): ReactNode { + // Initialize state immediately with global intent state to prevent flash + const [intentState, setIntentState] = useState(() => { + if (globalIntentState && handlers) { + return globalIntentState + } + return null + }) + + const [isProcessingAsyncIntent, setIsProcessingAsyncIntent] = useState(() => { + // Also initialize async processing state immediately + if (globalIntentState && handlers) { + const handler = handlers[globalIntentState.intentName] + const isAsync = handler?.type === 'async' + // Clear global state after using it + globalIntentState = null + return isAsync + } + return false + }) + + const hasInitialized = useRef(false) + + // Handle cases where handlers arrive after global intent state was captured + useEffect(() => { + if (!handlers || hasInitialized.current || intentState) return + + hasInitialized.current = true + + if (globalIntentState) { + setIntentState(globalIntentState) + + // Check if this is an async intent to determine initial processing state + const handler = handlers[globalIntentState.intentName] + if (handler?.type === 'async') { + setIsProcessingAsyncIntent(true) + } + + // Clear global state after using it + globalIntentState = null + } + }, [handlers, intentState]) + + // Handle navigation to new intent URLs after initial load + useEffect(() => { + if (!handlers) return + + const handleUrlChange = () => { + const intentData = parseIntentFromUrl(window.location.href) + if (intentData) { + setIntentState(intentData) + + // Check if this is an async intent + const handler = handlers[intentData.intentName] + if (handler?.type === 'async') { + setIsProcessingAsyncIntent(true) + } + } + } + + // Listen for navigation events + window.addEventListener('popstate', handleUrlChange) + + return () => { + window.removeEventListener('popstate', handleUrlChange) + } + }, [handlers]) + + // Callback for when async intent processing completes + const handleAsyncComplete = () => { + setIsProcessingAsyncIntent(false) + // Clear the intent state immediately since async intents should handle their own navigation + setIntentState(null) + } + + // If we have an intent to process, show the handler wrapper + if (intentState && handlers) { + const handler = handlers[intentState.intentName] + const isAsyncHandler = handler?.type === 'async' + + const shouldHideApp = handler?.hideApp ?? (isAsyncHandler ? true : false) + const showChildren = !isProcessingAsyncIntent && !shouldHideApp + + return ( + Processing intent...}> + + + + {/* Conditionally render children based on intent configuration and processing state */} + {showChildren && children} + + ) + } + + // Otherwise, render children normally + return <>{children} +} diff --git a/packages/react/src/components/SDKProvider.tsx b/packages/react/src/components/SDKProvider.tsx index 6168e977..de54cafc 100644 --- a/packages/react/src/components/SDKProvider.tsx +++ b/packages/react/src/components/SDKProvider.tsx @@ -3,6 +3,7 @@ import {type ReactElement, type ReactNode} from 'react' import {ResourceProvider} from '../context/ResourceProvider' import {AuthBoundary, type AuthBoundaryProps} from './auth/AuthBoundary' +import {type IntentHandlers, IntentResolver} from './IntentResolver' /** * @internal @@ -11,6 +12,7 @@ export interface SDKProviderProps extends AuthBoundaryProps { children: ReactNode config: SanityConfig | SanityConfig[] fallback: ReactNode + handlers?: IntentHandlers } /** @@ -24,6 +26,7 @@ export function SDKProvider({ children, config, fallback, + handlers, ...props }: SDKProviderProps): ReactElement { // reverse because we want the first config to be the default, but the @@ -36,13 +39,18 @@ export function SDKProvider({ if (index >= configs.length) { return ( - {children} + {children} ) } return ( - + {createNestedProviders(index + 1)} ) diff --git a/packages/react/src/components/SanityApp.tsx b/packages/react/src/components/SanityApp.tsx index 0b2f77f5..deaef29a 100644 --- a/packages/react/src/components/SanityApp.tsx +++ b/packages/react/src/components/SanityApp.tsx @@ -1,6 +1,7 @@ import {type SanityConfig} from '@sanity/sdk' import {type ReactElement, useEffect} from 'react' +import {type IntentHandlers} from './IntentResolver' import {SDKProvider} from './SDKProvider' import {isInIframe, isLocalUrl} from './utils' @@ -16,6 +17,8 @@ export interface SanityAppProps { children: React.ReactNode /* Fallback content to show when child components are suspending. Same as the `fallback` prop for React Suspense. */ fallback: React.ReactNode + /* Intent handlers for processing various intent types */ + handlers?: IntentHandlers } const REDIRECT_URL = 'https://sanity.io/welcome' @@ -28,7 +31,7 @@ const REDIRECT_URL = 'https://sanity.io/welcome' * must be wrapped with the SanityApp component to function properly. * * The `config` prop on the SanityApp component accepts either a single {@link SanityConfig} object, or an array of them. - * This allows your app to work with one or more of your organization’s datasets. + * This allows your app to work with one or more of your organization's datasets. * * @remarks * When passing multiple SanityConfig objects to the `config` prop, the first configuration in the array becomes the default @@ -82,6 +85,7 @@ export function SanityApp({ children, fallback, config = [], + handlers, ...props }: SanityAppProps): ReactElement { useEffect(() => { @@ -100,7 +104,7 @@ export function SanityApp({ }, [config]) return ( - + {children} ) diff --git a/packages/react/src/components/exampleAppArc.md b/packages/react/src/components/exampleAppArc.md new file mode 100644 index 00000000..df3557bc --- /dev/null +++ b/packages/react/src/components/exampleAppArc.md @@ -0,0 +1,97 @@ +```tsx + + + temporary? gets cleaned up? + + + + + + + +``` + +```ts +const navigateToSubtitleResource = () => { + sendIntent({intentName: 'downloadSubtitles', documentId: `sfdsfsd`, documentType: }) +} +``` + +```ts +const navigateToSubtitleResource = () => { + sendIntent({documentId: `sfdsfsd`, documentType: `movie`, intentType: `view`}) +} +``` + +```tsx +function Something() { + return +} + +// // option 1: use your router's component +// function UserHandlerComponent(props: SomeTypeGenedProps) { +// if (props.intent.intentId === 'editSubtitle',) { +// return +// } +// } + +// option 2: show a fallback "loading" screen and then call your router's navigate in an effect +function UserHandlerComponent(props: SomeTypeGenedProps) { + const router = useRouter() + + useEffect(() => { + if (props.intent.intentId) { + } + router.navigate() + }, []) + + return <>Loading your intent… +} +``` + +```tsx +function SomeComponentThatDispatchesAnIntent(docHandle: DocumentHandle) { + const intents = useIntents({ + // add params that you want to find + // documentType: '...' + ...docHandle, + type: 'edit' + }) + + const dispatchIntent = useDispatchIntent() + + return
    {intents.map(({appId, intentName, intentId})) =>
  • + +
  • }
+} +``` + +```ts +// intent definition +{ + intentName: 'downloadSubtitles', + intentType: 'view', + , +} +``` + +```tsx + + + + // listening to URL + + + // listening to URL + + + +``` + +```tsx + + {currentProjects.map((proj) => { + ; + })} + +```