Skip to content

Commit 807e2f4

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(selectors): scope alternate selector caches safely
1 parent 04b3b97 commit 807e2f4

8 files changed

Lines changed: 318 additions & 26 deletions

apps/sim/hooks/queries/dynamic-subblock-options.test.tsx

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import {
1919
dynamicSubBlockOptionKeys,
2020
useDynamicSubBlockOptionDisplayName,
2121
} from '@/hooks/queries/dynamic-subblock-options'
22-
import type { SelectorDefinition, SelectorKey } from '@/hooks/selectors/types'
22+
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
23+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
24+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
25+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
2326

2427
/** Any registered key; the hook only uses it to look the definition up. */
2528
const SELECTOR_KEY = 'workspace.credentialGroups' as SelectorKey
@@ -30,6 +33,7 @@ function mockDefinition(definition: Partial<SelectorDefinition>) {
3033

3134
interface HookHarness<T> {
3235
result: () => T
36+
queryClient: QueryClient
3337
unmount: () => void
3438
}
3539

@@ -55,6 +59,7 @@ function renderHookWithClient<T>(useHook: () => T): HookHarness<T> {
5559

5660
return {
5761
result: () => latest,
62+
queryClient,
5863
unmount: () => act(() => root.unmount()),
5964
}
6065
}
@@ -70,6 +75,9 @@ describe('useDynamicSubBlockOptionDisplayName', () => {
7075

7176
afterEach(() => {
7277
mounted.splice(0).forEach((unmount) => unmount())
78+
useWorkflowRegistry.setState({ activeWorkflowId: null })
79+
useWorkflowStore.setState({ blocks: {} })
80+
useSubBlockStore.setState({ workflowValues: {} })
7381
vi.clearAllMocks()
7482
})
7583

@@ -147,4 +155,86 @@ describe('useDynamicSubBlockOptionDisplayName', () => {
147155
expect(keyFor('group-1')).not.toEqual(keyFor('group-2'))
148156
expect(keyFor('group-1')).toEqual(keyFor('group-1'))
149157
})
158+
159+
it('scopes server-resolved detail hydration without keying raw dependencies', async () => {
160+
const fetchById = vi.fn(async ({ context, detailId }: SelectorQueryArgs) => {
161+
if (context.domain === 'DYNAMIC_PRIVATE_SENTINEL_B') {
162+
throw new Error('tenant B is unavailable')
163+
}
164+
return { id: detailId as string, label: 'Tenant A project' }
165+
})
166+
mockDefinition({
167+
key: SELECTOR_KEY,
168+
serverResolvedContextFields: ['domain'],
169+
getQueryKey: () => ['selectors', SELECTOR_KEY],
170+
fetchById,
171+
})
172+
const subBlock = {
173+
id: 'projectId',
174+
title: 'Project',
175+
type: 'project-selector',
176+
selectorKey: SELECTOR_KEY,
177+
} satisfies SubBlockConfig
178+
179+
useWorkflowRegistry.setState({ activeWorkflowId: 'workflow-1' })
180+
useWorkflowStore.setState({
181+
blocks: {
182+
'block-1': {
183+
id: 'block-1',
184+
type: 'jira',
185+
subBlocks: {},
186+
data: {},
187+
} as never,
188+
},
189+
})
190+
useSubBlockStore.setState({
191+
workflowValues: {
192+
'workflow-1': {
193+
'block-1': {
194+
credential: 'credential-1',
195+
domain: 'DYNAMIC_PRIVATE_SENTINEL_A',
196+
},
197+
},
198+
},
199+
})
200+
201+
const hook = renderHookWithClient(() =>
202+
useDynamicSubBlockOptionDisplayName({
203+
workspaceId: 'workspace-1',
204+
blockId: 'block-1',
205+
subBlock,
206+
value: 'project-1',
207+
})
208+
)
209+
mounted.push(hook.unmount)
210+
211+
await waitForResult(() => expect(hook.result()).toBe('Tenant A project'))
212+
213+
act(() => {
214+
useSubBlockStore.setState({
215+
workflowValues: {
216+
'workflow-1': {
217+
'block-1': {
218+
credential: 'credential-1',
219+
domain: 'DYNAMIC_PRIVATE_SENTINEL_B',
220+
},
221+
},
222+
},
223+
})
224+
})
225+
226+
await waitForResult(() => expect(fetchById).toHaveBeenCalledTimes(2))
227+
await waitForResult(() => expect(hook.result()).toBeNull())
228+
229+
const contexts = fetchById.mock.calls.map(([args]) => args.context)
230+
expect(contexts[0].selectorCacheScope).not.toBe(contexts[1].selectorCacheScope)
231+
expect(
232+
JSON.stringify(
233+
hook.queryClient
234+
.getQueryCache()
235+
.getAll()
236+
.map((query) => query.queryKey)
237+
)
238+
).not.toContain('DYNAMIC_PRIVATE_SENTINEL')
239+
})
150240
})

apps/sim/hooks/queries/dynamic-subblock-options.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import { useQueries } from '@tanstack/react-query'
33
import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context'
44
import { summarizeNames } from '@/lib/workflows/subblocks/display'
55
import type { SubBlockConfig } from '@/blocks/types'
6+
import {
7+
createSelectorCacheScopeRegistry,
8+
scopeServerResolvedSelectorContext,
9+
} from '@/hooks/selectors/context-resolution'
610
import { getSelectorDefinition } from '@/hooks/selectors/registry'
711
import type { SelectorContext } from '@/hooks/selectors/types'
12+
import { getScopedSelectorQueryKey } from '@/hooks/selectors/use-selector-query'
813
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
914
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
1015
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -62,6 +67,7 @@ export function useDynamicSubBlockOptionDisplayName({
6267
subBlock,
6368
value,
6469
}: UseDynamicSubBlockOptionDisplayNameArgs): string | null {
70+
const selectorCacheScopes = useMemo(() => createSelectorCacheScopeRegistry(), [])
6571
const optionIds = useMemo(() => getResolvableOptionIds(value), [value])
6672
// Label resolution follows the option source: a selector's own `fetchById`. There is no
6773
// per-block resolver any more, so a selector without one simply renders the raw id.
@@ -93,15 +99,28 @@ export function useDynamicSubBlockOptionDisplayName({
9399
})
94100
}, [block, liveValues, activeWorkflowId, workspaceId])
95101

