diff --git a/.github/workflows/deploy-docs-bundle-preview.yml b/.github/workflows/deploy-docs-bundle-preview.yml index 28795abf4c..23308eb10c 100644 --- a/.github/workflows/deploy-docs-bundle-preview.yml +++ b/.github/workflows/deploy-docs-bundle-preview.yml @@ -63,12 +63,30 @@ jobs: filePath: preview.txt comment_tag: pr_preview + analyze: + needs: ignore + if: needs.ignore.outputs.continue == 1 + runs-on: ubuntu-latest + permissions: write-all # required for the pr-preview comment + steps: + # set the ref to a specific branch so that the deployment is scoped to that branch (instead of a headless ref) + - uses: actions/checkout@v4 + with: + fetch-depth: 2 # used for turbo-ignore + ref: ${{ github.event.pull_request.head.ref || github.ref_name || github.ref }} + + - uses: ./.github/actions/install + + - name: Build + id: deploy + run: pnpm vercel-scripts deploy app.buildwithfern.com --token=${{ secrets.VERCEL_TOKEN }} --skip-deploy=true + env: + ANALYZE: 1 + - name: Analyze bundle - if: steps.deploy.outputs.deployment_url run: pnpm --package=nextjs-bundle-analysis dlx report - name: Upload analysis - if: steps.deploy.outputs.deployment_url uses: actions/upload-artifact@v4 with: name: analyze @@ -76,7 +94,7 @@ jobs: - name: Download base branch bundle stats uses: dawidd6/action-download-artifact@v2 - if: steps.deploy.outputs.deployment_url && success() && github.event.number + if: success() && github.event.number with: workflow: nextjs_bundle_analysis.yml branch: ${{ github.event.pull_request.base.ref }} @@ -84,11 +102,11 @@ jobs: # https://infrequently.org/2021/03/the-performance-inequality-gap/ - name: Compare with base branch bundle - if: steps.deploy.outputs.deployment_url && success() && github.event.number + if: success() && github.event.number run: ls -laR packages/ui/docs-bundle/.next/analyze/base && pnpm --package=nextjs-bundle-analysis dlx compare - name: Comment PR Bundle Analysis - if: steps.deploy.outputs.deployment_url && github.event_name == 'pull_request' + if: github.event_name == 'pull_request' uses: thollander/actions-comment-pull-request@v2 with: filePath: packages/ui/docs-bundle/.next/analyze/__bundle_analysis_comment.txt diff --git a/clis/vercel-scripts/src/utils/deployer.ts b/clis/vercel-scripts/src/utils/deployer.ts index 492466179f..9f6fe0a655 100644 --- a/clis/vercel-scripts/src/utils/deployer.ts +++ b/clis/vercel-scripts/src/utils/deployer.ts @@ -74,8 +74,16 @@ export class VercelDeployer { }); } - private async deploy(project: { id: string; name: string }): Promise { - let command = `pnpx vercel deploy --yes --prebuilt --token=${this.token} --archive=tgz`; + private async deploy( + project: { id: string; name: string }, + opts?: { prebuilt?: boolean }, + ): Promise { + let command = `pnpx vercel deploy --yes --token=${this.token} --archive=tgz`; + + if (opts?.prebuilt) { + command += " --prebuilt"; + } + if (this.environment === "production") { command += " --prod --skip-domain"; } @@ -121,13 +129,13 @@ export class VercelDeployer { this.pull(prj); - this.build(prj); - if (skipDeploy) { + // build-only + this.build(prj); return; } - const deployment = await this.deploy(prj); + const deployment = await this.deploy(prj, { prebuilt: false }); await this.promote(deployment); diff --git a/packages/ui/app/src/atoms/launchdarkly.ts b/packages/ui/app/src/atoms/launchdarkly.ts index 20965eb34d..d02152d98e 100644 --- a/packages/ui/app/src/atoms/launchdarkly.ts +++ b/packages/ui/app/src/atoms/launchdarkly.ts @@ -1,8 +1,7 @@ import { atom, useAtomValue, useSetAtom } from "jotai"; import * as LDClient from "launchdarkly-js-client-sdk"; import { useCallback, useEffect, useState } from "react"; -import useSWR from "swr"; -import { useApiRoute } from "../hooks/useApiRoute"; +import { useApiRouteSWR } from "../hooks/useApiRouteSWR"; // NOTE do not export this file in any index.ts file so that it can be properly tree-shaken // otherwise we risk importing launchdarkly-js-client-sdk in all of our bundles @@ -85,9 +84,8 @@ export const useLaunchDarklyFlag = (flag: string, equals = true, not = false): b // since useSWR is cached globally, we can use this hook in multiple components without worrying about multiple requests function useInitLaunchDarklyClient() { - const route = useApiRoute("/api/fern-docs/integrations/launchdarkly"); const setInfo = useSetAtom(SET_LAUNCH_DARKLY_INFO_ATOM); - useSWR(route, (key): Promise => fetch(key).then((res) => res.json()), { + useApiRouteSWR("/api/fern-docs/integrations/launchdarkly", { onSuccess(data) { void setInfo(data); }, diff --git a/packages/ui/app/src/header/Header.tsx b/packages/ui/app/src/header/Header.tsx index 172d753386..798efc292a 100644 --- a/packages/ui/app/src/header/Header.tsx +++ b/packages/ui/app/src/header/Header.tsx @@ -31,7 +31,7 @@ const UnmemoizedHeader = forwardRef extends SWRConfiguration> { + disabled?: boolean; + request?: RequestInit; +} + +function createFetcher(init?: RequestInit): (url: string) => Promise { + return (url: string): Promise => fetch(withSkewProtection(url), init).then((r) => r.json()); +} + +export function useApiRouteSWR(route: FernDocsApiRoute, options?: Options): SWRResponse { + const key = useApiRoute(route); + return useSWR(options?.disabled ? null : key, createFetcher(options?.request), options); +} + +export function useApiRouteSWRImmutable(route: FernDocsApiRoute, options?: Options): SWRResponse { + const key = useApiRoute(route); + return useSWRImmutable(options?.disabled ? null : key, createFetcher(options?.request), options); +} diff --git a/packages/ui/app/src/hooks/useInterceptNextDataHref.ts b/packages/ui/app/src/hooks/useInterceptNextDataHref.ts index 902dccadda..5546e165a3 100644 --- a/packages/ui/app/src/hooks/useInterceptNextDataHref.ts +++ b/packages/ui/app/src/hooks/useInterceptNextDataHref.ts @@ -8,6 +8,7 @@ import { parseRelativeUrl } from "next/dist/shared/lib/router/utils/parse-relati import { removeTrailingSlash } from "next/dist/shared/lib/router/utils/remove-trailing-slash"; import { Router } from "next/router"; import { useEffect } from "react"; +import { withSkewProtection } from "../util/withSkewProtection"; /** * This function is adapted from https://github.com/vercel/next.js/blob/canary/packages/next/src/client/page-loader.ts @@ -25,7 +26,7 @@ function createPageLoaderGetDataHref(basePath: string | undefined): PageLoader[" const getHrefForSlug = (path: string) => { const dataRoute = getAssetPathFromRoute(removeTrailingSlash(addLocale(path, locale)), ".json"); - return addPathPrefix(`/_next/data/${buildId}${dataRoute}${search}`, basePath); + return addPathPrefix(`/_next/data/${buildId}${dataRoute}${withSkewProtection(search)}`, basePath); }; const toRet = getHrefForSlug( diff --git a/packages/ui/app/src/playground/hooks/useEndpointContext.ts b/packages/ui/app/src/playground/hooks/useEndpointContext.ts index c7e2768eea..0835eb14f7 100644 --- a/packages/ui/app/src/playground/hooks/useEndpointContext.ts +++ b/packages/ui/app/src/playground/hooks/useEndpointContext.ts @@ -1,26 +1,23 @@ import { createEndpointContext, type ApiDefinition, type EndpointContext } from "@fern-api/fdr-sdk/api-definition"; import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { useMemo } from "react"; -import useSWRImmutable from "swr/immutable"; import { useWriteApiDefinitionAtom } from "../../atoms"; -import { useApiRoute } from "../../hooks/useApiRoute"; +import { useApiRouteSWRImmutable } from "../../hooks/useApiRouteSWR"; interface LoadableEndpointContext { context: EndpointContext | undefined; isLoading: boolean; } -const fetcher = (url: string): Promise => fetch(url).then((res) => res.json()); - /** * This hook leverages SWR to fetch and cache the definition for this endpoint. * It should be refactored to store the resulting endpoint in a global state, so that it can be shared between components. */ export function useEndpointContext(node: FernNavigation.EndpointNode | undefined): LoadableEndpointContext { - const route = useApiRoute( + const { data: apiDefinition, isLoading } = useApiRouteSWRImmutable( `/api/fern-docs/api-definition/${encodeURIComponent(node?.apiDefinitionId ?? "")}/endpoint/${encodeURIComponent(node?.endpointId ?? "")}`, + { disabled: node == null }, ); - const { data: apiDefinition, isLoading } = useSWRImmutable(node != null ? route : null, fetcher); const context = useMemo(() => createEndpointContext(node, apiDefinition), [node, apiDefinition]); useWriteApiDefinitionAtom(apiDefinition); diff --git a/packages/ui/app/src/playground/hooks/useWebSocketContext.ts b/packages/ui/app/src/playground/hooks/useWebSocketContext.ts index 2922f57fd9..05e86458b8 100644 --- a/packages/ui/app/src/playground/hooks/useWebSocketContext.ts +++ b/packages/ui/app/src/playground/hooks/useWebSocketContext.ts @@ -3,26 +3,23 @@ import { createWebSocketContext } 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 useSWRImmutable from "swr/immutable"; import { WRITE_API_DEFINITION_ATOM } from "../../atoms"; -import { useApiRoute } from "../../hooks/useApiRoute"; +import { useApiRouteSWRImmutable } from "../../hooks/useApiRouteSWR"; interface LoadableWebSocketContext { context: WebSocketContext | undefined; isLoading: boolean; } -const fetcher = (url: string): Promise => fetch(url).then((res) => res.json()); - /** * This hook leverages SWR to fetch and cache the definition for this endpoint. * It should be refactored to store the resulting endpoint in a global state, so that it can be shared between components. */ export function useWebSocketContext(node: FernNavigation.WebSocketNode): LoadableWebSocketContext { - const route = useApiRoute( + const { data: apiDefinition, isLoading } = useApiRouteSWRImmutable( `/api/fern-docs/api-definition/${encodeURIComponent(node.apiDefinitionId)}/websocket/${encodeURIComponent(node.webSocketId)}`, + { disabled: node == null }, ); - const { data: apiDefinition, isLoading } = useSWRImmutable(route, fetcher); const context = useMemo(() => createWebSocketContext(node, apiDefinition), [node, apiDefinition]); const set = useSetAtom(WRITE_API_DEFINITION_ATOM); diff --git a/packages/ui/app/src/search/SearchDialog.tsx b/packages/ui/app/src/search/SearchDialog.tsx index 2e4bddbc0c..6cb38cad3e 100644 --- a/packages/ui/app/src/search/SearchDialog.tsx +++ b/packages/ui/app/src/search/SearchDialog.tsx @@ -33,7 +33,7 @@ export const SearchDialog = (): ReactElement | null => { const isSearchDialogOpen = useIsSearchDialogOpen(); const { isAiChatbotEnabledInPreview } = useFeatureFlags(); - const [config] = useSearchConfig(); + const config = useSearchConfig(); if (!config.isAvailable) { if (isSearchDialogOpen) { @@ -73,7 +73,7 @@ export const SearchSidebar: React.FC> = ( [activeVersion, sidebar], ); - const [searchConfig] = useSearchConfig(); + const searchConfig = useSearchConfig(); const algoliaSearchClient = useAlgoliaSearchClient(); const inputRef = useRef(null); const isMobileScreen = useAtomValue(IS_MOBILE_SCREEN_ATOM); diff --git a/packages/ui/app/src/search/algolia/useAlgoliaSearchClient.ts b/packages/ui/app/src/search/algolia/useAlgoliaSearchClient.ts index 80b69945e4..9b5f850e9a 100644 --- a/packages/ui/app/src/search/algolia/useAlgoliaSearchClient.ts +++ b/packages/ui/app/src/search/algolia/useAlgoliaSearchClient.ts @@ -7,7 +7,7 @@ import { useSearchConfig } from "../../services/useSearchService"; export function useAlgoliaSearchClient(): [SearchClient, index: string] | undefined { const currentVersionId = useAtomValue(CURRENT_VERSION_ID_ATOM); - const [searchConfig] = useSearchConfig(); + const searchConfig = useSearchConfig(); return useMemo(() => { if (!searchConfig.isAvailable) { diff --git a/packages/ui/app/src/search/cohere/CohereChatButton.tsx b/packages/ui/app/src/search/cohere/CohereChatButton.tsx index 930f89363f..d29694a42b 100644 --- a/packages/ui/app/src/search/cohere/CohereChatButton.tsx +++ b/packages/ui/app/src/search/cohere/CohereChatButton.tsx @@ -9,7 +9,7 @@ import { useSearchConfig } from "../../services/useSearchService"; import { CohereChatbotModal } from "./CohereChatbotModal"; export function CohereChatButton(): ReactElement | null { - const [config] = useSearchConfig(); + const config = useSearchConfig(); const [enabled, setEnabled] = useAtom(COHERE_ASK_AI); // Close the dialog when the route changes diff --git a/packages/ui/app/src/search/inkeep/useInkeepSettings.ts b/packages/ui/app/src/search/inkeep/useInkeepSettings.ts index a06363e9ef..fac35e9602 100644 --- a/packages/ui/app/src/search/inkeep/useInkeepSettings.ts +++ b/packages/ui/app/src/search/inkeep/useInkeepSettings.ts @@ -17,7 +17,7 @@ const useInkeepSettings = (): } | undefined => { const theme = useTheme(); - const [searchConfig] = useSearchConfig(); + const searchConfig = useSearchConfig(); if (!searchConfig.isAvailable || searchConfig.inkeep == null) { return; diff --git a/packages/ui/app/src/services/useApiKeyInjectionConfig.ts b/packages/ui/app/src/services/useApiKeyInjectionConfig.ts index feac8ea98e..23254e3dd2 100644 --- a/packages/ui/app/src/services/useApiKeyInjectionConfig.ts +++ b/packages/ui/app/src/services/useApiKeyInjectionConfig.ts @@ -1,20 +1,11 @@ import type { APIKeyInjectionConfig } from "@fern-ui/fern-docs-auth"; -import useSWR from "swr"; -import { useApiRoute } from "../hooks/useApiRoute"; +import { useApiRouteSWR } from "../hooks/useApiRouteSWR"; const DEFAULT = { enabled: false as const }; export function useApiKeyInjectionConfig(): APIKeyInjectionConfig { - const key = useApiRoute("/api/fern-docs/auth/api-key-injection"); - const { data } = useSWR( - key, - async (url: string) => { - const res = await fetch(url); - return res.json(); - }, - { - refreshInterval: (latestData) => (latestData?.enabled ? 1000 * 60 * 5 : 0), // refresh every 5 minutes - }, - ); + const { data } = useApiRouteSWR("/api/fern-docs/auth/api-key-injection", { + refreshInterval: (latestData) => (latestData?.enabled ? 1000 * 60 * 5 : 0), // refresh every 5 minutes + }); return data ?? DEFAULT; } diff --git a/packages/ui/app/src/services/useSearchService.ts b/packages/ui/app/src/services/useSearchService.ts index e02c9dd5ab..4b4844a455 100644 --- a/packages/ui/app/src/services/useSearchService.ts +++ b/packages/ui/app/src/services/useSearchService.ts @@ -1,10 +1,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ import type { SearchConfig } from "@fern-ui/search-utils"; -import { useCallback } from "react"; -import useSWR, { mutate } from "swr"; -import { noop } from "ts-essentials"; import { useIsLocalPreview } from "../contexts/local-preview"; -import { useApiRoute } from "../hooks/useApiRoute"; +import { useApiRouteSWR } from "../hooks/useApiRouteSWR"; export type SearchCredentials = { appId: string; @@ -25,22 +22,17 @@ export declare namespace SearchService { export type SearchService = SearchService.Available | SearchService.Unavailable; -export function useSearchConfig(): [SearchConfig, refresh: () => void] { +export function useSearchConfig(): SearchConfig { const isLocalPreview = useIsLocalPreview(); if (isLocalPreview) { - return [{ isAvailable: false }, noop]; + return { isAvailable: false }; } - const key = useApiRoute("/api/fern-docs/search"); - const { data } = useSWR(key, (url: string) => fetch(url).then((res) => res.json()), { + const { data } = useApiRouteSWR("/api/fern-docs/search", { refreshInterval: 1000 * 60 * 60 * 2, // 2 hours revalidateOnFocus: false, }); - const refresh = useCallback(() => { - void mutate(key); - }, [key]); - - return [data ?? { isAvailable: false }, refresh]; + return data ?? { isAvailable: false }; } diff --git a/packages/ui/app/src/sidebar/SidebarSearchBar.tsx b/packages/ui/app/src/sidebar/SidebarSearchBar.tsx index ed6c2b57d9..9e7ddbc9e3 100644 --- a/packages/ui/app/src/sidebar/SidebarSearchBar.tsx +++ b/packages/ui/app/src/sidebar/SidebarSearchBar.tsx @@ -16,7 +16,7 @@ export const SidebarSearchBar: React.FC = memo(function hideKeyboardShortcutHint, }) { const openSearchDialog = useOpenSearchDialog(); - const [searchService] = useSearchConfig(); + const searchService = useSearchConfig(); return (