refactor(frontend-arch): migrate server state to React Query, collapse duplicate workflow-state cache, granular error boundaries (#5168)

* refactor(session): migrate SessionProvider to React Query useSessionQuery

Replace the hand-rolled useState/useEffect/loadSession session loading in
SessionProvider with a useSessionQuery() React Query hook. The SessionContext
shape is unchanged ({ data, isPending, error, refetch }) so no consumer changes.

The 'upgraded' path still forces a fresh DB read via
client.getSession({ query: { disableCookieCache: true } }) (refetch() cannot
pass disableCookieCache) and writes the result via queryClient.setQueryData,
then invalidates ['organizations']/['subscription'] as before.

* refactor(workflows): collapse duplicate workflow-state cache

The registry store fetched the GET /api/workflows/[id] envelope inline via
requestJson while useWorkflowState cached the same endpoint's mapped state
under workflowKeys.state(id) — two requests, two cache shapes, never
reconciled.

Collapse to one request + one cache entry keyed by workflowKeys.state(id):

- Add hooks/queries/utils/fetch-workflow-envelope.ts: a standalone
  fetchWorkflowEnvelope(id, signal) returning the full GetWorkflowResponseData.
  Standalone (not in workflows.ts) to avoid a store -> query-hook import cycle.
- useWorkflowState/useWorkflowStates now query the envelope and derive the
  mapped WorkflowState via select (mapWorkflowState), so consumers see the
  identical mapped shape from the shared entry.
- The store's loadWorkflowState reads via getQueryClient().fetchQuery({
  staleTime: 0 }) instead of raw requestJson — always-fresh (preserving the
  prior always-fetch boot/refresh semantics, incl. the socket
  handle-resource-event refresh path that has no separate state
  invalidation), in-flight deduped, writing into the same cache entry the
  hooks read.

Request-id staleness guard, deployment-cache priming, cross-store projection,
and the active-workflow-changed event are all preserved unchanged.

* fix(workspace): add granular error boundaries to logs, knowledge, and files panels

Scope a crash in one workspace panel to that panel instead of the whole
workspace shell. Each boundary reuses the shared ErrorState component and
mirrors the existing tables/settings error.tsx convention.

* refactor(unsubscribe): migrate page to React Query

Replace the hand-rolled useState+useEffect+requestJson server-state in the
unsubscribe page with React Query hooks. Add useUnsubscribe (validation/load
query, keyed by email+token, auto-runs on mount via enabled) and
useUnsubscribeMutation (unsubscribe action, reconciles cached preferences on
success) in hooks/queries/unsubscribe.ts with a hierarchical key factory.

Export UnsubscribeData/UnsubscribeActionResponse/UnsubscribeType type aliases
from the existing user contract; loading/error/success now derive from the
query and mutation objects with no local server-state mirror.

* test(frontend-arch): cover session race fix, workflow-state cache collapse, unsubscribe, error boundary

Add targeted tests for the four frontend-architecture refactors:
- session-provider: upgrade-path ordering — fresh disableCookieCache read wins
  over a late-resolving stale mount query (proves the cancelQueries guard)
- fetch-workflow-envelope + registry store: single shared state(id) cache entry,
  always-refetch (staleTime 0), request-id staleness guard
- unsubscribe: query enable-gating + mutation cache reconcile
- logs error boundary: renders ErrorState + reset wiring (also first ErrorState coverage)

* fix(session): harden upgrade path + address review feedback

- Reconcile plan surfaces after upgrade even when the fresh disableCookieCache
  read fails: invalidate ['organizations']/['subscription'] regardless of the
  bypass-read outcome (they read server truth, not the cookie cache). The valid
  cookie-cached session is still served, so a transient failure no longer signs
  the user out or leaves the just-upgraded plan looking stale. Org-activate
  fallback stays gated on having a session.
- Use a bare return in the cancelled branch of refreshAfterUpgrade (the caller
  discards the value) for clearer intent; caller coerces with ?? null.
- Make the upgrade tests deterministic: the mount mock honors the abort signal
  like the real fetch-backed client, and assertions read the query cache (the
  state cancelQueries/setQueryData/invalidation actually govern) instead of the
  async-rendered context value.

* refactor(session): break provider<->hook type cycle, fail-fast session query

Address review feedback:
- Move the AppSession type to lib/auth/session-response.ts (the module that
  produces it) so useSessionQuery and SessionProvider both import it from there,
  eliminating the provider <-> query-hook import cycle.