102+
const scopedResolverContext = useMemo(
103+
() =>
104+
definition
105+
? scopeServerResolvedSelectorContext(definition, resolverContext, selectorCacheScopes)
106+
: resolverContext,
107+
[definition, resolverContext, selectorCacheScopes]
108+
)
109+
96110
/**
97111
* The selector's own key for this context. Reusing it means the cache is scoped by exactly
98112
* what the selector reads — no second list of context fields to keep in step, and it stays
99113
* correct when a selector's dependencies change.
100114
*/
101115
const selectorScope = useMemo(
102116
() =>
103-
definition ? definition.getQueryKey({ key: definition.key, context: resolverContext }) : [],
104-
[definition, resolverContext]
117+
definition
118+
? getScopedSelectorQueryKey(definition, {
119+
key: definition.key,
120+
context: scopedResolverContext,
121+
})
122+
: [],
123+
[definition, scopedResolverContext]
105124
)
106125
const canResolve = Boolean(blockId && fetchById && optionIds.length > 0)
107126

@@ -121,7 +140,7 @@ export function useDynamicSubBlockOptionDisplayName({
121140
}
122141
return fetchById({
123142
key: definition.key,
124-
context: resolverContext,
143+
context: scopedResolverContext,
125144
detailId: optionId,
126145
signal,
127146
})

apps/sim/hooks/queries/workflow-search-replace.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { describe, expect, it } from 'vitest'
55
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
66
import {
77
buildWorkflowSearchMcpToolReplacementOptions,
8+
buildWorkflowSearchSelectorScope,
89
flattenWorkflowSearchReplacementOptions,
910
workflowSearchReplaceKeys,
1011
} from '@/hooks/queries/workflow-search-replace'
12+
import { createSelectorCacheScopeRegistry } from '@/hooks/selectors/context-resolution'
13+
import type { SelectorDefinition, SelectorKey } from '@/hooks/selectors/types'
1114

1215
function createMcpToolMatch(serverId?: string): WorkflowSearchMatch {
1316
return {
@@ -107,6 +110,59 @@ describe('workflowSearchReplaceKeys', () => {
107110
'{"oauthCredential":"credential-1","workspaceId":"workspace-1"}',
108111
])
109112
})
113+
114+
it('uses opaque scoped identities for server-resolved selectors', () => {
115+
const revisions = ['revision-a', 'revision-b'][Symbol.iterator]()
116+
const registry = createSelectorCacheScopeRegistry(() => revisions.next().value ?? 'unexpected')
117+
const definition = {
118+
key: 'jira.projects' as SelectorKey,
119+
serverResolvedContextFields: ['domain'],
120+
getQueryKey: () => ['selectors', 'jira.projects', 'credential-1'],
121+
} as SelectorDefinition
122+
123+
const first = buildWorkflowSearchSelectorScope(
124+
definition,
125+
{
126+
workspaceId: 'workspace-1',
127+
workflowId: 'workflow-1',
128+
domain: 'WORKFLOW_SEARCH_PRIVATE_SENTINEL_A',
129+
},
130+
registry
131+
)
132+
const changed = buildWorkflowSearchSelectorScope(
133+
definition,
134+
{
135+
workspaceId: 'workspace-1',
136+
workflowId: 'workflow-1',
137+
domain: 'WORKFLOW_SEARCH_PRIVATE_SENTINEL_B',
138+
},
139+
registry
140+
)
141+
142+
expect(first.identity).not.toEqual(changed.identity)
143+
expect(JSON.stringify(first.identity)).not.toContain('WORKFLOW_SEARCH_PRIVATE_SENTINEL')
144+
expect(first.context).toMatchObject({
145+
domain: 'WORKFLOW_SEARCH_PRIVATE_SENTINEL_A',
146+
selectorCacheScope: 'revision-a',
147+
})
148+
})
149+
150+
it('retains serialized context identity for legacy selectors', () => {
151+
const definition = {
152+
key: 'gmail.labels' as SelectorKey,
153+
getQueryKey: () => ['selectors', 'gmail.labels'],
154+
} as SelectorDefinition
155+
const context = { oauthCredential: 'credential-1', workspaceId: 'workspace-1' }
156+
157+
const scoped = buildWorkflowSearchSelectorScope(
158+
definition,
159+
context,
160+
createSelectorCacheScopeRegistry(() => 'unused')
161+
)
162+
163+
expect(scoped.context).toBe(context)
164+
expect(scoped.identity).toBe('{"oauthCredential":"credential-1","workspaceId":"workspace-1"}')
165+
})
110166
})
111167

