mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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]
|
||||
)
|
||||
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user