- Add retry: false to useSessionQuery, restoring the prior fail-fast contract
  (the global QueryClient default is retry: 1; an auth failure should surface
  immediately rather than retry a request that won't succeed).
- Return null (not the fetched value) from refreshAfterUpgrade's cancelled
  branch to make the cancellation contract explicit.
This commit is contained in:
Waleed
2026-06-22 12:44:07 -07:00
committed by GitHub
parent d8da1e2577
commit e96b150bc9
17 changed files with 1122 additions and 147 deletions
@@ -0,0 +1,272 @@
/**
* @vitest-environment jsdom
*/
import { act, useContext } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockSetActive, mockRequestJson } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockSetActive: vi.fn(),
mockRequestJson: vi.fn(),
}))
vi.mock('@/lib/auth/auth-client', () => ({
client: {
getSession: mockGetSession,
organization: { setActive: mockSetActive },
},
}))
vi.mock('@/lib/api/client/request', () => ({
requestJson: mockRequestJson,
}))
vi.mock('posthog-js', () => ({
default: {
identify: vi.fn(),
reset: vi.fn(),
startSessionRecording: vi.fn(),
sessionRecordingStarted: vi.fn(() => true),
},
}))
import type { AppSession } from '@/lib/auth/session-response'
import {
SessionContext,
type SessionHookResult,
SessionProvider,
} from '@/app/_shell/providers/session-provider'
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'
/** Deferred promise: lets a test resolve a mocked async call at a chosen moment. */
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
/** Set the jsdom URL search string before rendering the provider. */
function setSearch(search: string) {
window.history.replaceState({}, '', `/${search}`)
}
const STALE_SESSION: AppSession = {
user: { id: 'user-1', email: 'u@x.com', name: 'Stale plan' },
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
}
const FRESH_SESSION: AppSession = {
user: { id: 'user-1', email: 'u@x.com', name: 'Fresh plan' },
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
}
interface Harness {
ctx: () => SessionHookResult | null
queryClient: QueryClient
unmount: () => void
}
/**
* Mounts SessionProvider in a real React 19 root under jsdom with a real
* QueryClient, capturing the live context value via a probe consumer.
*/
function renderProvider(): Harness {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root: Root = createRoot(container)
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
let latest: SessionHookResult | null = null
function Probe() {
latest = useContext(SessionContext)
return null
}
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SessionProvider>
<Probe />
</SessionProvider>
</QueryClientProvider>
)
})
return {
ctx: () => latest,
queryClient,
unmount: () => act(() => root.unmount()),
}
}
/** Flush pending microtasks inside an act() boundary. */
async function flush() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
/** Repeatedly flush until `predicate` holds or the budget runs out. */
async function flushUntil(predicate: () => boolean, attempts = 40) {
for (let i = 0; i < attempts; i++) {
if (predicate()) return
await flush()
}
}
/** True when the getSession call is the upgrade (disableCookieCache) read. */
function isUpgradeCall(arg: unknown): boolean {
return Boolean(
arg &&
typeof arg === 'object' &&
'query' in (arg as Record<string, unknown>) &&
(arg as { query?: { disableCookieCache?: boolean } }).query?.disableCookieCache === true
)
}
describe('useSessionQuery', () => {
it('uses an all-rooted key factory and a 5-minute staleTime', () => {
expect(sessionKeys.all).toEqual(['session'])
expect(sessionKeys.detail()).toEqual(['session', 'detail'])
// The hook is exported and reads from the same detail key.
expect(typeof useSessionQuery).toBe('function')
})
})
describe('SessionProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
setSearch('')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes the contract context shape and the loaded session on a normal load', async () => {
mockGetSession.mockResolvedValue({ data: STALE_SESSION })
const h = renderProvider()
await flushUntil(() => h.ctx()?.data != null)
const ctx = h.ctx()
expect(ctx).not.toBeNull()
expect(ctx).toMatchObject({
data: expect.any(Object),
isPending: expect.any(Boolean),
error: null,
})
expect(typeof ctx?.refetch).toBe('function')
expect(ctx?.data).toEqual(STALE_SESSION)
expect(ctx?.isPending).toBe(false)
h.unmount()
})
it('upgrade path: fresh disableCookieCache read wins even when the stale mount query resolves LAST', async () => {
setSearch('?upgraded=true')
const mount = defer<{ data: AppSession }>()
const upgrade = defer<{ data: AppSession }>()
mockGetSession.mockImplementation((arg?: unknown) => {
if (isUpgradeCall(arg)) return upgrade.promise
// Honor the abort signal like the real fetch-backed client: cancelQueries
// aborts the in-flight mount read, so it rejects rather than resolving late.
const signal = (arg as { fetchOptions?: { signal?: AbortSignal } })?.fetchOptions?.signal
signal?.addEventListener('abort', () =>
mount.reject(new DOMException('Aborted', 'AbortError'))
)
return mount.promise
})
// activeOrganizationId is present, so setActive / listCreatorOrganizations are not reached.
const h = renderProvider()
await flush()
// Resolve the fresh upgrade read FIRST. The cancelQueries guard runs before
// setQueryData, cancelling (aborting) the in-flight stale mount query.
await act(async () => {
upgrade.resolve({ data: FRESH_SESSION })
await Promise.resolve()
})
await flushUntil(() => h.queryClient.getQueryData(sessionKeys.detail()) != null)
// Assert on the cache — the contended state cancelQueries + setQueryData
// govern. The fresh value wins; the aborted stale mount read never clobbers it.
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(FRESH_SESSION)
expect(h.queryClient.getQueryData(sessionKeys.detail())).not.toEqual(STALE_SESSION)
h.unmount()
})
it('upgrade path: a failed fresh read keeps the user signed in and still reconciles plan surfaces', async () => {
setSearch('?upgraded=true')
const mount = defer<{ data: AppSession }>()
const upgrade = defer<{ data: AppSession }>()
mockGetSession.mockImplementation((arg?: unknown) =>
isUpgradeCall(arg) ? upgrade.promise : mount.promise
)
const invalidateSpy = vi.spyOn(QueryClient.prototype, 'invalidateQueries')
const invalidatedKeys = () =>
invalidateSpy.mock.calls.map(([arg]) => (arg as { queryKey?: unknown[] })?.queryKey)
const h = renderProvider()
await flush()
// The fresh disableCookieCache read fails.
await act(async () => {
upgrade.reject(new Error('refresh failed'))
await Promise.resolve()
})
await flush()
// The normal cookie-cached mount query lands AFTER the failure.
await act(async () => {
mount.resolve({ data: STALE_SESSION })
await Promise.resolve()
})
await flushUntil(
() =>
h.queryClient.getQueryData(sessionKeys.detail()) != null &&
invalidatedKeys().some((k) => Array.isArray(k) && k[0] === 'subscription')
)
// The valid cookie-cached session is still cached — a failed upgrade refresh
// must not sign the user out, and it must not surface as a session error.
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(STALE_SESSION)
expect(h.queryClient.getQueryState(sessionKeys.detail())?.error ?? null).toBeNull()
// Plan surfaces read server truth, so they still reconcile after the failure.
expect(invalidatedKeys()).toContainEqual(['organizations'])
expect(invalidatedKeys()).toContainEqual(['subscription'])
invalidateSpy.mockRestore()
h.unmount()
})
it('strips the upgraded param from the URL', async () => {
setSearch('?upgraded=true&keep=1')
mockGetSession.mockResolvedValue({ data: FRESH_SESSION })
const h = renderProvider()
await flush()
expect(window.location.search).not.toContain('upgraded')
expect(window.location.search).toContain('keep=1')
h.unmount()
})
})
@@ -1,32 +1,17 @@
'use client'
import type React from 'react'
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import { createContext, useEffect, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations'
import { client } from '@/lib/auth/auth-client'
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
export type AppSession = {
user: {
id: string
email: string
emailVerified?: boolean
name?: string | null
image?: string | null
role?: string
createdAt?: Date
updatedAt?: Date
} | null
session?: {
id?: string
userId?: string
activeOrganizationId?: string
impersonatedBy?: string | null
}
} | null
import {
type AppSession,
extractSessionDataFromAuthClientResult,
} from '@/lib/auth/session-response'
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'
export type SessionHookResult = {
data: AppSession
@@ -40,56 +25,56 @@ export const SessionContext = createContext<SessionHookResult | null>(null)
const logger = createLogger('SessionProvider')
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [data, setData] = useState<AppSession>(null)
const [isPending, setIsPending] = useState(true)
const [error, setError] = useState<Error | null>(null)
const queryClient = useQueryClient()
const loadSession = useCallback(async (bypassCache = false) => {
try {
setIsPending(true)
setError(null)
const res = bypassCache
? await client.getSession({ query: { disableCookieCache: true } })
: await client.getSession()
const session = extractSessionDataFromAuthClientResult(res) as AppSession
setData(session)
return session
} catch (e) {
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
return null
} finally {
setIsPending(false)
}
}, [])
const query = useSessionQuery()
const { data, isPending, error, refetch } = query
useEffect(() => {
let isCancelled = false
// Check if user was redirected after plan upgrade
const params = new URLSearchParams(window.location.search)
const wasUpgraded = params.get('upgraded') === 'true'
if (wasUpgraded) {
params.delete('upgraded')
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname
window.history.replaceState({}, '', newUrl)
if (!wasUpgraded) {
return
}
params.delete('upgraded')
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname
window.history.replaceState({}, '', newUrl)
const refreshAfterUpgrade = async () => {
const res = await client.getSession({ query: { disableCookieCache: true } })
const fresh = extractSessionDataFromAuthClientResult(res) as AppSession
if (isCancelled) return null
await queryClient.cancelQueries({ queryKey: sessionKeys.detail() })
queryClient.setQueryData(sessionKeys.detail(), fresh)
return fresh
}
const initializeSession = async () => {
const session = await loadSession(wasUpgraded)
let session: AppSession = null
try {
session = await refreshAfterUpgrade()
} catch (e) {
logger.warn('Failed to refresh session after subscription upgrade', { error: e })
}
if (!wasUpgraded || isCancelled) {
if (isCancelled) {
return
}
// Refresh the plan surfaces even if the cookie-bypass read above failed: they
// query server truth (not the session cookie cache), so they still reconcile.
queryClient.invalidateQueries({ queryKey: ['organizations'] })
queryClient.invalidateQueries({ queryKey: ['subscription'] })
const activeOrganizationId = session?.session?.activeOrganizationId ?? null
if (activeOrganizationId) {
if (!session || activeOrganizationId) {
return
}
@@ -106,7 +91,12 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
await client.organization.setActive({ organizationId })
if (!isCancelled) {
await loadSession(true)
const res = await client.getSession({ query: { disableCookieCache: true } })
const fresh = extractSessionDataFromAuthClientResult(res) as AppSession
if (!isCancelled) {
await queryClient.cancelQueries({ queryKey: sessionKeys.detail() })
queryClient.setQueryData(sessionKeys.detail(), fresh)
}
}
} catch (error) {
logger.warn('Failed to activate organization after subscription upgrade', { error })
@@ -118,7 +108,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
return () => {
isCancelled = true
}
}, [loadSession, queryClient])
}, [queryClient])
useEffect(() => {
if (isPending) return
@@ -150,12 +140,15 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
.catch(() => {})
}, [data, isPending])
const refetch = useCallback(async () => {
await loadSession()
}, [loadSession])
const value = useMemo<SessionHookResult>(
() => ({ data, isPending, error, refetch }),
() => ({
data: data ?? null,
isPending,
error,
refetch: async () => {
await refetch()
},
}),
[data, isPending, error, refetch]
)
+19 -72
View File
@@ -1,91 +1,38 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { Suspense } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { useSearchParams } from 'next/navigation'
import { Loader } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import type { ContractJsonResponse } from '@/lib/api/contracts'
import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user'
import type { UnsubscribeType } from '@/lib/api/contracts/user'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { InviteLayout } from '@/app/invite/components'
type UnsubscribeData = ContractJsonResponse<typeof unsubscribeGetContract>
import { useUnsubscribe, useUnsubscribeMutation } from '@/hooks/queries/unsubscribe'
function UnsubscribeContent() {
const searchParams = useSearchParams()
const [loading, setLoading] = useState(true)
const [data, setData] = useState<UnsubscribeData | null>(null)
const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [unsubscribed, setUnsubscribed] = useState(false)
const email = searchParams.get('email')
const token = searchParams.get('token')
useEffect(() => {
if (!email || !token) {
setError('Missing email or token in URL')
setLoading(false)
return
}
const hasParams = Boolean(email) && Boolean(token)
const query = useUnsubscribe(email ?? undefined, token ?? undefined)
const unsubscribe = useUnsubscribeMutation()
requestJson(unsubscribeGetContract, { query: { email, token } })
.then((response) => {
setData(response)
})
.catch((err: unknown) => {
const message = getErrorMessage(err, 'Failed to validate unsubscribe link')
setError(message)
})
.finally(() => {
setLoading(false)
})
}, [email, token])
const data = query.data ?? null
const loading = hasParams && query.isLoading
const processing = unsubscribe.isPending
const unsubscribed = unsubscribe.isSuccess
const error = !hasParams
? 'Missing email or token in URL'
: query.isError
? getErrorMessage(query.error, 'Failed to validate unsubscribe link')
: unsubscribe.isError
? getErrorMessage(unsubscribe.error, 'Failed to process unsubscribe request')
: null
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
const handleUnsubscribe = (type: UnsubscribeType) => {
if (!email || !token) return
setProcessing(true)
try {
await requestJson(unsubscribePostContract, {
body: { email, token, type },
})
setUnsubscribed(true)
if (data) {
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
if (validTypes.includes(type)) {
if (type === 'all') {
setData({
...data,
currentPreferences: {
...data.currentPreferences,
unsubscribeAll: true,
},
})
} else {
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
| 'unsubscribeMarketing'
| 'unsubscribeUpdates'
| 'unsubscribeNotifications'
setData({
...data,
currentPreferences: {
...data.currentPreferences,
[propertyKey]: true,
},
})
}
}
}
} catch (err: unknown) {
const message = getErrorMessage(err, 'Failed to process unsubscribe request')
setError(message)
} finally {
setProcessing(false)
}
unsubscribe.mutate({ email, token, type })
}
if (loading) {
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function FilesError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load files'
description='Something went wrong while loading your files. Please try again.'
loggerName='FilesError'
/>
)
}
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function KnowledgeError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load knowledge'
description='Something went wrong while loading your knowledge bases. Please try again.'
loggerName='KnowledgeError'
/>
)
}
@@ -0,0 +1,75 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/components/emcn', () => ({
Button: ({ children, ...props }: { children: ReactNode } & Record<string, unknown>) => (
<button {...props}>{children}</button>
),
}))
vi.mock('@/app/workspace/[workspaceId]/components', async () => {
const errorModule = await import('@/app/workspace/[workspaceId]/components/error')
return errorModule
})
import LogsError from './error'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
act(() => {
root = createRoot(container)
})
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})
function findButtonByText(text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll('button')).find(
(el) => el.textContent?.trim() === text
)
if (!button) throw new Error(`Button with text "${text}" not found`)
return button as HTMLButtonElement
}
describe('LogsError boundary', () => {
it('renders the title and description from the shared ErrorState', () => {
const error = Object.assign(new Error('boom'), { digest: 'abc123' })
act(() => {
root.render(<LogsError error={error} reset={vi.fn()} />)
})
expect(container.textContent).toContain('Failed to load logs')
expect(container.textContent).toContain(
'Something went wrong while loading the logs. Please try again.'
)
})
it('calls reset when the refresh action is clicked', () => {
const reset = vi.fn()
const error = Object.assign(new Error('boom'), { digest: 'abc123' })
act(() => {
root.render(<LogsError error={error} reset={reset} />)
})
act(() => {
findButtonByText('Refresh').click()
})
expect(reset).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function LogsError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load logs'
description='Something went wrong while loading the logs. Please try again.'
loggerName='LogsError'
/>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query'
import { client } from '@/lib/auth/auth-client'
import {
type AppSession,
extractSessionDataFromAuthClientResult,
} from '@/lib/auth/session-response'
export const sessionKeys = {
all: ['session'] as const,
detail: () => [...sessionKeys.all, 'detail'] as const,
}
async function fetchSession(signal?: AbortSignal): Promise<AppSession> {
const res = await client.getSession({ fetchOptions: { signal } })
return extractSessionDataFromAuthClientResult(res) as AppSession
}
/**
* Reads the current Better Auth session via the client SDK.
*
* This is the Better Auth client SDK (not a same-origin `requestJson` contract),
* so a plain `useQuery` is correct — there is no boundary contract to bind.
*
* `retry: false` preserves the prior fail-fast contract: an auth failure (expired
* token, startup network partition) surfaces immediately rather than retrying a
* request that won't succeed.
*/
export function useSessionQuery() {
return useQuery({
queryKey: sessionKeys.detail(),
queryFn: ({ signal }) => fetchSession(signal),
staleTime: 5 * 60 * 1000,
retry: false,
})
}
+205
View File
@@ -0,0 +1,205 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRequestJson } = vi.hoisted(() => ({
mockRequestJson: vi.fn(),
}))
vi.mock('@/lib/api/client/request', () => ({
requestJson: mockRequestJson,
}))
import { requestJson } from '@/lib/api/client/request'
import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user'
import {
unsubscribeKeys,
useUnsubscribe,
useUnsubscribeMutation,
} from '@/hooks/queries/unsubscribe'
const EMAIL = 'person@example.com'
const TOKEN = 'tok-123'
const getResponse = {
success: true as const,
email: EMAIL,
token: TOKEN,
emailType: 'marketing',
isTransactional: false,
currentPreferences: {
unsubscribeAll: false,
unsubscribeMarketing: false,
unsubscribeUpdates: false,
unsubscribeNotifications: false,
},
}
/**
* Minimal dependency-free hook harness (the repo has no `@testing-library/react`).
* Mounts the hook in a real React 19 root under jsdom, wrapped in a real
* `QueryClientProvider`, so query/mutation lifecycles run exactly as in the app.
*/
function renderHookWithClient<T>(useHook: () => T): {
result: () => T
queryClient: QueryClient
unmount: () => void
} {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const container = document.createElement('div')
const root: Root = createRoot(container)
let latest: T
function Probe() {
latest = useHook()
return null
}
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
act(() => {
root.render(
<Wrapper>
<Probe />
</Wrapper>
)
})
return {
result: () => latest,
queryClient,
unmount: () => act(() => root.unmount()),
}
}
/** Flush pending microtasks and the macrotask queue (query observer scheduling) inside act(). */
async function flush() {
await act(async () => {
for (let i = 0; i < 5; i++) {
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
}
})
}
describe('useUnsubscribe', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('is disabled and does not fetch when email or token is missing', async () => {
const missingToken = renderHookWithClient(() => useUnsubscribe(EMAIL, undefined))
const missingEmail = renderHookWithClient(() => useUnsubscribe(undefined, TOKEN))
const missingBoth = renderHookWithClient(() => useUnsubscribe(undefined, undefined))
await flush()
expect(missingToken.result().fetchStatus).toBe('idle')
expect(missingEmail.result().fetchStatus).toBe('idle')
expect(missingBoth.result().fetchStatus).toBe('idle')
expect(mockRequestJson).not.toHaveBeenCalled()
missingToken.unmount()
missingEmail.unmount()
missingBoth.unmount()
})
it('fetches when both params are present and surfaces the contract data', async () => {
mockRequestJson.mockResolvedValueOnce(getResponse)
const { result, unmount } = renderHookWithClient(() => useUnsubscribe(EMAIL, TOKEN))
await flush()
expect(requestJson).toHaveBeenCalledTimes(1)
expect(requestJson).toHaveBeenCalledWith(
unsubscribeGetContract,
expect.objectContaining({ query: { email: EMAIL, token: TOKEN } })
)
expect(result().isSuccess).toBe(true)
expect(result().data).toEqual(getResponse)
expect(result().data?.isTransactional).toBe(false)
unmount()
})
})
describe('useUnsubscribeMutation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('calls requestJson with the post contract and flips the cached preference flag on success', async () => {
mockRequestJson.mockResolvedValueOnce({
success: true as const,
message: 'Unsubscribed',
email: EMAIL,
type: 'marketing' as const,
emailType: 'marketing',
})
const { result, queryClient, unmount } = renderHookWithClient(() => useUnsubscribeMutation())
const detailKey = unsubscribeKeys.detail(EMAIL, TOKEN)
queryClient.setQueryData(detailKey, getResponse)
await act(async () => {
await result().mutateAsync({ email: EMAIL, token: TOKEN, type: 'marketing' })
})
await flush()
expect(result().isSuccess).toBe(true)
expect(requestJson).toHaveBeenCalledTimes(1)
expect(requestJson).toHaveBeenCalledWith(
unsubscribePostContract,
expect.objectContaining({ body: { email: EMAIL, token: TOKEN, type: 'marketing' } })
)
const reconciled = queryClient.getQueryData<typeof getResponse>(detailKey)
expect(reconciled?.currentPreferences.unsubscribeMarketing).toBe(true)
expect(reconciled?.currentPreferences.unsubscribeAll).toBe(false)
expect(reconciled?.currentPreferences.unsubscribeUpdates).toBe(false)
unmount()
})
it('flips unsubscribeAll when type is "all"', async () => {
mockRequestJson.mockResolvedValueOnce({
success: true as const,
message: 'Unsubscribed',
email: EMAIL,
type: 'all' as const,
emailType: 'marketing',
})
const { result, queryClient, unmount } = renderHookWithClient(() => useUnsubscribeMutation())
const detailKey = unsubscribeKeys.detail(EMAIL, TOKEN)
queryClient.setQueryData(detailKey, getResponse)
await act(async () => {
await result().mutateAsync({ email: EMAIL, token: TOKEN, type: 'all' })
})
await flush()
expect(result().isSuccess).toBe(true)
const reconciled = queryClient.getQueryData<typeof getResponse>(detailKey)
expect(reconciled?.currentPreferences.unsubscribeAll).toBe(true)
unmount()
})
})
+76
View File
@@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type UnsubscribeActionResponse,
type UnsubscribeData,
type UnsubscribeType,
unsubscribeGetContract,
unsubscribePostContract,
} from '@/lib/api/contracts/user'
export const unsubscribeKeys = {
all: ['unsubscribe'] as const,
details: () => [...unsubscribeKeys.all, 'detail'] as const,
detail: (email?: string, token?: string) =>
[...unsubscribeKeys.details(), email ?? '', token ?? ''] as const,
}
async function fetchUnsubscribe(
email: string,
token: string,
signal?: AbortSignal
): Promise<UnsubscribeData> {
return requestJson(unsubscribeGetContract, { query: { email, token }, signal })
}
/**
* Validates an unsubscribe link and loads the recipient's current email preferences.
* Auto-runs on mount once both `email` and `token` are present.
*/
export function useUnsubscribe(email?: string, token?: string) {
return useQuery({
queryKey: unsubscribeKeys.detail(email, token),
queryFn: ({ signal }) => fetchUnsubscribe(email as string, token as string, signal),
enabled: Boolean(email) && Boolean(token),
staleTime: 5 * 60 * 1000,
retry: false,
})
}
interface UnsubscribeVariables {
email: string
token: string
type: UnsubscribeType
}
/**
* Submits an unsubscribe action and reconciles the cached preferences so the
* affected option immediately reflects the unsubscribed state.
*/
export function useUnsubscribeMutation() {
const queryClient = useQueryClient()
return useMutation<UnsubscribeActionResponse, Error, UnsubscribeVariables>({
mutationFn: ({ email, token, type }) =>
requestJson(unsubscribePostContract, { body: { email, token, type } }),
onSuccess: (_data, { email, token, type }) => {
const key = unsubscribeKeys.detail(email, token)
queryClient.setQueryData<UnsubscribeData>(key, (previous) => {
if (!previous) return previous
const preferenceKey =
type === 'all'
? 'unsubscribeAll'
: (`unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
| 'unsubscribeMarketing'
| 'unsubscribeUpdates'
| 'unsubscribeNotifications')
return {
...previous,
currentPreferences: {
...previous.currentPreferences,
[preferenceKey]: true,
},
}
})
},
})
}
@@ -0,0 +1,50 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRequestJson } = vi.hoisted(() => ({
mockRequestJson: vi.fn(),
}))
vi.mock('@/lib/api/client/request', () => ({
requestJson: mockRequestJson,
}))
vi.mock('@/lib/api/contracts/workflows', () => ({
getWorkflowStateContract: { __contract: 'getWorkflowState' },
}))
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
describe('fetchWorkflowEnvelope', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the unwrapped envelope from the contract response', async () => {
const envelope = {
id: 'wf-1',
isDeployed: true,
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
}
mockRequestJson.mockResolvedValue({ data: envelope })
const result = await fetchWorkflowEnvelope('wf-1')
expect(result).toBe(envelope)
})
it('forwards params.id and signal to requestJson against the contract', async () => {
mockRequestJson.mockResolvedValue({ data: { id: 'wf-2' } })
const controller = new AbortController()
await fetchWorkflowEnvelope('wf-2', controller.signal)
expect(mockRequestJson).toHaveBeenCalledTimes(1)
expect(mockRequestJson).toHaveBeenCalledWith(
{ __contract: 'getWorkflowState' },
{ params: { id: 'wf-2' }, signal: controller.signal }
)
})
})
@@ -0,0 +1,31 @@
import { requestJson } from '@/lib/api/client/request'
import {
type GetWorkflowResponseData,
getWorkflowStateContract,
} from '@/lib/api/contracts/workflows'
/**
* Fetches the full workflow envelope (in-state slice, deployment status,
* variables, and row metadata) for a single workflow from GET
* `/api/workflows/[id]`.
*
* Single source of truth for the `workflowKeys.state(id)` cache entry: the
* registry store hydrates it via `fetchQuery` (always-fresh, in-flight
* deduped) and `useWorkflowState`/`useWorkflowStates` project the mapped
* `WorkflowState` out of the same entry with `select`, so this endpoint has
* exactly one cache entry across the store and the hooks.
*
* Lives in a standalone util (rather than `hooks/queries/workflows.ts`) so the
* registry store can import it without creating a store ↔ query-hook import
* cycle.
*/
export async function fetchWorkflowEnvelope(
workflowId: string,
signal?: AbortSignal
): Promise<GetWorkflowResponseData> {
const { data } = await requestJson(getWorkflowStateContract, {
params: { id: workflowId },
signal,
})
return data
}
+15 -11
View File
@@ -18,7 +18,7 @@ import {
createWorkflowContract,
deleteWorkflowContract,
duplicateWorkflowContract,
getWorkflowStateContract,
type GetWorkflowResponseData,
type ImportWorkflowAsSuperuserBody,
type ImportWorkflowAsSuperuserResponse,
importWorkflowAsSuperuserContract,
@@ -28,6 +28,7 @@ import {
} from '@/lib/api/contracts/workflows'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state'
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
@@ -49,14 +50,11 @@ const logger = createLogger('WorkflowQueries')
export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
async function fetchWorkflowState(
workflowId: string,
signal?: AbortSignal
): Promise<WorkflowState | null> {
const { data } = await requestJson(getWorkflowStateContract, {
params: { id: workflowId },
signal,
})
/**
* Projects the in-state slice of the workflow envelope into the canvas-facing
* `WorkflowState` shape consumed by preview/editor surfaces.
*/
function mapWorkflowState(data: GetWorkflowResponseData): WorkflowState {
const wireState = data.state
return {
...wireState,
@@ -70,11 +68,16 @@ async function fetchWorkflowState(
* Fetches the full workflow state for a single workflow.
* Used by workflow blocks to show a preview of the child workflow
* and as a base query for input fields extraction.
*
* Derives the mapped `WorkflowState` from the shared envelope query via
* `select`, so it shares one cache entry (and one request) with the registry
* store's hydration and with `useWorkflowStates`.
*/
export function useWorkflowState(workflowId: string | undefined) {
return useQuery({
queryKey: workflowKeys.state(workflowId),
queryFn: workflowId ? ({ signal }) => fetchWorkflowState(workflowId, signal) : skipToken,
queryFn: workflowId ? ({ signal }) => fetchWorkflowEnvelope(workflowId, signal) : skipToken,
select: mapWorkflowState,
staleTime: 30 * 1000,
})
}
@@ -93,7 +96,8 @@ export function useWorkflowStates(
const results = useQueries({
queries: uniqueIds.map((id) => ({
queryKey: workflowKeys.state(id),
queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowState(id, signal),
queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowEnvelope(id, signal),
select: mapWorkflowState,
staleTime: 30 * 1000,
})),
})
+6 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import { isSameOrigin } from '@/lib/core/utils/validation'
export const userProfileSchema = z.object({
@@ -259,6 +259,11 @@ export const unsubscribePostContract = defineRouteContract({
},
})
export type UnsubscribeData = ContractJsonResponse<typeof unsubscribeGetContract>
export type UnsubscribeActionResponse = ContractJsonResponse<typeof unsubscribePostContract>
export type UnsubscribeBody = z.input<typeof unsubscribeBodySchema>
export type UnsubscribeType = NonNullable<UnsubscribeBody['type']>
export const usageLogsQuerySchema = z.object({
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
workspaceId: z.string().optional(),
+24
View File
@@ -1,3 +1,27 @@
/**
* The app-facing session shape derived from the Better Auth client response.
* Lives here (the module that produces it) so both the `useSessionQuery` hook
* and the `SessionProvider` can import it without a provider ↔ hook import cycle.
*/
export type AppSession = {
user: {
id: string
email: string
emailVerified?: boolean
name?: string | null
image?: string | null
role?: string
createdAt?: Date
updatedAt?: Date
} | null
session?: {
id?: string
userId?: string
activeOrganizationId?: string
impersonatedBy?: string | null
}
} | null
export function extractSessionDataFromAuthClientResult(result: unknown): unknown | null {
if (!result || typeof result !== 'object') {
return null
@@ -0,0 +1,211 @@
/**
* @vitest-environment node
*
* Focused tests for the registry store's `loadWorkflowState` after the
* workflow-state cache collapse: it hydrates the shared
* `workflowKeys.state(id)` entry via `fetchQuery` (always-fresh,
* `staleTime: 0`) and projects the envelope into the workflow / sub-block /
* variables / deployment stores, guarding against superseded responses.
*/
import { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRequestJson, sharedQueryClient } = vi.hoisted(() => ({
mockRequestJson: vi.fn(),
sharedQueryClient: { current: null as unknown },
}))
vi.mock('@/lib/api/client/request', () => ({
requestJson: mockRequestJson,
}))
vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: () => sharedQueryClient.current as QueryClient,
}))
const { replaceWorkflowState, initializeFromWorkflow, setVariablesState, clearError } = vi.hoisted(
() => ({
replaceWorkflowState: vi.fn(),
initializeFromWorkflow: vi.fn(),
setVariablesState: vi.fn(),
clearError: vi.fn(),
})
)
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: {
getState: () => ({ replaceWorkflowState, blocks: {} }),
setState: vi.fn(),
},
}))
vi.mock('@/stores/workflows/subblock/store', () => ({
useSubBlockStore: {
getState: () => ({ initializeFromWorkflow }),
setState: vi.fn(),
},
}))
vi.mock('@/stores/variables/store', () => ({
useVariablesStore: {
getState: () => ({ variables: {} }),
setState: (updater: unknown) => setVariablesState(updater),
},
}))
vi.mock('@/stores/operation-queue/store', () => ({
useOperationQueueStore: {
getState: () => ({ clearError }),
},
}))
vi.mock('@/hooks/queries/utils/invalidate-workflow-lists', () => ({
invalidateWorkflowLists: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/stores/workflows/utils', () => ({
getUniqueBlockName: vi.fn(),
regenerateBlockIds: vi.fn(),
}))
vi.mock('@/lib/workflows/autolayout/constants', () => ({
DEFAULT_DUPLICATE_OFFSET: { x: 0, y: 0 },
}))
vi.mock('@/hooks/queries/deployments', () => ({
deploymentKeys: {
infos: () => ['deployments', 'info'],
info: (workflowId: string | null) => ['deployments', 'info', workflowId ?? ''],
},
}))
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
function makeEnvelope(overrides: Record<string, unknown> = {}) {
return {
id: 'wf-1',
isDeployed: true,
deployedAt: new Date('2026-01-01T00:00:00.000Z'),
isPublicApi: false,
state: {
blocks: { b1: { id: 'b1' } },
edges: [],
loops: {},
parallels: {},
},
variables: { v1: { id: 'v1', workflowId: 'wf-1', name: 'x' } },
...overrides,
}
}
describe('registry store loadWorkflowState (collapsed cache)', () => {
beforeEach(() => {
vi.clearAllMocks()
// The store dispatches an `active-workflow-changed` CustomEvent on the
// window; provide a minimal stub under the node environment.
vi.stubGlobal('window', { dispatchEvent: vi.fn() })
sharedQueryClient.current = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
// Reset store to a clean state with a workspace scope so loadWorkflowState
// does not bail on the missing-workspace guard.
useWorkflowRegistry.setState({
activeWorkflowId: null,
error: null,
hydration: {
phase: 'idle',
workspaceId: 'ws-1',
workflowId: null,
requestId: null,
error: null,
},
})
})
it('projects envelope state, variables, and deployment info into the stores', async () => {
mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
expect(replaceWorkflowState).toHaveBeenCalledTimes(1)
expect(replaceWorkflowState.mock.calls[0][0]).toMatchObject({
currentWorkflowId: 'wf-1',
blocks: { b1: { id: 'b1' } },
edges: [],
})
expect(initializeFromWorkflow).toHaveBeenCalledWith('wf-1', { b1: { id: 'b1' } })
expect(setVariablesState).toHaveBeenCalledTimes(1)
const deploymentInfo = (sharedQueryClient.current as QueryClient).getQueryData([
'deployments',
'info',
'wf-1',
])
expect(deploymentInfo).toMatchObject({
isDeployed: true,
isPublicApi: false,
deployedAt: '2026-01-01T00:00:00.000Z',
})
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-1')
expect(useWorkflowRegistry.getState().hydration.phase).toBe('ready')
})
it('hydrates the SAME workflowKeys.state(id) cache entry the hooks read', async () => {
const envelope = makeEnvelope()
mockRequestJson.mockResolvedValue({ data: envelope })
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
const client = sharedQueryClient.current as QueryClient
const cached = client.getQueryData(workflowKeys.state('wf-1'))
expect(cached).toBeDefined()
expect((cached as { id: string }).id).toBe('wf-1')
// Exactly one cache entry exists for this endpoint — the shared one.
const stateEntries = client
.getQueryCache()
.findAll({ queryKey: workflowKeys.states() })
.filter((q) => q.queryKey[2] === 'wf-1')
expect(stateEntries).toHaveLength(1)
})
it('re-fetches on every call (staleTime: 0, never served stale)', async () => {
mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
expect(mockRequestJson).toHaveBeenCalledTimes(2)
})
it('discards a superseded response via the staleness guard', async () => {
// First load (wf-1) is in-flight; a second load (wf-2) supersedes the
// hydration workflowId, then wf-1 finally resolves. The guard compares the
// current hydration workflowId/requestId against the resolving request and
// must discard the now-stale wf-1 projection.
let resolveFirst: (value: unknown) => void = () => {}
const firstPending = new Promise((resolve) => {
resolveFirst = resolve
})
mockRequestJson
.mockImplementationOnce(() => firstPending)
.mockImplementationOnce(() => Promise.resolve({ data: makeEnvelope({ id: 'wf-2' }) }))
const firstLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-1')
const secondLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-2')
await secondLoad
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
const projectionsAfterSecond = replaceWorkflowState.mock.calls.length
resolveFirst({ data: makeEnvelope({ id: 'wf-1' }) })
await firstLoad
// The stale wf-1 result must not project again — hydration is now wf-2.
expect(replaceWorkflowState.mock.calls.length).toBe(projectionsAfterSecond)
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
})
})
+6 -4
View File
@@ -2,13 +2,13 @@ import { createLogger } from '@sim/logger'
import { generateRandomHex } from '@sim/utils/random'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
@@ -98,8 +98,10 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
}))
try {
const { data: workflowData } = await requestJson(getWorkflowStateContract, {
params: { id: workflowId },
const workflowData = await getQueryClient().fetchQuery({
queryKey: workflowKeys.state(workflowId),
queryFn: ({ signal }) => fetchWorkflowEnvelope(workflowId, signal),
staleTime: 0,
})
const deployedAt = workflowData.deployedAt ? workflowData.deployedAt.toISOString() : null