112168
describe('flattenWorkflowSearchReplacementOptions', () => {

apps/sim/hooks/queries/workflow-search-replace.ts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,19 @@ import {
3535
fetchOAuthCredentials,
3636
} from '@/hooks/queries/oauth/oauth-credentials'
3737
import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree'
38+
import {
39+
createSelectorCacheScopeRegistry,
40+
type SelectorCacheScopeRegistry,
41+
scopeServerResolvedSelectorContext,
42+
} from '@/hooks/selectors/context-resolution'
3843
import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry'
39-
import type { SelectorKey, SelectorOption } from '@/hooks/selectors/types'
44+
import type {
45+
SelectorContext,
46+
SelectorDefinition,
47+
SelectorKey,
48+
SelectorOption,
49+
} from '@/hooks/selectors/types'
50+
import { getScopedSelectorQueryKey } from '@/hooks/selectors/use-selector-query'
4051
import type { WorkflowFolder } from '@/stores/folders/types'
4152

4253
/** Stable identity while a folder list loads, so `select` isn't re-keyed on it. */
@@ -92,19 +103,26 @@ export const workflowSearchReplaceKeys = {
92103
knowledgeReplacementOptions: (workspaceId?: string) =>
93104
[...workflowSearchReplaceKeys.replacementOptions(), 'knowledge', workspaceId ?? ''] as const,
94105
selectorDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'selector'] as const,
95-
selectorDetail: (selectorKey?: string, contextKey?: string, value?: string) =>
106+
selectorDetail: (
107+
selectorKey?: string,
108+
contextIdentity?: string | readonly unknown[],
109+
value?: string
110+
) =>
96111
[
97112
...workflowSearchReplaceKeys.selectorDetails(),
98113
selectorKey ?? '',
99-
contextKey ?? '',
114+
contextIdentity ?? '',
100115
value ?? '',
101116
] as const,
102-
selectorReplacementOptions: (selectorKey?: string, contextKey?: string) =>
117+
selectorReplacementOptions: (
118+
selectorKey?: string,
119+
contextIdentity?: string | readonly unknown[]
120+
) =>
103121
[
104122
...workflowSearchReplaceKeys.replacementOptions(),
105123
'selector',
106124
selectorKey ?? '',
107-
contextKey ?? '',
125+
contextIdentity ?? '',
108126
] as const,
109127
}
110128

@@ -139,6 +157,25 @@ function selectorContextKey(match: WorkflowSearchMatch): string {
139157
return stableStringifyWorkflowSearchValue(match.resource?.selectorContext ?? {})
140158
}
141159

160+
export function buildWorkflowSearchSelectorScope(
161+
definition: SelectorDefinition,
162+
context: SelectorContext,
163+
registry: SelectorCacheScopeRegistry
164+
): { context: SelectorContext; identity: string | readonly unknown[] } {
165+
if (!definition.serverResolvedContextFields?.length) {
166+
return { context, identity: stableStringifyWorkflowSearchValue(context) }
167+
}
168+
169+
const scopedContext = scopeServerResolvedSelectorContext(definition, context, registry)
170+
return {
171+
context: scopedContext,
172+
identity: getScopedSelectorQueryKey(definition, {
173+
key: definition.key,
174+
context: scopedContext,
175+
}),
176+
}
177+
}
178+
142179
function uniqueSelectorDetailMatches(matches: WorkflowSearchMatch[]): WorkflowSearchMatch[] {
143180
const seen = new Set<string>()
144181
return matches.filter((match) => {
@@ -378,18 +415,22 @@ export function useWorkflowSearchMcpToolDetails(
378415

379416
export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) {
380417
const selectorMatches = useMemo(() => uniqueSelectorDetailMatches(matches), [matches])
418+
const cacheScopes = useMemo(() => createSelectorCacheScopeRegistry(), [])
381419

382420
return useQueries({
383421
queries: selectorMatches.map((match) => {
384422
const selectorKey = match.resource?.selectorKey as SelectorKey
385-
const context = match.resource?.selectorContext ?? {}
386-
const contextKey = selectorContextKey(match)
387423
const definition = getSelectorDefinition(selectorKey)
424+
const { context, identity } = buildWorkflowSearchSelectorScope(
425+
definition,
426+
match.resource?.selectorContext ?? {},
427+
cacheScopes
428+
)
388429
const queryArgs = { key: selectorKey, context, detailId: match.rawValue }
389430
const baseEnabled = definition.enabled ? definition.enabled(queryArgs) : true
390431

391432
return {
392-
queryKey: workflowSearchReplaceKeys.selectorDetail(selectorKey, contextKey, match.rawValue),
433+
queryKey: workflowSearchReplaceKeys.selectorDetail(selectorKey, identity, match.rawValue),
393434
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<SelectorOption | null> => {
394435
if (definition.fetchById) {
395436
return definition.fetchById({ ...queryArgs, signal })
@@ -652,18 +693,22 @@ export function useWorkflowSearchMcpToolReplacementOptions(
652693

653694
export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSearchMatch[]) {
654695
const selectorGroups = useMemo(() => uniqueSelectorOptionGroups(matches), [matches])
696+
const cacheScopes = useMemo(() => createSelectorCacheScopeRegistry(), [])
655697

656698
return useQueries({
657699
queries: selectorGroups.map((match) => {
658700
const selectorKey = match.resource?.selectorKey as SelectorKey
659-
const context = match.resource?.selectorContext ?? {}
660-
const contextKey = selectorContextKey(match)
661701
const definition = getSelectorDefinition(selectorKey)
702+
const { context, identity } = buildWorkflowSearchSelectorScope(
703+
definition,
704+
match.resource?.selectorContext ?? {},
705+
cacheScopes
706+
)
662707
const queryArgs = { key: selectorKey, context }
663708
const baseEnabled = definition.enabled ? definition.enabled(queryArgs) : true
664709

665710
return {
666-
queryKey: workflowSearchReplaceKeys.selectorReplacementOptions(selectorKey, contextKey),
711+
queryKey: workflowSearchReplaceKeys.selectorReplacementOptions(selectorKey, identity),
667712
queryFn: ({ signal }: { signal: AbortSignal }) =>
668713
loadAllSelectorOptions(definition, { ...queryArgs, signal }),
669714
enabled: Boolean(selectorKey && baseEnabled),

0 commit comments

Comments
 (0)