fix(studio): fall back for framework icons without an asset (#51065)

I made the connected-project framework icons fall back when no shipped
SVG exists for a framework. The three icon sites built
`/img/icons/frameworks/<framework>.svg` straight from the integration's
framework preset, which is an open-ended string. They only fell back
when the value was empty, so any preset without an asset (`express`,
`hono`, `fastapi`, `tanstack-start` and others) showed a broken image
and logged a 404.

**Changed:**
- **Broken framework icons**: `getFrameworkIconUrl` returns the asset
URL only for slugs in a set that mirrors `public/img/icons/frameworks/`.
The integration connection row, the org project linker and the
marketplace project picker now show their existing fallback icon for any
other slug. A test keeps the set equal to the directory listing.
- **Framework type**: I deleted the hand-kept `VercelFramework` union.
It listed exactly the shipped icon slugs, while the API types the field
as `string | null`, and that mismatch is what made the old empty-only
check look safe.

**Note:** I rejected an `onError` fallback because the browser still
sends the 404 request. Adding logos for common presets is left for
design.

## To test

Tested on Vercel preview (staging): no real connection there uses these
presets, so I rewrote the org integrations response in the browser to
give one integration four connections.
- [x] Open an org's Integrations page with connections whose framework
has no shipped icon (`express`, `eve`, `tanstack-start-lovable`). Expect
the fallback badge and no request under
`/dashboard/img/icons/frameworks/` for those slugs. Observed: all three
rows showed the badge and the network log had no request for their SVGs.
- [x] Same page with a `nextjs` connection. Expect its framework logo.
Observed: `nextjs.svg` loaded with a 200.
- [x] Same page with the real, unmodified response (one connection with
`framework: null`). Expect the badge, no frameworks requests, and no new
console errors. Observed: as expected.

## Linear
- fixes GROWTH-1309


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Vercel integration and project views now display framework icons when
available and fall back to the Vercel icon when no matching icon exists.
* Framework metadata now supports values beyond a fixed list, while
unsupported frameworks continue to use the fallback icon.

* **Tests**
* Added coverage for supported and unsupported framework icons,
including base-path handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pamela Chia authored and GitHub committed 2026-09-30 12:59:36 +08:00
1 parent 9690efeb42
commit 38b74af3f1
7 files changed
+127 -72

No files matched your search

@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { IntegrationConnection } from './IntegrationPanels'
import type { IntegrationProjectConnection } from '@/data/integrations/integrations.types'
import { render } from '@/tests/helpers'
vi.mock('@/data/projects/project-detail-query', () => ({
useProjectDetailQuery: () => ({ data: undefined, isPending: false }),
}))
const connectionWithFramework = (framework: string) =>
({
id: 'connection-1',
supabase_project_ref: 'project-ref',
foreign_project_id: 'vercel-project-1',
metadata: { id: 'vercel-project-1', name: 'my-app', framework },
}) as IntegrationProjectConnection
const frameworkIconSelector = 'img[src*="/img/icons/frameworks/"]'
describe('IntegrationConnection', () => {
it('renders the fallback badge for a framework without a shipped icon', () => {
const { container } = render(
<IntegrationConnection type="Vercel" connection={connectionWithFramework('express')} />
)
expect(container.querySelector(frameworkIconSelector)).toBeNull()
})
it('renders the framework icon for a framework with a shipped icon', () => {
const { container } = render(
<IntegrationConnection type="Vercel" connection={connectionWithFramework('nextjs')} />
)
expect(container.querySelector(frameworkIconSelector)?.getAttribute('src')).toMatch(
/\/img\/icons\/frameworks\/nextjs\.svg$/
)
})
})
@@ -13,7 +13,7 @@ import type {
} from '@/data/integrations/integrations.types'
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
import { BASE_PATH } from '@/lib/constants'
import { getIntegrationConfigurationUrl } from '@/lib/integration-utils'
import { getFrameworkIconUrl, getIntegrationConfigurationUrl } from '@/lib/integration-utils'
const ICON_STROKE_WIDTH = 2
const ICON_SIZE = 14
@@ -150,6 +150,7 @@ export const IntegrationConnection = forwardRef<HTMLLIElement, IntegrationConnec
ref
) => {
const { data: project } = useProjectDetailQuery({ ref: connection.supabase_project_ref })
const frameworkIconUrl = getFrameworkIconUrl(connection?.metadata?.framework)
return (
<li
@@ -186,17 +187,12 @@ export const IntegrationConnection = forwardRef<HTMLLIElement, IntegrationConnec
/>
<div className="flex-1 min-w-0 flex gap-2 items-center">
{!connection?.metadata?.framework ? (
{!frameworkIconUrl ? (
<div className="bg-black text-white w-4 h-4 rounded-sm flex items-center justify-center">
<HandleIcon type={type} className={'w-2.5!'} />
</div>
) : (
<img
src={`${BASE_PATH}/img/icons/frameworks/${connection.metadata.framework}.svg`}
width={21}
height={21}
alt={`icon`}
/>
<img src={frameworkIconUrl} width={21} height={21} alt={`icon`} />
)}
{type === 'GitHub' ? (
<a
@@ -13,7 +13,7 @@ import { useIntegrationVercelConnectionsCreateMutation } from '@/data/integratio
import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { BASE_PATH } from '@/lib/constants'
import { getFrameworkIconUrl } from '@/lib/integration-utils'
import { EMPTY_ARR } from '@/lib/void'
import { useSidePanelsStateSnapshot } from '@/state/side-panels'
import type { ResponseError } from '@/types'
@@ -57,16 +57,12 @@ export const SidePanelVercelProjectLinker = () => {
const getForeignProjectIcon = useCallback(
(_project: ForeignProject) => {
const project = vercelProjectsById[_project.id]
const frameworkIconUrl = getFrameworkIconUrl(project?.framework)
return !project?.framework ? (
return !frameworkIconUrl ? (
vercelIcon
) : (
<img
src={`${BASE_PATH}/img/icons/frameworks/${project.framework}.svg`}
width={21}
height={21}
alt={`icon`}
/>
<img src={frameworkIconUrl} width={21} height={21} alt={`icon`} />
)
},
[vercelProjectsById]
@@ -1,50 +1,3 @@
export type VercelFramework =
| (
| 'blitzjs'
| 'nextjs'
| 'gatsby'
| 'remix'
| 'astro'
| 'hexo'
| 'eleventy'
| 'docusaurus-2'
| 'docusaurus'
| 'preact'
| 'solidstart'
| 'dojo'
| 'ember'
| 'vue'
| 'scully'
| 'ionic-angular'
| 'angular'
| 'polymer'
| 'svelte'
| 'sveltekit'
| 'sveltekit-1'
| 'ionic-react'
| 'create-react-app'
| 'gridsome'
| 'umijs'
| 'sapper'
| 'saber'
| 'stencil'
| 'nuxtjs'
| 'redwoodjs'
| 'hugo'
| 'jekyll'
| 'brunch'
| 'middleman'
| 'zola'
| 'hydrogen'
| 'vite'
| 'vitepress'
| 'vuepress'
| 'parcel'
| 'sanity'
| 'storybook'
)
| null
export type VercelGitLink =
| {
/**
@@ -142,7 +95,7 @@ export type Imetadata = {
}
link?: VercelGitLink
name: string
framework: VercelFramework
framework: string | null
}
export type IntegrationProjectConnection = {
+24
View File
@@ -1,6 +1,10 @@
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
FRAMEWORK_ICON_SLUGS,
getFrameworkIconUrl,
getInitialMigrationSQLFromGitHubRepo,
getIntegrationConfigurationUrl,
} from './integration-utils'
@@ -10,6 +14,7 @@ import type {
VercelAccount,
VercelTeamAccount,
} from '@/data/integrations/integrations.types'
import { BASE_PATH } from '@/lib/constants'
vi.mock('@/data/fetchers', () => ({
fetchHandler: vi.fn(),
@@ -255,4 +260,23 @@ describe('integration-utils', () => {
expect(result).toBe('')
})
})
describe('getFrameworkIconUrl', () => {
it('returns undefined for missing or unshipped frameworks and a URL for shipped ones', () => {
expect(getFrameworkIconUrl('not-a-framework')).toBeUndefined()
expect(getFrameworkIconUrl(null)).toBeUndefined()
expect(getFrameworkIconUrl(undefined)).toBeUndefined()
expect(getFrameworkIconUrl('')).toBeUndefined()
expect(getFrameworkIconUrl('nextjs')).toBe(`${BASE_PATH}/img/icons/frameworks/nextjs.svg`)
})
it('stays in sync with public/img/icons/frameworks/*.svg', () => {
const slugsOnDisk = readdirSync(join(__dirname, '../public/img/icons/frameworks'))
.filter((file) => file.endsWith('.svg'))
.map((file) => file.replace(/\.svg$/, ''))
.sort()
expect([...FRAMEWORK_ICON_SLUGS].sort()).toEqual(slugsOnDisk)
})
})
})
+51
View File
@@ -3,6 +3,7 @@ import { getCreateMigrationsTableSQL, getInsertMigrationSQL } from '@supabase/pg
import { isResponseOk } from './api/apiWrapper'
import { fetchHandler } from '@/data/fetchers'
import type { Integration } from '@/data/integrations/integrations.types'
import { BASE_PATH } from '@/lib/constants'
import { ResponseError, type SupaResponse } from '@/types'
async function fetchGitHub<T = any>(url: string, responseJson = true): Promise<SupaResponse<T>> {
@@ -132,3 +133,53 @@ function getGitHubConfigurationUrl(integration: GitHubIntegration) {
: ''
}settings/installations/${integration.metadata?.installation_id}`
}
export const FRAMEWORK_ICON_SLUGS: ReadonlySet<string> = new Set([
'angular',
'astro',
'blitzjs',
'brunch',
'create-react-app',
'docusaurus',
'docusaurus-2',
'dojo',
'eleventy',
'ember',
'gatsby',
'gridsome',
'hexo',
'hugo',
'hydrogen',
'ionic-angular',
'ionic-react',
'jekyll',
'middleman',
'nextjs',
'nuxtjs',
'parcel',
'polymer',
'preact',
'redwoodjs',
'remix',
'saber',
'sanity',
'sapper',
'scully',
'solidstart',
'stencil',
'storybook',
'svelte',
'sveltekit',
'sveltekit-1',
'umijs',
'vite',
'vitepress',
'vue',
'vuepress',
'zola',
])
export function getFrameworkIconUrl(framework?: string | null): string | undefined {
if (!framework || !FRAMEWORK_ICON_SLUGS.has(framework)) return undefined
return `${BASE_PATH}/img/icons/frameworks/${framework}.svg`
}
@@ -25,8 +25,8 @@ import { useIntegrationVercelConnectionsCreateMutation } from '@/data/integratio
import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { withAuth } from '@/hooks/misc/withAuth'
import { BASE_PATH } from '@/lib/constants'
import { getErrorMessage } from '@/lib/get-error-message'
import { getFrameworkIconUrl } from '@/lib/integration-utils'
import { hasVercelDeployButtonSignals } from '@/lib/integrations/vercel-install.utils'
import { buildStudioPageTitle } from '@/lib/page-title'
import { useProfileNameAndPicture } from '@/lib/profile'
@@ -83,16 +83,12 @@ const VercelChooseProjectPage: NextPageWithLayout = () => {
const getForeignProjectIcon = useCallback(
(_project: ForeignProject) => {
const project = vercelProjectsById[_project.id]
const frameworkIconUrl = getFrameworkIconUrl(project?.framework)
return !project?.framework ? (
return !frameworkIconUrl ? (
vercelIcon
) : (
<img
src={`${BASE_PATH}/img/icons/frameworks/${project.framework}.svg`}
width={21}
height={21}
alt="Framework icon"
/>
<img src={frameworkIconUrl} width={21} height={21} alt="Framework icon" />
)
},
[vercelProjectsById]