diff --git a/apps/sim/app/_shell/providers/session-provider.test.tsx b/apps/sim/app/_shell/providers/session-provider.test.tsx new file mode 100644 index 0000000000..9e02703f05 --- /dev/null +++ b/apps/sim/app/_shell/providers/session-provider.test.tsx @@ -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() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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( + + + + + + ) + }) + + 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) && + (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() + }) +}) diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index b19ee64788..d94c07e48b 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -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(null) const logger = createLogger('SessionProvider') export function SessionProvider({ children }: { children: React.ReactNode }) { - const [data, setData] = useState(null) - const [isPending, setIsPending] = useState(true) - const [error, setError] = useState(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( - () => ({ data, isPending, error, refetch }), + () => ({ + data: data ?? null, + isPending, + error, + refetch: async () => { + await refetch() + }, + }), [data, isPending, error, refetch] ) diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx index 3804267ea4..c7fac2f9aa 100644 --- a/apps/sim/app/unsubscribe/unsubscribe.tsx +++ b/apps/sim/app/unsubscribe/unsubscribe.tsx @@ -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 +import { useUnsubscribe, useUnsubscribeMutation } from '@/hooks/queries/unsubscribe' function UnsubscribeContent() { const searchParams = useSearchParams() - const [loading, setLoading] = useState(true) - const [data, setData] = useState(null) - const [error, setError] = useState(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 - } - - 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 handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => { + const hasParams = Boolean(email) && Boolean(token) + const query = useUnsubscribe(email ?? undefined, token ?? undefined) + const unsubscribe = useUnsubscribeMutation() + + 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 = (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) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/error.tsx b/apps/sim/app/workspace/[workspaceId]/files/error.tsx new file mode 100644 index 0000000000..a3e69db7e0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function FilesError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/error.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/error.tsx new file mode 100644 index 0000000000..59a072ba7f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function KnowledgeError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx b/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx new file mode 100644 index 0000000000..4b1c259bf7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx @@ -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) => ( + + ), +})) + +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() + }) + + 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() + }) + + act(() => { + findButtonByText('Refresh').click() + }) + + expect(reset).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/error.tsx b/apps/sim/app/workspace/[workspaceId]/logs/error.tsx new file mode 100644 index 0000000000..7d6310f993 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function LogsError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts new file mode 100644 index 0000000000..e41d1db7a7 --- /dev/null +++ b/apps/sim/hooks/queries/session.ts @@ -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 { + 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, + }) +} diff --git a/apps/sim/hooks/queries/unsubscribe.test.tsx b/apps/sim/hooks/queries/unsubscribe.test.tsx new file mode 100644 index 0000000000..97a7c49227 --- /dev/null +++ b/apps/sim/hooks/queries/unsubscribe.test.tsx @@ -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(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 {children} + } + + act(() => { + root.render( + + + + ) + }) + + 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(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(detailKey) + expect(reconciled?.currentPreferences.unsubscribeAll).toBe(true) + + unmount() + }) +}) diff --git a/apps/sim/hooks/queries/unsubscribe.ts b/apps/sim/hooks/queries/unsubscribe.ts new file mode 100644 index 0000000000..29116ffe82 --- /dev/null +++ b/apps/sim/hooks/queries/unsubscribe.ts @@ -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 { + 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({ + mutationFn: ({ email, token, type }) => + requestJson(unsubscribePostContract, { body: { email, token, type } }), + onSuccess: (_data, { email, token, type }) => { + const key = unsubscribeKeys.detail(email, token) + queryClient.setQueryData(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, + }, + } + }) + }, + }) +} diff --git a/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts new file mode 100644 index 0000000000..71dca0258b --- /dev/null +++ b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts @@ -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 } + ) + }) +}) diff --git a/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts new file mode 100644 index 0000000000..d7a55d1c57 --- /dev/null +++ b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts @@ -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 { + const { data } = await requestJson(getWorkflowStateContract, { + params: { id: workflowId }, + signal, + }) + return data +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 865b66bec6..55df6d469a 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -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 { - 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, })), }) diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index a100cb45af..030c851551 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -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 +export type UnsubscribeActionResponse = ContractJsonResponse +export type UnsubscribeBody = z.input +export type UnsubscribeType = NonNullable + export const usageLogsQuerySchema = z.object({ source: z.enum(['workflow', 'wand', 'copilot']).optional(), workspaceId: z.string().optional(), diff --git a/apps/sim/lib/auth/session-response.ts b/apps/sim/lib/auth/session-response.ts index 262cc9a1bc..f41fccce32 100644 --- a/apps/sim/lib/auth/session-response.ts +++ b/apps/sim/lib/auth/session-response.ts @@ -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 diff --git a/apps/sim/stores/workflows/registry/store.test.ts b/apps/sim/stores/workflows/registry/store.test.ts new file mode 100644 index 0000000000..77e4fd8a02 --- /dev/null +++ b/apps/sim/stores/workflows/registry/store.test.ts @@ -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 = {}) { + 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') + }) +}) diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index c692874997..97a0b55672 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -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()( })) 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