diff --git a/packages/fdr-sdk/src/api-definition/__test__/join.test.ts b/packages/fdr-sdk/src/api-definition/__test__/join.test.ts index f10da742ca..42c34b0346 100644 --- a/packages/fdr-sdk/src/api-definition/__test__/join.test.ts +++ b/packages/fdr-sdk/src/api-definition/__test__/join.test.ts @@ -1,5 +1,5 @@ import { APIV1Read } from "../../client"; -import { join } from "../join"; +import { joiner } from "../join"; import * as Latest from "../latest"; const PRIMITIVE_SHAPE: Latest.TypeReference.Primitive = { @@ -276,7 +276,7 @@ const api3: Latest.ApiDefinition = { describe("join", () => { it("should prune endpoint1 and its types", () => { - const pruned = join(api1, api2, api3); + const pruned = joiner()(api1, api2, api3); expect(Object.keys(pruned.endpoints)).toStrictEqual([endpoint1.id, endpoint2.id]); expect(Object.keys(pruned.websockets)).toStrictEqual([websocket1.id]); diff --git a/packages/fdr-sdk/src/api-definition/join.ts b/packages/fdr-sdk/src/api-definition/join.ts index 62daf87455..20821c7435 100644 --- a/packages/fdr-sdk/src/api-definition/join.ts +++ b/packages/fdr-sdk/src/api-definition/join.ts @@ -8,78 +8,82 @@ import * as Latest from "./latest"; * @param apis list of API definitions to join (must have the same ID) * @returns a new API definition that is the result of joining the input API definitions */ -export function join(first: Latest.ApiDefinition, ...apis: Latest.ApiDefinition[]): Latest.ApiDefinition { - const joined: Latest.ApiDefinition = { - id: first.id, - endpoints: { ...first.endpoints }, - websockets: { ...first.websockets }, - webhooks: { ...first.webhooks }, - types: { ...first.types }, - subpackages: { ...first.subpackages }, - auths: { ...first.auths }, - globalHeaders: first.globalHeaders ? [...first.globalHeaders] : undefined, - }; - - let isJoined = false; - for (const api of apis) { - if (api.id !== joined.id) { - throw new Error("Cannot join API definitions with different IDs"); - } +export function joiner( + force = false, +): (first: Latest.ApiDefinition, ...apis: Latest.ApiDefinition[]) => Latest.ApiDefinition { + return (first, ...apis) => { + const joined: Latest.ApiDefinition = { + id: first.id, + endpoints: { ...first.endpoints }, + websockets: { ...first.websockets }, + webhooks: { ...first.webhooks }, + types: { ...first.types }, + subpackages: { ...first.subpackages }, + auths: { ...first.auths }, + globalHeaders: first.globalHeaders ? [...first.globalHeaders] : undefined, + }; - for (const [endpointId, endpoint] of Object.entries(api.endpoints)) { - if (!isJoined && !first.endpoints[Latest.EndpointId(endpointId)]) { - isJoined = true; + let isJoined = false; + for (const api of apis) { + if (api.id !== joined.id) { + throw new Error("Cannot join API definitions with different IDs"); } - joined.endpoints[Latest.EndpointId(endpointId)] = endpoint; - } - for (const [webSocketId, webSocket] of Object.entries(api.websockets)) { - if (!isJoined && !first.websockets[Latest.WebSocketId(webSocketId)]) { - isJoined = true; + for (const [endpointId, endpoint] of Object.entries(api.endpoints)) { + if (!isJoined && !first.endpoints[Latest.EndpointId(endpointId)]) { + isJoined = true; + } + joined.endpoints[Latest.EndpointId(endpointId)] = endpoint; } - joined.websockets[Latest.WebSocketId(webSocketId)] = webSocket; - } - for (const [webhookId, webhook] of Object.entries(api.webhooks)) { - if (!isJoined && !first.webhooks[Latest.WebhookId(webhookId)]) { - isJoined = true; + for (const [webSocketId, webSocket] of Object.entries(api.websockets)) { + if (!isJoined && !first.websockets[Latest.WebSocketId(webSocketId)]) { + isJoined = true; + } + joined.websockets[Latest.WebSocketId(webSocketId)] = webSocket; } - joined.webhooks[Latest.WebhookId(webhookId)] = webhook; - } - for (const [typeId, type] of Object.entries(api.types)) { - if (!isJoined && !first.types[Latest.TypeId(typeId)]) { - isJoined = true; + for (const [webhookId, webhook] of Object.entries(api.webhooks)) { + if (!isJoined && !first.webhooks[Latest.WebhookId(webhookId)]) { + isJoined = true; + } + joined.webhooks[Latest.WebhookId(webhookId)] = webhook; } - joined.types[Latest.TypeId(typeId)] = type; - } - for (const [subpackageId, subpackage] of Object.entries(api.subpackages)) { - if (!isJoined && !first.subpackages[Latest.SubpackageId(subpackageId)]) { - isJoined = true; + for (const [typeId, type] of Object.entries(api.types)) { + if (!isJoined && !first.types[Latest.TypeId(typeId)]) { + isJoined = true; + } + joined.types[Latest.TypeId(typeId)] = type; } - joined.subpackages[Latest.SubpackageId(subpackageId)] = subpackage; - } - for (const [authId, auth] of Object.entries(api.auths)) { - if (!isJoined && !first.auths[Latest.AuthSchemeId(authId)]) { - isJoined = true; + for (const [subpackageId, subpackage] of Object.entries(api.subpackages)) { + if (!isJoined && !first.subpackages[Latest.SubpackageId(subpackageId)]) { + isJoined = true; + } + joined.subpackages[Latest.SubpackageId(subpackageId)] = subpackage; } - joined.auths[Latest.AuthSchemeId(authId)] = auth; - } - const globalHeaders = (joined.globalHeaders ??= []); - api.globalHeaders?.forEach((header) => { - if (!globalHeaders.find((h) => h.key === header.key)) { - isJoined = true; - globalHeaders.push(header); + for (const [authId, auth] of Object.entries(api.auths)) { + if (!isJoined && !first.auths[Latest.AuthSchemeId(authId)]) { + isJoined = true; + } + joined.auths[Latest.AuthSchemeId(authId)] = auth; } - }); - } - if (!isJoined) { - return first; - } + const globalHeaders = (joined.globalHeaders ??= []); + api.globalHeaders?.forEach((header) => { + if (!globalHeaders.find((h) => h.key === header.key)) { + isJoined = true; + globalHeaders.push(header); + } + }); + } + + if (!isJoined && !force) { + return first; + } - return joined; + return joined; + }; } diff --git a/packages/ui/app/src/api-reference/ApiEndpointPage.tsx b/packages/ui/app/src/api-reference/ApiEndpointPage.tsx index 9ad9eacebe..1466eea33a 100644 --- a/packages/ui/app/src/api-reference/ApiEndpointPage.tsx +++ b/packages/ui/app/src/api-reference/ApiEndpointPage.tsx @@ -2,7 +2,7 @@ import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { EMPTY_OBJECT } from "@fern-api/ui-core-utils"; import { useSetAtom } from "jotai"; import { useEffect } from "react"; -import { WRITE_API_DEFINITION_ATOM, useNavigationNodes } from "../atoms"; +import { useNavigationNodes, useWriteApiDefinitionAtom } from "../atoms"; import { ALL_ENVIRONMENTS_ATOM } from "../atoms/environment"; import { BottomNavigationNeighbors } from "../components/BottomNavigationNeighbors"; import { FernErrorBoundary } from "../components/FernErrorBoundary"; @@ -18,8 +18,7 @@ export declare namespace ApiEndpointPage { } export const ApiEndpointPage: React.FC = ({ content }) => { - const set = useSetAtom(WRITE_API_DEFINITION_ATOM); - useEffect(() => set(content.apiDefinition), [content.apiDefinition, set]); + useWriteApiDefinitionAtom(content.apiDefinition); // TODO: Why are we doing this here? const setEnvironmentIds = useSetAtom(ALL_ENVIRONMENTS_ATOM); diff --git a/packages/ui/app/src/api-reference/ApiReferenceContent.tsx b/packages/ui/app/src/api-reference/ApiReferenceContent.tsx index 6aea0d02ca..605c1a370e 100644 --- a/packages/ui/app/src/api-reference/ApiReferenceContent.tsx +++ b/packages/ui/app/src/api-reference/ApiReferenceContent.tsx @@ -2,9 +2,10 @@ import type { ApiDefinition } from "@fern-api/fdr-sdk/api-definition"; import type * as FernDocs from "@fern-api/fdr-sdk/docs"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { dfs } from "@fern-api/fdr-sdk/traversers"; -import { memo, useEffect, useMemo } from "react"; +import { ReactElement, memo, useEffect, useMemo } from "react"; import { useIsReady } from "../atoms"; import { FernErrorBoundary } from "../components/FernErrorBoundary"; +import { useIsLocalPreview } from "../contexts/local-preview"; import { scrollToRoute } from "../util/anchor"; import { ApiPackageContent, ApiPackageContentNode } from "./ApiPackageContent"; @@ -104,4 +105,11 @@ const UnmemoizedApiReferenceContent: React.FC = ({ ); }; -export const ApiReferenceContent = memo(UnmemoizedApiReferenceContent, (prev, next) => prev.node.id === next.node.id); +const MemoizedApiReferenceContent = memo(UnmemoizedApiReferenceContent, (prev, next) => prev.node.id === next.node.id); + +export function ApiReferenceContent(props: ApiReferenceContentProps): ReactElement { + const isLocalPreview = useIsLocalPreview(); + // do not memoize when in local preview mode to ensure that the page is re-rendered on every change + const Component = isLocalPreview ? UnmemoizedApiReferenceContent : MemoizedApiReferenceContent; + return ; +} diff --git a/packages/ui/app/src/api-reference/ApiReferencePage.tsx b/packages/ui/app/src/api-reference/ApiReferencePage.tsx index aa62437d3b..c0d860a8b2 100644 --- a/packages/ui/app/src/api-reference/ApiReferencePage.tsx +++ b/packages/ui/app/src/api-reference/ApiReferencePage.tsx @@ -1,6 +1,4 @@ -import { useSetAtom } from "jotai"; -import { useEffect } from "react"; -import { WRITE_API_DEFINITION_ATOM, useIsReady, useNavigationNodes } from "../atoms"; +import { useIsReady, useNavigationNodes, useWriteApiDefinitionAtom } from "../atoms"; import { ApiPageContext } from "../contexts/api-page"; import { DocsContent } from "../resolver/DocsContent"; import { BuiltWithFern } from "../sidebar/BuiltWithFern"; @@ -15,8 +13,7 @@ export declare namespace ApiReferencePage { export const ApiReferencePage: React.FC = ({ content }) => { const hydrated = useIsReady(); - const set = useSetAtom(WRITE_API_DEFINITION_ATOM); - useEffect(() => set(content.apiDefinition), [content.apiDefinition, set]); + useWriteApiDefinitionAtom(content.apiDefinition); const node = useNavigationNodes().get(content.apiReferenceNodeId); diff --git a/packages/ui/app/src/atoms/apis.ts b/packages/ui/app/src/atoms/apis.ts index 1c0c952b7f..18a479a33b 100644 --- a/packages/ui/app/src/atoms/apis.ts +++ b/packages/ui/app/src/atoms/apis.ts @@ -1,28 +1,55 @@ import type * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import { join } from "@fern-api/fdr-sdk/api-definition"; +import { joiner } from "@fern-api/fdr-sdk/api-definition"; import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; -import { atom, useAtomValue } from "jotai"; +import { atom, useAtomValue, useSetAtom } from "jotai"; import { atomFamily } from "jotai/utils"; +import { useEffect } from "react"; import { useMemoOne } from "use-memo-one"; +import { useIsLocalPreview } from "../contexts/local-preview"; import { FEATURE_FLAGS_ATOM } from "./flags"; import { RESOLVED_API_DEFINITION_ATOM, RESOLVED_PATH_ATOM } from "./navigation"; const SETTABLE_APIS_ATOM = atom>({}); SETTABLE_APIS_ATOM.debugLabel = "SETTABLE_APIS_ATOM"; -export const WRITE_API_DEFINITION_ATOM = atom(null, (_get, set, apiDefinition: ApiDefinition.ApiDefinition) => { - set(SETTABLE_APIS_ATOM, (prev) => { - const prevDefinition = prev[apiDefinition.id]; - if (prevDefinition == null) { - return { ...prev, [apiDefinition.id]: apiDefinition }; - } - const merged = join(prevDefinition, apiDefinition); - if (merged === prevDefinition) { - return prev; +export const WRITE_API_DEFINITION_ATOM = atom( + null, + (_get, set, apiDefinition: ApiDefinition.ApiDefinition, force?: boolean) => { + set(SETTABLE_APIS_ATOM, (prev) => { + const prevDefinition = prev[apiDefinition.id]; + if (prevDefinition == null) { + return { ...prev, [apiDefinition.id]: apiDefinition }; + } + const merged = joiner(force)(prevDefinition, apiDefinition); + if (merged === prevDefinition) { + return prev; + } + return { ...prev, [apiDefinition.id]: merged }; + }); + }, +); + +export function useWriteApiDefinitionAtom(api: ApiDefinition.ApiDefinition | undefined): void { + const isLocalPreview = useIsLocalPreview(); + const set = useSetAtom(WRITE_API_DEFINITION_ATOM); + useEffect(() => { + if (api != null) { + set(api, isLocalPreview); } - return { ...prev, [apiDefinition.id]: merged }; - }); -}); + }, [api, set, isLocalPreview]); +} + +export function useWriteApiDefinitionsAtom( + apis: Record, +): void { + const isLocalPreview = useIsLocalPreview(); + const set = useSetAtom(WRITE_API_DEFINITION_ATOM); + useEffect(() => { + Object.values(apis).forEach((api) => { + set(api, isLocalPreview); + }); + }, [apis, set]); +} export const READ_APIS_ATOM = atom((get) => get(SETTABLE_APIS_ATOM)); diff --git a/packages/ui/app/src/docs/CustomMarkdownPage.tsx b/packages/ui/app/src/docs/CustomMarkdownPage.tsx index a39686ff22..60e616cfc0 100644 --- a/packages/ui/app/src/docs/CustomMarkdownPage.tsx +++ b/packages/ui/app/src/docs/CustomMarkdownPage.tsx @@ -1,6 +1,5 @@ -import { useSetAtom } from "jotai"; -import { ReactElement, useEffect } from "react"; -import { WRITE_API_DEFINITION_ATOM } from "../atoms"; +import { ReactElement } from "react"; +import { useWriteApiDefinitionsAtom } from "../atoms"; import { MdxContent } from "../mdx/MdxContent"; import { DocsContent } from "../resolver/DocsContent"; @@ -9,12 +8,6 @@ interface CustomMarkdownPageProps { } export function CustomMarkdownPage({ content }: CustomMarkdownPageProps): ReactElement { - const set = useSetAtom(WRITE_API_DEFINITION_ATOM); - useEffect(() => { - Object.values(content.apis).forEach((api) => { - set(api); - }); - }, [content.apis, set]); - + useWriteApiDefinitionsAtom(content.apis); return ; } diff --git a/packages/ui/app/src/playground/hooks/useEndpointContext.ts b/packages/ui/app/src/playground/hooks/useEndpointContext.ts index 20bf2e006d..c7e2768eea 100644 --- a/packages/ui/app/src/playground/hooks/useEndpointContext.ts +++ b/packages/ui/app/src/playground/hooks/useEndpointContext.ts @@ -1,9 +1,8 @@ import { createEndpointContext, type ApiDefinition, type EndpointContext } from "@fern-api/fdr-sdk/api-definition"; import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; -import { useSetAtom } from "jotai"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import useSWRImmutable from "swr/immutable"; -import { WRITE_API_DEFINITION_ATOM } from "../../atoms"; +import { useWriteApiDefinitionAtom } from "../../atoms"; import { useApiRoute } from "../../hooks/useApiRoute"; interface LoadableEndpointContext { @@ -23,12 +22,7 @@ export function useEndpointContext(node: FernNavigation.EndpointNode | undefined ); const { data: apiDefinition, isLoading } = useSWRImmutable(node != null ? route : null, fetcher); const context = useMemo(() => createEndpointContext(node, apiDefinition), [node, apiDefinition]); - const set = useSetAtom(WRITE_API_DEFINITION_ATOM); - useEffect(() => { - if (apiDefinition != null) { - set(apiDefinition); - } - }, [apiDefinition, set]); + useWriteApiDefinitionAtom(apiDefinition); return { context, isLoading }; }