Skip to content

Commit 507cee1

Browse files
authored
fix(integrations): repair corrupt icons, backfill missing block metas, restore scroll on back-nav (#5342)
* fix(integrations): repair corrupt icons, backfill missing block metas, restore scroll on back-nav - Restore 7 brand icons (Google, Outlook, MongoDB, Postgres, OpenRouter, Groq, Cerebras) whose SVG path data was corrupted by a past bulk reformat, flooding the integrations page console with <path> parse errors; add a check:icon-paths CI gate that validates every icon d attribute (operand counts + arc flags). - Backfill BlockMeta (tags/url/templates/skills) for postgresql, mysql, ssh, sftp, smtp — previously catalog integrations with empty detail pages; add an integration meta-coverage CI check so every catalog block must have a meta. - Add scroll-position restoration for the integrations index/detail inner scroll containers so browser Back returns to where you were. - Remove the error digest pill from the shared workspace ErrorShell (kept in logs, dropped from UI). * fix(integrations): make scroll restoration robust — value-based echo detection + Back/Forward-only gate Addresses review: replace the racy programmatic-scroll flag with value comparison (a restore's echo equals lastApplied and is ignored, so a stuck flag can never drop the first user scroll or overwrite the saved target), and gate restoration on popstate history traversals so fresh push navigations open at the top instead of jumping mid-list. TSDoc-only comments. * fix(ci): attribute icon-path errors for export-const icons too Greptile review: iconNameAt only matched 'export function', so a malformed path inside an 'export const XxxIcon = (...)' arrow-function icon would be misattributed to the preceding function-declared icon. Match both forms (mirrors check-bare-icons indexIconBodies).
1 parent 4e8b88f commit 507cee1

17 files changed

Lines changed: 1043 additions & 74 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ jobs:
128128
- name: Bare-icon theme-safety audit
129129
run: bun run check:bare-icons
130130

131+
- name: Icon SVG path validity audit
132+
run: bun run check:icon-paths
133+
131134
- name: Verify realtime prune graph
132135
run: bun run check:realtime-prune
133136

apps/docs/components/icons.tsx

Lines changed: 31 additions & 31 deletions
Large diffs are not rendered by default.

apps/sim/app/workspace/[workspaceId]/components/error/error.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,15 @@ interface ErrorShellProps {
2525
title: string
2626
description: string
2727
icon?: ReactNode
28-
digest?: string
2928
children: ReactNode
3029
}
3130

3231
/**
3332
* Centered layout shared by the workspace error boundary and not-found page.
34-
* Renders a framed glyph, serif headline, supporting paragraph, optional
35-
* digest pill, and a row of action buttons.
33+
* Renders a framed glyph, serif headline, supporting paragraph, and a row of
34+
* action buttons.
3635
*/
37-
export function ErrorShell({ title, description, icon, digest, children }: ErrorShellProps) {
36+
export function ErrorShell({ title, description, icon, children }: ErrorShellProps) {
3837
return (
3938
<div className='flex h-full flex-1 items-center justify-center bg-[var(--bg)] px-6 py-12'>
4039
<div className='flex w-full max-w-[420px] flex-col items-center gap-5 text-center'>
@@ -51,12 +50,6 @@ export function ErrorShell({ title, description, icon, digest, children }: Error
5150
{description}
5251
</p>
5352
</div>
54-
{digest && (
55-
<span className='inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-1 font-mono text-[11px]'>
56-
<span className='text-[var(--text-muted)]'>digest</span>
57-
<span className='truncate text-[var(--text-body)]'>{digest}</span>
58-
</span>
59-
)}
6053
<div className='flex flex-wrap items-center justify-center gap-2 pt-1'>{children}</div>
6154
</div>
6255
</div>
@@ -85,7 +78,7 @@ export function ErrorState({
8578
}, [error.message, error.digest, loggerName])
8679

8780
return (
88-
<ErrorShell title={title} description={description} icon={icon} digest={error.digest}>
81+
<ErrorShell title={title} description={description} icon={icon}>
8982
{children}
9083
<Button variant='primary' size='md' onClick={reset}>
9184
Refresh

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integr
1919
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
2020
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
2121
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
22+
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
2223
import { getTileIconColorClass } from '@/blocks/icon-color'
2324
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
2425
import {
@@ -44,6 +45,7 @@ interface IntegrationBlockDetailProps {
4445
}
4546

4647
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
48+
const scrollContainerRef = useRef<HTMLDivElement>(null)
4749
useOAuthReturnRouter()
4850
const router = useRouter()
4951
const [connectMode, setConnectMode] = useQueryState(connectParam.key, connectParam.parser)
@@ -53,11 +55,13 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
5355
const oauthService = resolveOAuthServiceForIntegration(integration)
5456
const [oauthOpen, setOAuthOpen] = useState(false)
5557

56-
const { data: credentials = [] } = useWorkspaceCredentials({
58+
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
5759
workspaceId,
5860
enabled: Boolean(workspaceId),
5961
})
6062

63+
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
64+
6165
const connectedCredentials = useMemo(() => {
6266
if (!oauthService) return []
6367
return credentials.filter(
@@ -170,7 +174,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
170174
serviceIcon={oauthService.serviceIcon}
171175
/>
172176
)}
173-
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
177+
<div
178+
ref={scrollContainerRef}
179+
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
180+
>
174181
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
175182
<div className='flex flex-col gap-3'>
176183
{Icon ? (
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
'use client'
2+
3+
import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react'
4+
import { usePathname } from 'next/navigation'
5+
6+
/** Namespace prefix so restoration keys never collide with other tab state. */
7+
const STORAGE_PREFIX = 'integrations-scroll:' as const
8+
9+
/**
10+
* True when the most recent navigation was a Back/Forward history traversal.
11+
*
12+
* A single module-level `popstate` listener flips this before the destination
13+
* page mounts; each restore reads it once and clears it. Fresh push navigations
14+
* (a sidebar link, a typed URL) never fire `popstate`, so the flag stays false
15+
* and those visits open at the top instead of jumping to a stale position.
16+
* Registered at module load — a per-hook listener would attach too late to see
17+
* the `popstate` that triggered its own mount.
18+
*/
19+
let lastNavWasTraversal = false
20+
if (typeof window !== 'undefined') {
21+
window.addEventListener('popstate', () => {
22+
lastNavWasTraversal = true
23+
})
24+
}
25+
26+
interface UseScrollRestorationOptions {
27+
/**
28+
* Flag that flips to `true` once the async content the scroll height depends
29+
* on has settled (e.g. the credentials query is no longer pending). A late
30+
* restore is re-attempted when this transitions so we do not clamp against a
31+
* too-short container while data is still loading.
32+
*/
33+
ready?: boolean
34+
}
35+
36+
/**
37+
* Restores the scroll position of an inner scroll container across browser
38+
* Back/Forward navigation within the same tab.
39+
*
40+
* Next.js App Router only restores WINDOW scroll, so a page whose content
41+
* scrolls inside a nested `overflow-y-auto` element loses its position on Back.
42+
* This hook persists `scrollTop` in `sessionStorage` (per-pathname, tab-scoped)
43+
* and re-applies it — only on history traversals — once the container has laid
44+
* out.
45+
*
46+
* Programmatic vs. user scrolls are told apart by VALUE, not a one-shot event
47+
* flag: a restore assigns `scrollTop === lastAppliedRef`, so the echoed scroll
48+
* event compares equal and is ignored (it is never persisted, so a clamped
49+
* value cannot overwrite the saved target). This avoids the race where the
50+
* programmatic scroll event fires before the listener attaches and a stuck flag
51+
* drops the user's first real scroll. The restore itself latches only on a full
52+
* (non-clamped) apply, so a late `ready` retry can complete a position that was
53+
* clamped against still-loading content, and it stops the moment the user
54+
* scrolls away from the last applied value so it never fights them.
55+
*
56+
* @param containerRef Ref to the scrollable container element.
57+
* @param options `ready` marks async content as settled for a late retry.
58+
*/
59+
export function useScrollRestoration(
60+
containerRef: RefObject<HTMLDivElement | null>,
61+
{ ready = true }: UseScrollRestorationOptions = {}
62+
): void {
63+
const pathname = usePathname()
64+
const storageKey = `${STORAGE_PREFIX}${pathname}`
65+
66+
const storageKeyRef = useRef(storageKey)
67+
const hasRestoredRef = useRef(false)
68+
/** Last `scrollTop` this hook assigned, so its echo scroll event is ignored. */
69+
const lastAppliedRef = useRef(-1)
70+
/** Latest user-initiated `scrollTop`, flushed to storage on unmount. */
71+
const latestUserScrollRef = useRef<number | null>(null)
72+
const rafRef = useRef<number | null>(null)
73+
/** Captured once per mount: did we arrive here via Back/Forward? */
74+
const shouldRestoreRef = useRef<boolean | null>(null)
75+
76+
useEffect(() => {
77+
storageKeyRef.current = storageKey
78+
}, [storageKey])
79+
80+
useEffect(() => {
81+
const el = containerRef.current
82+
if (!el) return
83+
84+
const persist = (value: number) => {
85+
try {
86+
sessionStorage.setItem(storageKeyRef.current, String(value))
87+
} catch {}
88+
}
89+
90+
const onScroll = () => {
91+
if (el.scrollTop === lastAppliedRef.current) return
92+
latestUserScrollRef.current = el.scrollTop
93+
if (rafRef.current !== null) return
94+
rafRef.current = requestAnimationFrame(() => {
95+
rafRef.current = null
96+
persist(el.scrollTop)
97+
})
98+
}
99+
100+
el.addEventListener('scroll', onScroll, { passive: true })
101+
102+
return () => {
103+
el.removeEventListener('scroll', onScroll)
104+
if (rafRef.current !== null) {
105+
cancelAnimationFrame(rafRef.current)
106+
rafRef.current = null
107+
}
108+
if (latestUserScrollRef.current !== null) persist(latestUserScrollRef.current)
109+
}
110+
}, [containerRef])
111+
112+
useLayoutEffect(() => {
113+
const el = containerRef.current
114+
if (!el || hasRestoredRef.current) return
115+
116+
if (shouldRestoreRef.current === null) {
117+
shouldRestoreRef.current = lastNavWasTraversal
118+
lastNavWasTraversal = false
119+
}
120+
if (!shouldRestoreRef.current) {
121+
hasRestoredRef.current = true
122+
return
123+
}
124+
125+
if (lastAppliedRef.current !== -1 && el.scrollTop !== lastAppliedRef.current) {
126+
hasRestoredRef.current = true
127+
return
128+
}
129+
130+
let target = 0
131+
try {
132+
const raw = sessionStorage.getItem(storageKeyRef.current)
133+
target = raw ? Number(raw) : 0
134+
} catch {}
135+
if (!Number.isFinite(target) || target <= 0) {
136+
hasRestoredRef.current = true
137+
return
138+
}
139+
140+
const maxScroll = el.scrollHeight - el.clientHeight
141+
if (maxScroll <= 0) return
142+
143+
lastAppliedRef.current = Math.min(target, maxScroll)
144+
el.scrollTop = lastAppliedRef.current
145+
if (maxScroll >= target) hasRestoredRef.current = true
146+
}, [containerRef, ready])
147+
}

apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type ComponentType, useCallback, useMemo } from 'react'
3+
import { type ComponentType, useCallback, useMemo, useRef } from 'react'
44
import {
55
ArrowRight,
66
ChevronDown,
@@ -26,6 +26,7 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c
2626
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
2727
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
2828
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
29+
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
2930
import {
3031
ALL_CATEGORY,
3132
CONNECTED_LABEL,
@@ -135,6 +136,7 @@ function ConnectedItem({ href, blockType, name, description, icon: Icon }: Conne
135136
}
136137

137138
export function Integrations() {
139+
const scrollContainerRef = useRef<HTMLDivElement>(null)
138140
const params = useParams()
139141
const workspaceId = (params?.workspaceId as string) || ''
140142

@@ -163,6 +165,8 @@ export function Integrations() {
163165
enabled: Boolean(workspaceId),
164166
})
165167

168+
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
169+
166170
const oauthCredentials = useMemo(
167171
() => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'),
168172
[credentials]
@@ -289,7 +293,10 @@ export function Integrations() {
289293
return (
290294
<div className='flex h-full flex-col bg-[var(--bg)]'>
291295
<IntegrationTabsHeader active='integrations' workspaceId={workspaceId} />
292-
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
296+
<div
297+
ref={scrollContainerRef}
298+
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
299+
>
293300
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
294301
<ShowcaseWithExplore prompt='Explain the integrations in Sim and what I should connect.' />
295302
<div className='flex items-center gap-2'>

0 commit comments

Comments
 (0)