diff --git a/.changeset/fuzzy-cherries-mate.md b/.changeset/fuzzy-cherries-mate.md new file mode 100644 index 00000000..19966a7a --- /dev/null +++ b/.changeset/fuzzy-cherries-mate.md @@ -0,0 +1,6 @@ +--- +"@frames.js/debugger": patch +"@frames.js/render": patch +--- + +feat: simpler frame app context resolution diff --git a/packages/debugger/app/components/frame-app.tsx b/packages/debugger/app/components/frame-app.tsx index a06fa8a0..83088542 100644 --- a/packages/debugger/app/components/frame-app.tsx +++ b/packages/debugger/app/components/frame-app.tsx @@ -11,7 +11,8 @@ import { useConfig } from "wagmi"; import type { EIP6963ProviderInfo } from "@farcaster/frame-sdk"; import type { FramePrimaryButton, - ResolveClientFunction, + ResolveContextFunction, + FrameContext, } from "@frames.js/render/frame-app/types"; import { useCallback, useEffect, useRef, useState } from "react"; import type { UseQueryResult } from "@tanstack/react-query"; @@ -84,67 +85,79 @@ export function FrameApp({ const frameAppNotificationManagerPromiseRef = useRef( frameAppNotificationManager.promise ); - const resolveClient: ResolveClientFunction = useCallback(async () => { - try { - const clientInfoResponse = await fetch("/client-info"); + const resolveContext: ResolveContextFunction = useCallback( + async ({ signal }) => { + const location: FrameContext["location"] = + context.context === "button_press" + ? { + type: "launcher", + } + : { + type: "cast_embed", + embed: "", + cast: fallbackFrameContext.castId, + }; - if (!clientInfoResponse.ok) { - throw new Error("Failed to fetch client info"); - } + try { + const clientInfoResponse = await fetch("/client-info", { + signal, + }); - const parseClientInfo = z.object({ - fid: z.number().int(), - }); + if (!clientInfoResponse.ok) { + throw new Error("Failed to fetch client info"); + } - const clientInfo = parseClientInfo.parse(await clientInfoResponse.json()); + const parseClientInfo = z.object({ + fid: z.number().int(), + }); - const { manager } = await frameAppNotificationManagerPromiseRef.current; - const clientFid = clientInfo.fid; + const clientInfo = parseClientInfo.parse( + await clientInfoResponse.json() + ); + + const { manager } = await frameAppNotificationManagerPromiseRef.current; + const clientFid = clientInfo.fid; - if (!manager.state || manager.state.frame.status === "removed") { return { - clientFid, - added: false, + client: { + clientFid, + added: manager.state?.frame.status === "added", + notificationDetails: + manager.state?.frame.status === "added" + ? manager.state.frame.notificationDetails ?? undefined + : undefined, + }, + location, + user: userContext, }; - } - - return { - clientFid, - added: true, - notificationDetails: - manager.state.frame.notificationDetails ?? undefined, - }; - } catch (e) { - console.error(e); - - toast({ - title: "Unexpected error", - description: - "Failed to load notifications settings. Check the console for more details.", - variant: "destructive", - }); + } catch (e) { + if (!(typeof e === "string" && e.startsWith("Aborted because"))) { + console.error(e); + + toast({ + title: "Unexpected error", + description: + "Failed to load notifications settings. Check the console for more details.", + variant: "destructive", + }); + } - return { - clientFid: -1, - added: false, - }; - } - }, [toast]); + return { + client: { + clientFid: -1, + added: false, + }, + location, + user: userContext, + }; + } + }, + [toast, context, userContext] + ); const frameApp = useFrameAppInIframe({ debug: true, source: context.parseResult, - client: resolveClient, - location: - context.context === "button_press" - ? { - type: "launcher", - } - : { - type: "cast_embed", - embed: "", - cast: fallbackFrameContext.castId, - }, - user: userContext, + context: resolveContext, provider, proxyUrl: "/frames", addFrameRequestsCache, diff --git a/packages/debugger/app/notifications/types.ts b/packages/debugger/app/notifications/types.ts index 069fc482..3e0f0ffc 100644 --- a/packages/debugger/app/notifications/types.ts +++ b/packages/debugger/app/notifications/types.ts @@ -2,7 +2,6 @@ import type { FrameNotificationDetails, SendNotificationRequest, } from "@farcaster/frame-sdk"; -import { FrameClientConfig } from "@frames.js/render/frame-app/types"; import type { FrameServerEvent } from "frames.js/farcaster-v2/events"; export type Notification = SendNotificationRequest; @@ -40,7 +39,7 @@ export type RecordedEvent = export type NotificationSettings = | { enabled: true; - details: NonNullable; + details: FrameNotificationDetails; webhookUrl: string; signerPrivateKey: string; } diff --git a/packages/render/src/frame-app/types.ts b/packages/render/src/frame-app/types.ts index 0c508db7..bf5516fe 100644 --- a/packages/render/src/frame-app/types.ts +++ b/packages/render/src/frame-app/types.ts @@ -10,8 +10,6 @@ import type { ParseFramesV2ResultWithFrameworkDetails } from "frames.js/frame-pa import type { Provider } from "ox/Provider"; import type { Default as DefaultRpcSchema, ExtractRequest } from "ox/RpcSchema"; -export type FrameClientConfig = Context.ClientContext; - export type SendTransactionRpcRequest = ExtractRequest< DefaultRpcSchema, "eth_sendTransaction" @@ -99,12 +97,21 @@ export type OnSignInFunction = ( export type OnViewProfileFunction = FrameHost["viewProfile"]; +export type FrameContext = Context.FrameContext; + +export type ResolveContextFunctionOptions = { + /** + * Called when hook is unmounted + */ + signal: AbortSignal; +}; + /** - * Function called when the frame app is being loaded and we need to resolve the client that renders the frame app + * Function called when the frame app is loaded and needs a context to be rendered */ -export type ResolveClientFunction = (options: { - signal: AbortSignal; -}) => Promise; +export type ResolveContextFunction = ( + options: ResolveContextFunctionOptions +) => Promise; export type HostEndpointEmitter = Pick< HostEndpoint, diff --git a/packages/render/src/frame-app/use-resolve-client.ts b/packages/render/src/frame-app/use-resolve-context.ts similarity index 58% rename from packages/render/src/frame-app/use-resolve-client.ts rename to packages/render/src/frame-app/use-resolve-context.ts index 66da1157..728037d0 100644 --- a/packages/render/src/frame-app/use-resolve-client.ts +++ b/packages/render/src/frame-app/use-resolve-context.ts @@ -1,14 +1,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { FrameClientConfig, ResolveClientFunction } from "./types"; +import type { ResolveContextFunction, FrameContext } from "./types"; -type UseResolveClientOptions = { - client: FrameClientConfig | ResolveClientFunction; +type UseResolveContextOptions = { + context: FrameContext | ResolveContextFunction; }; -type UseResolveClientResult = +type UseResolveContextResult = | { status: "success"; - client: FrameClientConfig; + context: FrameContext; } | { status: "error"; @@ -18,15 +18,15 @@ type UseResolveClientResult = status: "pending"; }; -export function useResolveClient({ - client, -}: UseResolveClientOptions): UseResolveClientResult { +export function useResolveContext({ + context, +}: UseResolveContextOptions): UseResolveContextResult { const abortControllerRef = useRef(null); - const [state, setState] = useState(() => { - if (typeof client !== "function") { + const [state, setState] = useState(() => { + if (typeof context !== "function") { return { status: "success", - client, + context, }; } @@ -35,7 +35,7 @@ export function useResolveClient({ }; }); - const resolveClient = useCallback((resolve: ResolveClientFunction) => { + const resolveContext = useCallback((resolve: ResolveContextFunction) => { // cancel previous request abortControllerRef.current?.abort(); @@ -47,7 +47,7 @@ export function useResolveClient({ setState({ status: "pending", }); - const resolvedClient = await resolve({ + const resolvedContext = await resolve({ signal: abortController.signal, }); @@ -57,7 +57,7 @@ export function useResolveClient({ setState({ status: "success", - client: resolvedClient, + context: resolvedContext, }); }) .catch((e) => { @@ -72,26 +72,28 @@ export function useResolveClient({ }); return () => { - abortController.abort(); + abortController.abort( + "Aborted because the component has been unmounted/remounted" + ); }; }, []); useEffect(() => { - if (typeof client !== "function") { + if (typeof context !== "function") { setState((prevState) => { - if (prevState.status === "success" && prevState.client !== client) { + if (prevState.status === "success" && prevState.context !== context) { return { status: "success", - client, + context, }; } return prevState; }); } else { - resolveClient(client); + return resolveContext(context); } - }, [client, resolveClient]); + }, [context, resolveContext]); return state; } diff --git a/packages/render/src/use-frame-app.ts b/packages/render/src/use-frame-app.ts index eb8ff01c..1421188e 100644 --- a/packages/render/src/use-frame-app.ts +++ b/packages/render/src/use-frame-app.ts @@ -1,11 +1,10 @@ import type { ParseFramesV2ResultWithFrameworkDetails } from "frames.js/frame-parsers"; -import type { FrameHost, HostEndpoint, Context } from "@farcaster/frame-host"; +import type { FrameHost, HostEndpoint } from "@farcaster/frame-host"; import { AddFrame } from "@farcaster/frame-host"; import { useMemo } from "react"; import { useFreshRef } from "./hooks/use-fresh-ref"; import type { EthProvider, - FrameClientConfig, HostEndpointEmitter, OnAddFrameRequestedFunction, OnEIP6963RequestProviderRequestedFunction, @@ -15,11 +14,12 @@ import type { OnSignMessageRequestFunction, OnSignTypedDataRequestFunction, OnViewProfileFunction, - ResolveClientFunction, + ResolveContextFunction, + FrameContext, } from "./frame-app/types"; import { assertNever } from "./assert-never"; import { useFetchFrameApp } from "./frame-app/use-fetch-frame-app"; -import { useResolveClient } from "./frame-app/use-resolve-client"; +import { useResolveContext } from "./frame-app/use-resolve-context"; import { useDebugLog } from "./hooks/use-debug-log"; const defaultFrameRequestCache = new Set(); @@ -92,6 +92,8 @@ const defaultSignIn: OnSignInFunction = () => { return Promise.reject(new Error("onSignIn not implemented")); }; +// @todo ok it is safe to use resolveContext or something like this, which will resolve whole context +// the value should be memoized otherwise it will rerender frame app export type UseFrameAppOptions = { /** * @example @@ -110,26 +112,13 @@ export type UseFrameAppOptions = { */ provider: EthProvider; /** - * Frame client that is rendering the app + * Frame context which is used to render the frame app. * - * This is async function if you need to fetch the client configuration - * like notification settings, etc. + * The function is called after the frame app is loaded and it should return the context. * - * Value should be memoized otherwise it will cause unnecessary re-renders. + * The value (either an object or function) should be memoized otherwise it will cause unnecessary re-renders. */ - client: FrameClientConfig | ResolveClientFunction; - /** - * Information about the context from which the frame was launched. - * - * @defaultValue launcher context - */ - location?: Context.LocationContext; - /** - * Information about the user who manipulates with the frame. - * - * Value should be memoized otherwise it will cause unnecessary re-renders. - */ - user: Context.UserContext; + context: FrameContext | ResolveContextFunction; /** * Either: * @@ -204,7 +193,7 @@ export type UseFrameAppOptions = { export type UseFrameAppReturn = | { frame: ParseFramesV2ResultWithFrameworkDetails; - client: FrameClientConfig; + context: FrameContext; /** * Url that has been used to fetch the frame app. * @@ -229,18 +218,12 @@ export type UseFrameAppReturn = status: "error"; }; -const defaultLocation: Context.LauncherLocationContext = { - type: "launcher", -}; - /** * This hook is used to handle frames v2 apps. */ export function useFrameApp({ + context, provider, - client, - location = defaultLocation, - user, source, fetchFn, proxyUrl, @@ -260,7 +243,6 @@ export function useFrameApp({ }: UseFrameAppOptions): UseFrameAppReturn { const providerRef = useFreshRef(provider); const debugRef = useFreshRef(debug); - const locationRef = useFreshRef(location); const readyRef = useFreshRef(onReady); const closeRef = useFreshRef(onClose); const onOpenUrlRef = useFreshRef(onOpenUrl); @@ -272,7 +254,7 @@ export function useFrameApp({ const onSignInRef = useFreshRef(onSignIn); const onAddFrameRequestedRef = useFreshRef(onAddFrameRequested); const addFrameRequestsCacheRef = useFreshRef(addFrameRequestsCache); - const clientResolutionState = useResolveClient({ client }); + const clientResolutionState = useResolveContext({ context }); const frameResolutionState = useFetchFrameApp({ source, fetchFn, @@ -303,7 +285,7 @@ export function useFrameApp({ }; } - const resolvedClient = clientResolutionState.client; + const resolvedContext = clientResolutionState.context; switch (frameResolutionState.status) { case "success": { @@ -393,11 +375,7 @@ export function useFrameApp({ logDebug("sdk.close() called"); closeRef.current?.(); }, - context: { - client: resolvedClient, - location: locationRef.current, - user, - }, + context: resolvedContext, async ethProviderRequest(parameters) { // @ts-expect-error -- type mismatch return providerRef.current.request(parameters); @@ -439,7 +417,7 @@ export function useFrameApp({ status: "success", frame: frameResolutionState.frame, frameUrl, - client: clientResolutionState.client, + context: resolvedContext, }; } case "error": { @@ -459,7 +437,6 @@ export function useFrameApp({ }, [ clientResolutionState, frameResolutionState, - locationRef, logDebug, addFrameRequestsCacheRef, onAddFrameRequestedRef, @@ -471,6 +448,5 @@ export function useFrameApp({ onViewProfileRef, onEIP6963RequestProviderRequestedRef, onSignInRef, - user, ]); }