diff --git a/.agents/rules/web/data.md b/.agents/rules/web/data.md index aec3fc17fb7..002daf34263 100644 --- a/.agents/rules/web/data.md +++ b/.agents/rules/web/data.md @@ -46,15 +46,21 @@ Queries, caching, and where data is fetched. ## Query callback lifetimes Query options can remain cached after their UI unmounts, including for disabled -queries. Callbacks created inside hooks can retain component accessors, Solid -owners, and detached DOM through shared closure environments—even when a callback -only references a locally resolved ID. This does not mean every closure captures -every local variable. +queries: for `gcTime`, and indefinitely on a query other views still observe, +which keeps its last observer's options. Callbacks created inside hooks can +retain component accessors, Solid owners, and detached DOM through shared closure +environments—even when a callback only references a locally resolved ID. This +does not mean every closure captures every local variable. -- Create cached callbacks in module-scope query factories accepting resolved - IDs, flags, and plain data. Resolve reactive inputs inside the `useQuery` / - `useInfiniteQuery` options accessor, e.g. `useQuery(() => - itemQueryOptions(id()))`, so inputs still update reactively. +- **FE-69** Create cached callbacks in module-scope query factories accepting + resolved IDs, flags, and plain data. Resolve reactive inputs inside the + `useQuery` / `useInfiniteQuery` options accessor, e.g. `useQuery(() => + itemQueryOptions(id()))`, so inputs still update reactively. Enforced by + ast-grep `ts-/tsx-no-inline-query-callbacks`, CI error, for functions written + in a query hook's options, and in `queryOptions` or client `fetchQuery`-family + options inside a `use*`, `create*`, or component function or an object + method. Names ending in `Options` count as factories. The rule does not see + references to hook-local functions or wrapper-hook arguments (below). - Keep pure selectors and polling policies at module scope, or inside those factories. Check **all** retained callbacks: `queryFn`, `select`, `refetchInterval`, pagination, placeholder callbacks, and functions in `meta`. diff --git a/apps/web/src/features/block-database/queries/column-casts.ts b/apps/web/src/features/block-database/queries/column-casts.ts index 8dbab2bc545..3b35ed38081 100644 --- a/apps/web/src/features/block-database/queries/column-casts.ts +++ b/apps/web/src/features/block-database/queries/column-casts.ts @@ -1,7 +1,7 @@ import { throwOnErr } from '@core/util/result'; import { storageServiceClient } from '@service-storage/client'; import type { ColumnCast as ServerColumnCast } from '@service-storage/generated/schemas/columnCast'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { match } from 'ts-pattern'; import type { @@ -11,6 +11,32 @@ import type { } from '../../database/core/column-schema'; import { databaseColumnKeys } from './keys'; +function columnCastsQueryOptions( + databaseId: string, + tableId: string, + columnId: string, + version: number, + enabled: boolean +) { + return queryOptions({ + queryKey: databaseColumnKeys.casts(databaseId, tableId, columnId, version) + .queryKey, + queryFn: () => + throwOnErr(() => + storageServiceClient.databases.columnCasts({ + id: databaseId, + tableId, + columnId, + }) + ), + enabled, + select: (casts: ServerColumnCast[]) => ({ + version, + casts: casts.map(toMenuCast), + }), + }); +} + /** The type menu's dry runs for one table, fetched when a menu opens. */ export function createColumnCasts(params: { databaseId: string; @@ -18,30 +44,15 @@ export function createColumnCasts(params: { version: Accessor; }): DatabaseColumnCastsSource { return (columnId, open) => { - const query = useQuery(() => { - const version = params.version(); - return { - queryKey: databaseColumnKeys.casts( - params.databaseId, - params.tableId, - columnId, - version - ).queryKey, - queryFn: () => - throwOnErr(() => - storageServiceClient.databases.columnCasts({ - id: params.databaseId, - tableId: params.tableId, - columnId, - }) - ), - enabled: open(), - select: (casts: ServerColumnCast[]) => ({ - version, - casts: casts.map(toMenuCast), - }), - }; - }); + const query = useQuery(() => + columnCastsQueryOptions( + params.databaseId, + params.tableId, + columnId, + params.version(), + open() + ) + ); // Gated on status: an unopened menu's query is pending and must not suspend. return (): DatabaseColumnCasts => query.isSuccess diff --git a/apps/web/src/features/block-database/queries/starter-database.test.ts b/apps/web/src/features/block-database/queries/starter-database.test.ts index aa2abc81109..ec4f473d4ac 100644 --- a/apps/web/src/features/block-database/queries/starter-database.test.ts +++ b/apps/web/src/features/block-database/queries/starter-database.test.ts @@ -10,9 +10,17 @@ const mocks = vi.hoisted(() => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined), options: undefined as | undefined - | (() => { enabled: boolean; queryFn: () => Promise }), + | (() => { + enabled: boolean; + queryFn: (context: { client: unknown }) => Promise; + }), })); +const runQuery = () => + mocks.options!().queryFn({ + client: { invalidateQueries: mocks.invalidateQueries }, + }); + vi.mock('@app/lib/analytics/posthog', () => ({ useFeatureFlag: () => () => mocks.flag, })); @@ -30,11 +38,11 @@ vi.mock('@service-storage/client', () => ({ }, })); vi.mock('@tanstack/solid-query', () => ({ + queryOptions: (options: T) => options, useQuery: (options: typeof mocks.options) => { mocks.options = options; return {}; }, - useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), })); beforeEach(() => { @@ -72,7 +80,7 @@ describe('starter onboarding', () => { }); it('opens the newly seeded board and refreshes the database list', async () => { - await mocks.options!().queryFn(); + await runQuery(); const selection = createUserScopedStorage( 'database-view-selection:example' ).read(mocks.userId!); @@ -86,7 +94,7 @@ describe('starter onboarding', () => { it('does not replace an existing browser view preference', async () => { const storage = createUserScopedStorage('database-view-selection:example'); storage.write(mocks.userId!, 'existing preference'); - await mocks.options!().queryFn(); + await runQuery(); expect(storage.read(mocks.userId!)).toBe('existing preference'); }); @@ -97,17 +105,17 @@ describe('starter onboarding', () => { tableId: null, viewId: null, }); - await mocks.options!().queryFn(); + await runQuery(); expect(localStorage.length).toBe(0); expect(mocks.invalidateQueries).not.toHaveBeenCalled(); }); it('keeps failed provisioning retryable without marking it complete', async () => { mocks.ensureStarter.mockRejectedValueOnce(new Error('offline')); - await expect(mocks.options!().queryFn()).rejects.toThrow('offline'); + await expect(runQuery()).rejects.toThrow('offline'); expect(localStorage.length).toBe(0); expect(mocks.invalidateQueries).not.toHaveBeenCalled(); - await mocks.options!().queryFn(); + await runQuery(); expect(mocks.ensureStarter).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/src/features/block-database/queries/starter-database.ts b/apps/web/src/features/block-database/queries/starter-database.ts index c53b7924269..a979e7efdb2 100644 --- a/apps/web/src/features/block-database/queries/starter-database.ts +++ b/apps/web/src/features/block-database/queries/starter-database.ts @@ -6,27 +6,19 @@ import { createUserScopedStorage } from '@core/util/userScopedStorage'; import { useDatabasesQuery } from '@queries/storage/databases'; import { databasesKeys } from '@queries/storage/keys'; import { storageServiceClient } from '@service-storage/client'; -import { useQuery, useQueryClient } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { DatabaseViewSelection } from '../../database/core/view-selection'; -/** Mounted inside authenticated app chrome. Provisioning never suspends the app. */ -export function useStarterDatabase() { - const userId = useUserId(); - const flag = useFeatureFlag(enableDatabases); - const databases = useDatabasesQuery(); - const client = useQueryClient(); - return useQuery(() => ({ - queryKey: databasesKeys.starter(userId() ?? '').queryKey, - enabled: - !!userId() && - !flag().loading && - flag().enabled && - databases.isSuccess && - databases.data?.length === 0, +function starterDatabaseQueryOptions( + ownerId: string | undefined, + enabled: boolean +) { + return queryOptions({ + queryKey: databasesKeys.starter(ownerId ?? '').queryKey, + enabled: !!ownerId && enabled, staleTime: Infinity, gcTime: Infinity, - queryFn: async () => { - const ownerId = userId(); + queryFn: async ({ client }) => { const result = await throwOnErr(() => storageServiceClient.databases.ensureStarter() ); @@ -58,5 +50,21 @@ export function useStarterDatabase() { }); return result; }, - })); + }); +} + +/** Mounted inside authenticated app chrome. Provisioning never suspends the app. */ +export function useStarterDatabase() { + const userId = useUserId(); + const flag = useFeatureFlag(enableDatabases); + const databases = useDatabasesQuery(); + return useQuery(() => + starterDatabaseQueryOptions( + userId(), + !flag().loading && + flag().enabled && + databases.isSuccess && + databases.data?.length === 0 + ) + ); } diff --git a/apps/web/src/features/block-database/queries/views.ts b/apps/web/src/features/block-database/queries/views.ts index a6f14088c1a..97a61c9d2ef 100644 --- a/apps/web/src/features/block-database/queries/views.ts +++ b/apps/web/src/features/block-database/queries/views.ts @@ -14,7 +14,7 @@ import type { CardPosition } from '@service-storage/generated/schemas/cardPositi import type { DatabaseView } from '@service-storage/generated/schemas/databaseView'; import type { NewView } from '@service-storage/generated/schemas/newView'; import type { ViewQuery } from '@service-storage/generated/schemas/viewQuery'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import { errAsync, okAsync, ResultAsync } from 'neverthrow'; import type { Accessor } from 'solid-js'; import { v7 as uuidv7 } from 'uuid'; @@ -299,27 +299,28 @@ export function reorderDatabaseViews( }); } +function cardPositionsQueryOptions(databaseId: string, id: string | undefined) { + return queryOptions({ + queryKey: databaseViewKeys.positions(databaseId, id ?? '').queryKey, + queryFn: async () => { + const { positions } = await throwOnErr(() => + storageServiceClient.databases.viewPositions({ + id: databaseId, + viewId: id ?? '', + }) + ); + return positions; + }, + enabled: !!id, + }); +} + /** Where a board's cards sit, read again when its table changes. */ export function useCardPositions( databaseId: string, viewId: Accessor ) { - return useQuery(() => { - const id = viewId(); - return { - queryKey: databaseViewKeys.positions(databaseId, id ?? '').queryKey, - queryFn: async () => { - const { positions } = await throwOnErr(() => - storageServiceClient.databases.viewPositions({ - id: databaseId, - viewId: id ?? '', - }) - ); - return positions; - }, - enabled: !!id, - }; - }); + return useQuery(() => cardPositionsQueryOptions(databaseId, viewId())); } export function setCardPositions( diff --git a/apps/web/src/features/block-database/queries/webhooks.ts b/apps/web/src/features/block-database/queries/webhooks.ts index b1d3a4b8f19..42a0d7f60bf 100644 --- a/apps/web/src/features/block-database/queries/webhooks.ts +++ b/apps/web/src/features/block-database/queries/webhooks.ts @@ -5,11 +5,21 @@ import { queryClient } from '@queries/client'; import { storageServiceClient } from '@service-storage/client'; import type { CreatedDatabaseWebhookResponse } from '@service-storage/generated/schemas/createdDatabaseWebhookResponse'; import type { DatabaseWebhookResponse } from '@service-storage/generated/schemas/databaseWebhookResponse'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { ResultAsync } from 'neverthrow'; import type { Accessor } from 'solid-js'; import { databaseWebhookKeys } from './keys'; +function databaseWebhooksQueryOptions(databaseId: string) { + return queryOptions({ + queryKey: databaseWebhookKeys.list(databaseId).queryKey, + queryFn: () => + throwOnErr(() => + storageServiceClient.databases.listWebhooks({ id: databaseId }) + ), + }); +} + /** A table's webhooks; `undefined` until they are read. */ export function useTableWebhooks( databaseId: Accessor, @@ -18,13 +28,7 @@ export function useTableWebhooks( webhooks: Accessor; failed: Accessor; } { - const query = useQuery(() => ({ - queryKey: databaseWebhookKeys.list(databaseId()).queryKey, - queryFn: () => - throwOnErr(() => - storageServiceClient.databases.listWebhooks({ id: databaseId() }) - ), - })); + const query = useQuery(() => databaseWebhooksQueryOptions(databaseId())); return { webhooks: () => query.isSuccess diff --git a/apps/web/src/features/block-pr/queries/pr-links-query.ts b/apps/web/src/features/block-pr/queries/pr-links-query.ts index 3ce5fd95499..eb671acc454 100644 --- a/apps/web/src/features/block-pr/queries/pr-links-query.ts +++ b/apps/web/src/features/block-pr/queries/pr-links-query.ts @@ -18,7 +18,7 @@ import { import { agentHarnessServiceClient } from '@service-agent-harness/client'; import type { MessageParent } from '@service-agent-harness/generated/schemas'; import { storageServiceClient } from '@service-storage/client'; -import { useQueries } from '@tanstack/solid-query'; +import { queryOptions, useQueries } from '@tanstack/solid-query'; import { type Accessor, createMemo } from 'solid-js'; import { buildPrLinks, @@ -252,6 +252,16 @@ const chunk = (items: readonly T[], size: number): T[][] => items.slice(index * size, (index + 1) * size) ); +function prLinksQueryOptions(pullRequests: readonly PrLinksTarget[]) { + return queryOptions({ + queryKey: agentSessionPullRequestKeys.linksForPullRequests( + pullRequests.map((pullRequest) => pullRequest.url) + ).queryKey, + queryFn: () => fetchPrLinks(pullRequests), + staleTime: LINKS_STALE_TIME, + }); +} + /** * Links for each pull request, fetched one page-sized chunk at a time so a * list that loads another page reuses the earlier chunks' cache entries. @@ -261,13 +271,7 @@ export function usePrLinksQuery( ) { const chunks = createMemo(() => chunk(pullRequests(), LINKS_CHUNK_SIZE)); const queries = useQueries(() => ({ - queries: chunks().map((pullRequests) => ({ - queryKey: agentSessionPullRequestKeys.linksForPullRequests( - pullRequests.map((pullRequest) => pullRequest.url) - ).queryKey, - queryFn: () => fetchPrLinks(pullRequests), - staleTime: LINKS_STALE_TIME, - })), + queries: chunks().map((pullRequests) => prLinksQueryOptions(pullRequests)), })); const page = createMemo(() => { diff --git a/apps/web/src/features/calendar/availability/use-availability-ranges.ts b/apps/web/src/features/calendar/availability/use-availability-ranges.ts index 1af9f256577..d89f7579663 100644 --- a/apps/web/src/features/calendar/availability/use-availability-ranges.ts +++ b/apps/web/src/features/calendar/availability/use-availability-ranges.ts @@ -2,12 +2,14 @@ import { useUserId } from '@core/context/user'; import { fireAndForget } from '@core/util/fireAndForget'; import { calendarKeys } from '@queries/calendar/keys'; import { + type CalendarOccurrenceQueryRange, createCalendarOccurrenceQueryRange, fetchCalendarOccurrences, useCalendarOccurrencesQuery, } from '@queries/calendar/occurrences'; import { queryClient } from '@queries/client'; import { CalendarSyncStatus } from '@service-storage/generated/schemas/calendarSyncStatus'; +import { queryOptions } from '@tanstack/solid-query'; import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { AVAILABILITY_RANGE_OPTIONS, @@ -19,6 +21,17 @@ import { resolveAvailabilityWindow, } from './availability'; +function currentOccurrencesQueryOptions( + userId: string | undefined, + range: CalendarOccurrenceQueryRange +) { + return queryOptions({ + queryKey: calendarKeys.occurrences(userId ?? '', range).queryKey, + queryFn: ({ signal }) => fetchCalendarOccurrences(range, signal), + staleTime: 0, + }); +} + /** Evaluates every copy range against the same occurrence snapshot and settings. */ export function useAvailabilityRanges(settings: () => AvailabilitySettings) { const userId = useUserId(); @@ -69,11 +82,9 @@ export function useAvailabilityRanges(settings: () => AvailabilitySettings) { ); // A copy must use current occurrences, even when the initial query's // 60-second cache window has not expired yet. - const data = await queryClient.fetchQuery({ - queryKey: calendarKeys.occurrences(userId() ?? '', range).queryKey, - queryFn: ({ signal }) => fetchCalendarOccurrences(range, signal), - staleTime: 0, - }); + const data = await queryClient.fetchQuery( + currentOccurrencesQueryOptions(userId(), range) + ); if (data.syncStatus === CalendarSyncStatus.syncing) return; const currentTime = new Date(); diff --git a/apps/web/src/features/calendar/queries/event-replacement-source.ts b/apps/web/src/features/calendar/queries/event-replacement-source.ts index 9cd9cc2f3ba..2ea57e71c24 100644 --- a/apps/web/src/features/calendar/queries/event-replacement-source.ts +++ b/apps/web/src/features/calendar/queries/event-replacement-source.ts @@ -6,7 +6,7 @@ import { invalidateCalendarEventPreviews } from '@queries/calendar/mention-previ import { invalidateCalendarOccurrences } from '@queries/calendar/occurrences'; import type { CalendarReplacementView } from '@service-calendar/generated/schemas'; import { emailClient } from '@service-email/client'; -import { useMutation, useQuery } from '@tanstack/solid-query'; +import { queryOptions, useMutation, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import type { EventReplacementSource } from '../context/event-replacement-source'; import type { @@ -84,24 +84,28 @@ export function useEventReplacementSource( }; } -/** Mounted inside the small provider-action Suspense boundary, never the calendar viewport. */ -export function useCalendarProviderUrl( - target: Accessor -) { - return useQuery(() => ({ +function calendarProviderUrlQueryOptions(target: EventReplacementTarget) { + return queryOptions({ queryKey: [ ...calendarKeys._def, 'provider-url', - target().eventId, - target().calendarId, - target().recurrenceId, + target.eventId, + target.calendarId, + target.recurrenceId, ], queryFn: async () => ( await throwOnErr(() => - emailClient.calendarEventProviderUrl(target().eventId, target()) + emailClient.calendarEventProviderUrl(target.eventId, target) ) ).url, staleTime: 60_000, - })); + }); +} + +/** Mounted inside the small provider-action Suspense boundary, never the calendar viewport. */ +export function useCalendarProviderUrl( + target: Accessor +) { + return useQuery(() => calendarProviderUrlQueryOptions(target())); } diff --git a/apps/web/src/features/crm/queries/contact-suggestions.ts b/apps/web/src/features/crm/queries/contact-suggestions.ts index 895e3d4aaea..5da26b7fe0e 100644 --- a/apps/web/src/features/crm/queries/contact-suggestions.ts +++ b/apps/web/src/features/crm/queries/contact-suggestions.ts @@ -2,11 +2,36 @@ import type { CrmContactEntity } from '@entity'; import { queryReadyGate } from '@queries/gate'; import { soupKeys } from '@queries/soup/keys'; import { fetchCrmContacts } from '@service-storage/crm-contacts'; -import { useInfiniteQuery } from '@tanstack/solid-query'; +import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/solid-query'; import { type Accessor, createMemo } from 'solid-js'; import type { CrmRecordDependencies } from './dependencies'; import { toCrmContactEntity } from './graphql'; +function crmContactsQueryOptions( + searchTerm: string, + limit: number, + enabled: boolean +) { + return infiniteQueryOptions({ + queryKey: [...soupKeys.crmPeople('all').queryKey, searchTerm, limit], + enabled, + staleTime: 60_000, + initialPageParam: null as string | null, + // Each page names the search it answers, so a consumer reading + // pages during a key change cannot attribute them to the new search. + queryFn: async ({ pageParam, signal }) => ({ + ...(await fetchCrmContacts({ + cursor: pageParam, + search: searchTerm, + limit, + signal, + })), + search: searchTerm, + }), + getNextPageParam: (page) => page.nextCursor, + }); +} + /** Cursor-paged GraphQL source for contact suggestions. */ export function useCrmContactsQuery( deps: Pick, @@ -15,27 +40,7 @@ export function useCrmContactsQuery( limit = 100 ) { return useInfiniteQuery( - () => { - const searchTerm = search().trim(); - return { - queryKey: [...soupKeys.crmPeople('all').queryKey, searchTerm, limit], - enabled: enabled(), - staleTime: 60_000, - initialPageParam: null as string | null, - // Each page names the search it answers, so a consumer reading - // pages during a key change cannot attribute them to the new search. - queryFn: async ({ pageParam, signal }) => ({ - ...(await fetchCrmContacts({ - cursor: pageParam, - search: searchTerm, - limit, - signal, - })), - search: searchTerm, - }), - getNextPageParam: (page) => page.nextCursor, - }; - }, + () => crmContactsQueryOptions(search().trim(), limit, enabled()), () => deps.client ); } diff --git a/apps/web/src/features/crm/queries/pipeline-views.ts b/apps/web/src/features/crm/queries/pipeline-views.ts index b30456b0453..89666d10ea4 100644 --- a/apps/web/src/features/crm/queries/pipeline-views.ts +++ b/apps/web/src/features/crm/queries/pipeline-views.ts @@ -3,7 +3,7 @@ import { throwOnErr } from '@core/util/result'; import type { DatabaseView } from '@service-storage/generated/schemas/databaseView'; import type { OpResult } from '@service-storage/generated/schemas/opResult'; import type { TableDetail } from '@service-storage/generated/schemas/tableDetail'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import { errAsync, okAsync, ResultAsync } from 'neverthrow'; import { match } from 'ts-pattern'; import { v7 as uuidv7 } from 'uuid'; @@ -28,17 +28,24 @@ import { crmKeys } from './keys'; type ViewOpResult = Extract; +function pipelineTableQueryOptions( + deps: CrmRecordDependencies, + pipelineId: string +) { + return queryOptions({ + queryKey: crmKeys.pipelineTable(pipelineId).queryKey, + queryFn: () => + throwOnErr(() => deps.storage.getCrmPipelineTable(pipelineId)), + }); +} + /** The pipeline's single table, whose stored views the toolbar lists. */ export function usePipelineTableQuery( deps: CrmRecordDependencies, pipeline: Pipeline ) { return useQuery( - () => ({ - queryKey: crmKeys.pipelineTable(pipeline.id).queryKey, - queryFn: () => - throwOnErr(() => deps.storage.getCrmPipelineTable(pipeline.id)), - }), + () => pipelineTableQueryOptions(deps, pipeline.id), () => deps.client ); } diff --git a/apps/web/src/features/crm/queries/pipelines.ts b/apps/web/src/features/crm/queries/pipelines.ts index 29cbc4fe257..5d19c6d3034 100644 --- a/apps/web/src/features/crm/queries/pipelines.ts +++ b/apps/web/src/features/crm/queries/pipelines.ts @@ -1,23 +1,30 @@ import { fireAndForget } from '@core/util/fireAndForget'; import { throwOnErr } from '@core/util/result'; import { queryReadyGate } from '@queries/gate'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import type { PipelinesSource } from '../context/pipelines'; import type { Pipeline } from '../core/pipeline'; import type { CrmQueryDependencies } from './dependencies'; import { crmKeys } from './keys'; +function pipelinesQueryOptions( + deps: CrmQueryDependencies, + teamId: string | undefined +) { + return queryOptions({ + queryKey: crmKeys.pipelines(teamId).queryKey, + queryFn: () => throwOnErr(() => deps.storage.listCrmPipelines()), + enabled: !!teamId, + }); +} + export function createPipelinesSource( deps: CrmQueryDependencies, teamId: Accessor ): PipelinesSource { const query = useQuery( - () => ({ - queryKey: crmKeys.pipelines(teamId()).queryKey, - queryFn: () => throwOnErr(() => deps.storage.listCrmPipelines()), - enabled: !!teamId(), - }), + () => pipelinesQueryOptions(deps, teamId()), () => deps.client ); const refresh = async () => { diff --git a/apps/web/src/features/database-query/queries/app-query-source.ts b/apps/web/src/features/database-query/queries/app-query-source.ts index e5270d14382..4c693745de1 100644 --- a/apps/web/src/features/database-query/queries/app-query-source.ts +++ b/apps/web/src/features/database-query/queries/app-query-source.ts @@ -2,7 +2,6 @@ import { handleAiUsageLimitError } from '@app/features/paywall/ai-usage-limit-ha import type { TModel } from '@core/component/AI/constant'; import { databaseSqlAnswer } from '@core/database-sql/answer'; import { databaseSqlSchema } from '@core/database-sql/catalog'; -import { throwOnErr } from '@core/util/result'; import { queryClient } from '@queries/client'; import { checkDatabaseSql, @@ -13,13 +12,12 @@ import { useDatabaseQueryDefinition, } from '@queries/storage/database-queries'; import { + databaseDetailQueryOptions, fetchViewerDatabases, useViewerDatabases, } from '@queries/storage/databases'; import { useDatabaseTableChanges } from '@queries/storage/databases-sync'; -import { databasesKeys } from '@queries/storage/keys'; import { useEntitySubscription } from '@service-connection/client'; -import { storageServiceClient } from '@service-storage/client'; import type { DatabaseDetail } from '@service-storage/generated/schemas/databaseDetail'; import { ResultAsync } from 'neverthrow'; import type { Accessor } from 'solid-js'; @@ -94,11 +92,7 @@ export function createQueryCapabilities( describe: (databaseId) => ResultAsync.fromPromise( queryClient.fetchQuery({ - queryKey: databasesKeys.detail(databaseId).queryKey, - queryFn: () => - throwOnErr(() => - storageServiceClient.databases.get({ id: databaseId }) - ), + ...databaseDetailQueryOptions(databaseId), staleTime: 0, }), databasesFailure diff --git a/apps/web/src/features/database/queries/api-source.ts b/apps/web/src/features/database/queries/api-source.ts index 713d9f32a09..e554667ae6a 100644 --- a/apps/web/src/features/database/queries/api-source.ts +++ b/apps/web/src/features/database/queries/api-source.ts @@ -1,5 +1,10 @@ import { queryReadyGate } from '@queries/gate'; -import { type QueryClient, useQuery } from '@tanstack/solid-query'; +import { + type QueryClient, + type QueryKey, + queryOptions, + useQuery, +} from '@tanstack/solid-query'; import { err, ok, ResultAsync } from 'neverthrow'; import { type Accessor, createSignal } from 'solid-js'; import { v7 as uuidv7 } from 'uuid'; @@ -52,6 +57,59 @@ export async function readDatabasePages( }); } +function tableSchemaQueryOptions( + api: DatabaseApi, + key: QueryKey, + tableId: string +) { + return queryOptions({ + queryKey: [...key, 'schema'], + // Polls refetch the schema; stable columns keep cells from remounting previews. + reconcile: 'id', + queryFn: async () => { + const result = await api.readTable(tableId); + if (result.isErr()) throw result.error; + return result.value; + }, + }); +} + +function tableRowsQueryOptions( + api: DatabaseApi, + key: QueryKey, + request: DatabaseRowRequest +) { + return queryOptions({ + queryKey: [...key, 'rows', request.query], + queryFn: async () => { + const result = await readDatabasePages(api, request); + if (result.isErr()) throw result.error; + return result.value; + }, + }); +} + +function retainedRowsQueryOptions( + api: DatabaseApi, + key: QueryKey, + tableId: string, + rowIds: string[] +) { + return queryOptions({ + queryKey: [...key, 'retained', [...rowIds].sort()], + enabled: rowIds.length > 0, + queryFn: async () => { + const result = await readDatabasePages(api, { + tableId, + query: { filter: null }, + rowIds, + }); + if (result.isErr()) throw result.error; + return result.value; + }, + }); +} + /** Default data adapter for a paginated, database-shaped API. */ export function createDatabaseApiSource(args: { api: DatabaseApi; @@ -65,46 +123,16 @@ export function createDatabaseApiSource(args: { () => [] ); const table = useQuery( - () => ({ - queryKey: [...key, 'schema'], - // Polls refetch the schema; stable columns keep cells from remounting previews. - reconcile: 'id', - queryFn: async () => { - const result = await api.readTable(tableId); - if (result.isErr()) throw result.error; - return result.value; - }, - }), + () => tableSchemaQueryOptions(api, key, tableId), () => client ); const rows = useQuery( - () => ({ - queryKey: [...key, 'rows', args.view().query], - queryFn: async () => { - const result = await readDatabasePages(api, { - tableId, - query: args.view().query, - }); - if (result.isErr()) throw result.error; - return result.value; - }, - }), + () => + tableRowsQueryOptions(api, key, { tableId, query: args.view().query }), () => client ); const held = useQuery( - () => ({ - queryKey: [...key, 'retained', [...retained()()].sort()], - enabled: retained()().length > 0, - queryFn: async () => { - const result = await readDatabasePages(api, { - tableId, - query: { filter: null }, - rowIds: [...retained()()], - }); - if (result.isErr()) throw result.error; - return result.value; - }, - }), + () => retainedRowsQueryOptions(api, key, tableId, [...retained()()]), () => client ); const schema = () => (queryReadyGate(table) ? table.data : undefined); diff --git a/apps/web/src/features/email-compose/queries/draft-lifecycle.ts b/apps/web/src/features/email-compose/queries/draft-lifecycle.ts index 2b613de848b..40ed72d3411 100644 --- a/apps/web/src/features/email-compose/queries/draft-lifecycle.ts +++ b/apps/web/src/features/email-compose/queries/draft-lifecycle.ts @@ -8,7 +8,12 @@ import { import { emailKeys } from '@queries/email/keys'; import { fetchFreshEmailThread } from '@queries/email/thread'; import type { ApiMessage } from '@service-email/generated/schemas'; -import { useQuery, useQueryClient } from '@tanstack/solid-query'; +import { + type QueryKey, + queryOptions, + useQuery, + useQueryClient, +} from '@tanstack/solid-query'; import { createSignal, onCleanup } from 'solid-js'; import type { EmailDraftLifecycleSource, @@ -92,6 +97,47 @@ function refreshInterval(state: EmailDraftLifecycleState | undefined) { : DUE_REFRESH_INTERVAL_MS; } +type DraftIdentity = Parameters[0]; +/** The observer's latest refresh; a read started before it is stale. */ +type RefreshVersion = { current: number }; + +async function readLifecycle(identity: DraftIdentity, version: RefreshVersion) { + const started = version.current; + const state = await fetchLifecycle(identity); + return { state, version: started }; +} + +function lifecycleReadOptions( + queryKey: QueryKey, + identity: DraftIdentity, + version: RefreshVersion +) { + return { + queryKey, + queryFn: () => readLifecycle(identity, version), + staleTime: 0, + }; +} + +function lifecycleQueryOptions( + queryKey: QueryKey, + identity: DraftIdentity, + version: RefreshVersion +) { + return queryOptions({ + ...lifecycleReadOptions(queryKey, identity, version), + enabled: identity.draftId.length > 0 && identity.threadId.length > 0, + refetchOnWindowFocus: 'always', + refetchOnReconnect: 'always', + refetchInterval: (stateQuery) => + refreshInterval( + stateQuery.state.data?.version === version.current + ? stateQuery.state.data.state + : undefined + ), + }); +} + export const emailDraftLifecycleSource: EmailDraftLifecycleSource = { observe(input) { const queryClient = useQueryClient(); @@ -112,31 +158,17 @@ export const emailDraftLifecycleSource: EmailDraftLifecycleSource = { threadId: string; inboxId?: string; }>(); - let refreshVersion = 0; + const refreshVersion: RefreshVersion = { current: 0 }; const [requiredReadVersion, setRequiredReadVersion] = createSignal(0); - const read = async (identity: Parameters[0]) => { - const version = refreshVersion; - const state = await fetchLifecycle(identity); - return { state, version }; - }; const query = useQuery(() => { const draftId = input.draftId() ?? ''; const threadId = input.threadId() ?? ''; const inboxId = input.inboxId(); - return { - queryKey: observerQueryKey(draftId, threadId, inboxId), - enabled: draftId.length > 0 && threadId.length > 0, - queryFn: () => read({ draftId, threadId, inboxId }), - staleTime: 0, - refetchOnWindowFocus: 'always' as const, - refetchOnReconnect: 'always' as const, - refetchInterval: (stateQuery) => - refreshInterval( - stateQuery.state.data?.version === refreshVersion - ? stateQuery.state.data.state - : undefined - ), - }; + return lifecycleQueryOptions( + observerQueryKey(draftId, threadId, inboxId), + { draftId, threadId, inboxId }, + refreshVersion + ); }); const refresh = async () => { @@ -145,7 +177,7 @@ export const emailDraftLifecycleSource: EmailDraftLifecycleSource = { const inboxId = input.inboxId(); if (!draftId || !threadId) return undefined; - const version = ++refreshVersion; + const version = ++refreshVersion.current; // Once a mutation needs reconciliation, no earlier observation is // authoritative again, even if the fresh read fails or query-store // notifications are delayed. A later successful poll can satisfy this @@ -158,12 +190,14 @@ export const emailDraftLifecycleSource: EmailDraftLifecycleSource = { // read. Explicit cancellation prevents its late result from entering // this observer's cache; the REST query below starts independently. await queryClient.cancelQueries({ queryKey, exact: true }); - const result = await queryClient.fetchQuery({ - queryKey, - queryFn: () => read({ draftId, threadId, inboxId }), - staleTime: 0, - }); - return version === refreshVersion && + const result = await queryClient.fetchQuery( + lifecycleReadOptions( + queryKey, + { draftId, threadId, inboxId }, + refreshVersion + ) + ); + return version === refreshVersion.current && result.version === version && draftId === input.draftId() && threadId === input.threadId() && @@ -171,7 +205,7 @@ export const emailDraftLifecycleSource: EmailDraftLifecycleSource = { ? result.state : undefined; } finally { - if (version === refreshVersion) setRefreshing(undefined); + if (version === refreshVersion.current) setRefreshing(undefined); } }; diff --git a/apps/web/src/features/email-message/queries/message-operation.ts b/apps/web/src/features/email-message/queries/message-operation.ts index f574c0119ba..062e8ad29fd 100644 --- a/apps/web/src/features/email-message/queries/message-operation.ts +++ b/apps/web/src/features/email-message/queries/message-operation.ts @@ -3,27 +3,28 @@ import { queryClient } from '@queries/client'; import { refreshEmailThreadCache } from '@queries/email/cache-cleanup'; import { emailKeys } from '@queries/email/keys'; import { emailClient } from '@service-email/client'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import { createSignal } from 'solid-js'; import type { MessageOperationSourceFactory } from '../context/message-operation-source'; -export const createMessageOperationSource: MessageOperationSourceFactory = ( - messageId, - threadId -) => { - const [needsReload, setNeedsReload] = createSignal(false); - let refreshedRemote = false; - const query = useQuery(() => ({ - queryKey: emailKeys.messageOperation(messageId() ?? '').queryKey, - enabled: !!messageId(), +/** After taking the provider's copy, reload the thread once it synchronizes. */ +type RemoteReload = { requested: boolean; done: boolean }; + +function messageOperationQueryOptions( + messageId: string | undefined, + thread: string | undefined, + reload: RemoteReload +) { + return queryOptions({ + queryKey: emailKeys.messageOperation(messageId ?? '').queryKey, + enabled: !!messageId, queryFn: async () => { const result = await throwOnErr(() => - emailClient.messageOperation(messageId()!) + emailClient.messageOperation(messageId!) ); - const thread = threadId?.(); if ( - needsReload() && - !refreshedRemote && + reload.requested && + !reload.done && result.operation?.state === 'SYNCHRONIZED' && thread ) { @@ -31,7 +32,7 @@ export const createMessageOperationSource: MessageOperationSourceFactory = ( await queryClient.invalidateQueries({ queryKey: emailKeys.thread(thread).queryKey, }); - refreshedRemote = true; + reload.done = true; } return result; }, @@ -43,7 +44,18 @@ export const createMessageOperationSource: MessageOperationSourceFactory = ( ? 5000 : false; }, - })); + }); +} + +export const createMessageOperationSource: MessageOperationSourceFactory = ( + messageId, + threadId +) => { + const [needsReload, setNeedsReload] = createSignal(false); + const reload: RemoteReload = { requested: false, done: false }; + const query = useQuery(() => + messageOperationQueryOptions(messageId(), threadId?.(), reload) + ); return { needsReload, operation: () => { @@ -70,7 +82,10 @@ export const createMessageOperationSource: MessageOperationSourceFactory = ( accept_duplicate_risk: acceptDuplicateRisk, }) ); - if (action === 'use_provider') setNeedsReload(true); + if (action === 'use_provider') { + reload.requested = true; + setNeedsReload(true); + } await query.refetch(); await queryClient.invalidateQueries({ queryKey: emailKeys._def }); const thread = threadId?.(); diff --git a/apps/web/src/features/reviews-view/queries/use-reviews-facets-query.ts b/apps/web/src/features/reviews-view/queries/use-reviews-facets-query.ts index ec489535e4f..eeaf6c38ab8 100644 --- a/apps/web/src/features/reviews-view/queries/use-reviews-facets-query.ts +++ b/apps/web/src/features/reviews-view/queries/use-reviews-facets-query.ts @@ -2,7 +2,7 @@ import type { ListControlOption } from '@app/components/view-shell'; import { throwOnErr } from '@core/util/result'; import type { GithubPullRequestLabel } from '@entity/types/entity'; import { storageServiceClient } from '@service-storage/client'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; const REVIEWS_FACETS_STALE_TIME = 60_000; @@ -13,18 +13,20 @@ const userOption = (user: UserFacet): ListControlOption => ({ label: user.login ?? user.githubUserId, }); +const reviewsFacetsQueryOptions = queryOptions({ + queryKey: ['reviews', 'githubPullRequestFacets'] as const, + queryFn: () => + throwOnErr(() => storageServiceClient.getGithubPullRequestFacets()), + staleTime: REVIEWS_FACETS_STALE_TIME, +}); + /** * Filter options across every pull request visible to the viewer and their * team, not only the loaded pages. Repository, author, and assignee option ids * are the numeric GitHub ids the backend filters match; label ids are names. */ export function useReviewsFacetsQuery() { - const query = useQuery(() => ({ - queryKey: ['reviews', 'githubPullRequestFacets'] as const, - queryFn: () => - throwOnErr(() => storageServiceClient.getGithubPullRequestFacets()), - staleTime: REVIEWS_FACETS_STALE_TIME, - })); + const query = useQuery(() => reviewsFacetsQueryOptions); const facets = () => (query.isSuccess ? query.data : undefined); const repositories = (): ListControlOption[] => diff --git a/apps/web/src/features/routines/queries/routines.ts b/apps/web/src/features/routines/queries/routines.ts index 3249d363fcc..3519115cc0b 100644 --- a/apps/web/src/features/routines/queries/routines.ts +++ b/apps/web/src/features/routines/queries/routines.ts @@ -1,13 +1,17 @@ import { throwOnErr } from '@core/util/result'; import { scheduledActionClient } from '@service-scheduled-action/client'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { scheduledActionKeys } from './keys'; -export function useRoutineQuery(id: Accessor) { - return useQuery(() => ({ - queryKey: scheduledActionKeys.detail({ scheduleId: id() }).queryKey, - queryFn: () => throwOnErr(() => scheduledActionClient.getRoutine(id())), +function routineQueryOptions(id: string) { + return queryOptions({ + queryKey: scheduledActionKeys.detail({ scheduleId: id }).queryKey, + queryFn: () => throwOnErr(() => scheduledActionClient.getRoutine(id)), refetchOnWindowFocus: 'always', - })); + }); +} + +export function useRoutineQuery(id: Accessor) { + return useQuery(() => routineQueryOptions(id())); } diff --git a/apps/web/src/features/scheduling/queries/public.ts b/apps/web/src/features/scheduling/queries/public.ts index 4580e1a3485..36faf38d23a 100644 --- a/apps/web/src/features/scheduling/queries/public.ts +++ b/apps/web/src/features/scheduling/queries/public.ts @@ -1,7 +1,7 @@ import { ThrownResultError, throwOnErr } from '@core/util/result'; import { queryClient } from '@queries/client'; import { schedulingClient } from '@service-email/scheduling'; -import { useMutation, useQuery } from '@tanstack/solid-query'; +import { queryOptions, useMutation, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { BookingRejectedError } from '../core/booking-error'; import type { BookingReceipt, BookingRequest } from '../core/types'; @@ -24,13 +24,75 @@ function validateReceipt(receipt: BookingReceipt): BookingReceipt { return receipt; } +function publicProfileQueryOptions(id: string) { + return queryOptions({ + queryKey: schedulingKeys.publicProfile(id).queryKey, + queryFn: async () => + (await throwOnErr(() => schedulingClient.profile(id))).profile, + retry: false, + }); +} + export function usePublicProfileQuery(id: Accessor) { - return useQuery(() => ({ - queryKey: schedulingKeys.publicProfile(id()).queryKey, + return useQuery(() => publicProfileQueryOptions(id())); +} + +function slotsQueryOptions( + profile: string, + event: string, + date: string, + timeZone: string +) { + return queryOptions({ + queryKey: schedulingKeys.slots(profile, event, `${date}:${timeZone}`) + .queryKey, + staleTime: 0, + retry: false, queryFn: async () => - (await throwOnErr(() => schedulingClient.profile(id()))).profile, + ( + await throwOnErr(() => + schedulingClient.slots(profile, event, date, timeZone) + ) + ).slots, + }); +} + +function receiptQueryOptions(id: string, token: string) { + return queryOptions({ + queryKey: schedulingKeys.receipt(id, token).queryKey, + queryFn: async () => + validateReceipt( + await throwOnErr(() => schedulingClient.receipt(id, token)) + ), retry: false, - })); + gcTime: 0, + refetchInterval: (q) => + q.state.data?.booking.status === 'processing' || + q.state.data?.booking.status === 'failed' + ? 5000 + : false, + }); +} + +function replacementSlotsQueryOptions( + id: string, + token: string, + date: string, + timeZone: string +) { + return queryOptions({ + queryKey: schedulingKeys.replacementSlots(id, token, date, timeZone) + .queryKey, + queryFn: async () => + ( + await throwOnErr(() => + schedulingClient.replacementSlots(id, token, date, timeZone) + ) + ).slots, + staleTime: 0, + gcTime: 0, + retry: false, + }); } export function createPublicBookingSource() { @@ -70,18 +132,7 @@ export function createPublicBookingSource() { })); return { slots: (profile: string, event: string, date: string, timeZone: string) => - queryClient.fetchQuery({ - queryKey: schedulingKeys.slots(profile, event, `${date}:${timeZone}`) - .queryKey, - staleTime: 0, - retry: false, - queryFn: async () => - ( - await throwOnErr(() => - schedulingClient.slots(profile, event, date, timeZone) - ) - ).slots, - }), + queryClient.fetchQuery(slotsQueryOptions(profile, event, date, timeZone)), book: (profile: string, event: string, request: BookingRequest) => book.mutateAsync({ profile, event, request }), }; @@ -91,20 +142,7 @@ export function createBookingReceiptSource( id: Accessor, token: Accessor ) { - const receipt = useQuery(() => ({ - queryKey: schedulingKeys.receipt(id(), token()).queryKey, - queryFn: async () => - validateReceipt( - await throwOnErr(() => schedulingClient.receipt(id(), token())) - ), - retry: false, - gcTime: 0, - refetchInterval: (q) => - q.state.data?.booking.status === 'processing' || - q.state.data?.booking.status === 'failed' - ? 5000 - : false, - })); + const receipt = useQuery(() => receiptQueryOptions(id(), token())); const cancel = useMutation(() => ({ mutationFn: () => throwOnErr(() => schedulingClient.cancelPublic(id(), token())), @@ -126,18 +164,8 @@ export function createBookingReceiptSource( await receipt.refetch(); }, replacementSlots: (date: string, timeZone: string) => - queryClient.fetchQuery({ - queryKey: schedulingKeys.replacementSlots(id(), token(), date, timeZone) - .queryKey, - queryFn: async () => - ( - await throwOnErr(() => - schedulingClient.replacementSlots(id(), token(), date, timeZone) - ) - ).slots, - staleTime: 0, - gcTime: 0, - retry: false, - }), + queryClient.fetchQuery( + replacementSlotsQueryOptions(id(), token(), date, timeZone) + ), }; } diff --git a/apps/web/src/features/settings/queries/inbox-settings-operations.ts b/apps/web/src/features/settings/queries/inbox-settings-operations.ts index 55a274cf3e9..0f1e098a06a 100644 --- a/apps/web/src/features/settings/queries/inbox-settings-operations.ts +++ b/apps/web/src/features/settings/queries/inbox-settings-operations.ts @@ -1,14 +1,17 @@ import { throwOnErr } from '@core/util/result'; import { emailKeys } from '@queries/email/keys'; import { emailClient } from '@service-email/client'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; -export function useInboxSettingsOperations(linkId: Accessor) { - const operations = useQuery(() => ({ - queryKey: emailKeys.settingsOperations(linkId()).queryKey, - queryFn: () => throwOnErr(() => emailClient.settingsOperations(linkId())), +function inboxSettingsOperationsQueryOptions(linkId: string) { + return queryOptions({ + queryKey: emailKeys.settingsOperations(linkId).queryKey, + queryFn: () => throwOnErr(() => emailClient.settingsOperations(linkId)), refetchInterval: 5000, - })); - return operations; + }); +} + +export function useInboxSettingsOperations(linkId: Accessor) { + return useQuery(() => inboxSettingsOperationsQueryOptions(linkId())); } diff --git a/apps/web/src/features/tasks-view/queries/use-tasks-query.ts b/apps/web/src/features/tasks-view/queries/use-tasks-query.ts index 4b52eaaa08c..812d697a9a6 100644 --- a/apps/web/src/features/tasks-view/queries/use-tasks-query.ts +++ b/apps/web/src/features/tasks-view/queries/use-tasks-query.ts @@ -23,7 +23,10 @@ import { } from '@entity'; import { SYSTEM_PROPERTY_IDS } from '@property/constants'; import { createGroupedSoupQueries } from '@queries/soup/grouped/create-grouped-soup-queries'; -import { useSoupAstItemsQuery } from '@queries/soup/items'; +import { + type SoupApiItemFilter, + useSoupAstItemsQuery, +} from '@queries/soup/items'; import { mapApiSoupItemToEntity } from '@queries/soup/transform-utils'; import type { TagSetResponse } from '@service-properties/generated/schemas/tagSetResponse'; import { type Accessor, createMemo } from 'solid-js'; @@ -114,6 +117,32 @@ function namesCrmRecord(task: TaskEntityWithProperties, crm: TaskCrmScope) { ).includes(crm.id); } +/** A plain copy of the view's facet store, safe to keep in cached options. */ +function copyFacets( + facets: TaskViewContext['facets'] +): TaskViewContext['facets'] { + return Object.fromEntries( + Object.entries(facets).map(([facet, values]) => [facet, [...values]]) + ); +} + +/** Admits an optimistic row only where this scoped view shows it. */ +function taskInsertFilter( + context: TaskViewContext, + reference: TaskReferenceScope | undefined, + crm: TaskCrmScope | undefined +): SoupApiItemFilter { + return (item) => { + const entity = mapApiSoupItemToEntity(item); + return ( + isTaskEntity(entity) && + taskMatchesView(entity, context) && + (!reference || referencesEntity(entity, reference)) && + (!crm || namesCrmRecord(entity, crm)) + ); + }; +} + /** Query, service search, and row assembly owned by the production Tasks view. */ export function useTasksDataSource( state: TasksDataSourceInput, @@ -153,35 +182,28 @@ export function useTasksDataSource( const queryArgs = () => queryArgsFor(); const scoped = Boolean(options.reference || options.crm); + const viewContext = (): TaskViewContext => ({ + tab: state.tab, + userId: options.userId(), + facets: state.facets, + facetContext: facetContext(), + }); + const query = useSoupAstItemsQuery(queryArgs, () => ({ enabled: facetOptionsReady(), networkPaused: options.networkPaused?.(), keepPreviousData: scoped, - // Scoped lists admit optimistic rows only where this view shows them. meta: scoped ? { - insertFilter: (item) => { - const entity = mapApiSoupItemToEntity(item); - const reference = options.reference?.(); - const crm = options.crm?.(); - return ( - isTaskEntity(entity) && - taskMatchesView(entity, viewContext()) && - (!reference || referencesEntity(entity, reference)) && - (!crm || namesCrmRecord(entity, crm)) - ); - }, + insertFilter: taskInsertFilter( + { ...viewContext(), facets: copyFacets(state.facets) }, + options.reference?.(), + options.crm?.() + ), } : undefined, })); - const viewContext = (): TaskViewContext => ({ - tab: state.tab, - userId: options.userId(), - facets: state.facets, - facetContext: facetContext(), - }); - const transformEntities = (entities: EntityData[]) => { const selected: TaskEntityWithProperties[] = []; const context = viewContext(); diff --git a/apps/web/src/lib/core/component/AI/component/tool/email/DraftComposer.tsx b/apps/web/src/lib/core/component/AI/component/tool/email/DraftComposer.tsx index 12012c60b1c..472125b6660 100644 --- a/apps/web/src/lib/core/component/AI/component/tool/email/DraftComposer.tsx +++ b/apps/web/src/lib/core/component/AI/component/tool/email/DraftComposer.tsx @@ -44,8 +44,7 @@ import { useMessageInboxQuery, } from '@queries/email/link'; import { queryReadyGate } from '@queries/gate'; -import { fetchDocumentMetadata } from '@queries/storage/document-metadata'; -import { entityKeys } from '@queries/storage/keys'; +import { documentMetadataQueryOptions } from '@queries/storage/document-metadata'; import type { SendEmail } from '@service-cognition/generated/tools/types'; import { FileTypeMap } from '@service-storage/fileTypeMap'; import { debounce } from '@solid-primitives/scheduled'; @@ -242,11 +241,9 @@ export function EmailDraftComposer(props: EmailDraftComposerProps) { const describeDocument = async ( documentId: string ): Promise => { - const metadata = await queryClient.fetchQuery({ - queryKey: entityKeys.documentMetadata(documentId).queryKey, - queryFn: () => fetchDocumentMetadata(documentId), - staleTime: 60 * 1000, - }); + const metadata = await queryClient.fetchQuery( + documentMetadataQueryOptions(documentId) + ); const fileType = metadata.fileType ?? undefined; return { fileName: documentAttachmentFileName(metadata.documentName, fileType), diff --git a/apps/web/src/lib/queries/agent-session/pull-requests.ts b/apps/web/src/lib/queries/agent-session/pull-requests.ts index e586a9f2d3e..81552ca24e2 100644 --- a/apps/web/src/lib/queries/agent-session/pull-requests.ts +++ b/apps/web/src/lib/queries/agent-session/pull-requests.ts @@ -1,41 +1,49 @@ import { throwOnErr } from '@core/util/result'; import { agentHarnessServiceClient } from '@service-agent-harness/client'; -import { useMutation, useQuery } from '@tanstack/solid-query'; +import { queryOptions, useMutation, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { queryClient } from '../client'; import { agentSessionPullRequestKeys } from './keys'; const PULL_REQUEST_LINKS_STALE_TIME = 30_000; -/** Ids of the viewable sessions linked to the pull request at `url`. */ -export function usePullRequestAgentSessionsQuery( - url: Accessor -) { - return useQuery(() => ({ - queryKey: agentSessionPullRequestKeys.forPullRequest(url() ?? '').queryKey, +function pullRequestAgentSessionsQueryOptions(url: string | undefined) { + return queryOptions({ + queryKey: agentSessionPullRequestKeys.forPullRequest(url ?? '').queryKey, queryFn: async () => ( await throwOnErr(() => - agentHarnessServiceClient.sessionsForPullRequest(url()!) + agentHarnessServiceClient.sessionsForPullRequest(url!) ) ).sessionIds, - enabled: Boolean(url()), + enabled: Boolean(url), staleTime: PULL_REQUEST_LINKS_STALE_TIME, - })); + }); } -/** The pull requests linked to a session, with who linked each. */ -export function useAgentSessionPullRequestsQuery(sessionId: Accessor) { - return useQuery(() => ({ - queryKey: agentSessionPullRequestKeys.forSession(sessionId()).queryKey, +/** Ids of the viewable sessions linked to the pull request at `url`. */ +export function usePullRequestAgentSessionsQuery( + url: Accessor +) { + return useQuery(() => pullRequestAgentSessionsQueryOptions(url())); +} + +function agentSessionPullRequestsQueryOptions(sessionId: string) { + return queryOptions({ + queryKey: agentSessionPullRequestKeys.forSession(sessionId).queryKey, queryFn: async () => ( await throwOnErr(() => - agentHarnessServiceClient.listPullRequests(sessionId()) + agentHarnessServiceClient.listPullRequests(sessionId) ) ).pullRequests, staleTime: PULL_REQUEST_LINKS_STALE_TIME, - })); + }); +} + +/** The pull requests linked to a session, with who linked each. */ +export function useAgentSessionPullRequestsQuery(sessionId: Accessor) { + return useQuery(() => agentSessionPullRequestsQueryOptions(sessionId())); } type PullRequestLinkChange = { sessionId: string; url: string }; diff --git a/apps/web/src/lib/queries/agent-session/warm.ts b/apps/web/src/lib/queries/agent-session/warm.ts index 56cddc0b4b7..116330be7af 100644 --- a/apps/web/src/lib/queries/agent-session/warm.ts +++ b/apps/web/src/lib/queries/agent-session/warm.ts @@ -1,7 +1,11 @@ import { MACRO_NEW_BOT_ID } from '@core/constant/macroNew'; import { throwOnErr } from '@core/util/result'; import { agentHarnessServiceClient } from '@service-agent-harness/client'; -import { type QueryClient, useQuery } from '@tanstack/solid-query'; +import { + type QueryClient, + queryOptions, + useQuery, +} from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { v7 as uuidv7 } from 'uuid'; import { queryClient } from '../client'; @@ -15,36 +19,37 @@ type WarmSession = { expires: number; }; +function warmAgentSessionQueryOptions(owner: string | undefined) { + return queryOptions({ + queryKey: agentSessionWarmKeys.owner(owner).queryKey, + enabled: !!owner, + staleTime: CLIENT_WARM_TTL_MS, + gcTime: CLIENT_WARM_TTL_MS, + retry: false, + refetchOnWindowFocus: false, + queryFn: async () => { + if (!owner) return null; + // Keep one reservation in flight across Home -> Agents navigation. + // Aborting on unmount would abandon its server ID and warm another. + const result = await throwOnErr(() => + agentHarnessServiceClient.warm(uuidv7()) + ); + if (result.session) { + return { + id: result.session.id, + model: result.session.model, + instructions: result.session.instructions, + expires: Date.now() + CLIENT_WARM_TTL_MS, + } satisfies WarmSession; + } + return null; + }, + }); +} + /** Best-effort speculation; never read its data into a Suspense boundary. */ export function useWarmAgentSessionQuery(userId: Accessor) { - return useQuery(() => { - const owner = userId(); - return { - queryKey: agentSessionWarmKeys.owner(owner).queryKey, - enabled: !!owner, - staleTime: CLIENT_WARM_TTL_MS, - gcTime: CLIENT_WARM_TTL_MS, - retry: false, - refetchOnWindowFocus: false, - queryFn: async () => { - if (!owner) return null; - // Keep one reservation in flight across Home -> Agents navigation. - // Aborting on unmount would abandon its server ID and warm another. - const result = await throwOnErr(() => - agentHarnessServiceClient.warm(uuidv7()) - ); - if (result.session) { - return { - id: result.session.id, - model: result.session.model, - instructions: result.session.instructions, - expires: Date.now() + CLIENT_WARM_TTL_MS, - } satisfies WarmSession; - } - return null; - }, - }; - }); + return useQuery(() => warmAgentSessionQueryOptions(userId())); } /** Why a new session did or did not start on a warm reservation. */ diff --git a/apps/web/src/lib/queries/agents/agent-api-key.ts b/apps/web/src/lib/queries/agents/agent-api-key.ts index 2c66e0c0eac..d3d0095ef25 100644 --- a/apps/web/src/lib/queries/agents/agent-api-key.ts +++ b/apps/web/src/lib/queries/agents/agent-api-key.ts @@ -2,21 +2,25 @@ import { throwOnErr } from '@core/util/result'; import { queryClient } from '@queries/client'; import { authServiceClient } from '@service-auth/client'; import type { AgentApiKey } from '@service-auth/generated/schemas/agentApiKey'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { agentKeys } from './keys'; +function agentApiKeyQueryOptions(agentId: string | undefined) { + return queryOptions({ + queryKey: agentKeys.apiKey(agentId ?? '').queryKey, + enabled: agentId !== undefined, + queryFn: () => + throwOnErr(() => authServiceClient.getAgentApiKey(agentId ?? '')), + }); +} + /** * The agent's own API key, if it has one, and whether its owner's plan lets * it run on one. The response never carries a key. */ export function useAgentApiKeyQuery(agentId: Accessor) { - return useQuery(() => ({ - queryKey: agentKeys.apiKey(agentId() ?? '').queryKey, - enabled: agentId() !== undefined, - queryFn: () => - throwOnErr(() => authServiceClient.getAgentApiKey(agentId() ?? '')), - })); + return useQuery(() => agentApiKeyQueryOptions(agentId())); } function store(agentId: string, key: AgentApiKey) { diff --git a/apps/web/src/lib/queries/agents/capabilities.ts b/apps/web/src/lib/queries/agents/capabilities.ts index a46928a9f39..0d39f698c4f 100644 --- a/apps/web/src/lib/queries/agents/capabilities.ts +++ b/apps/web/src/lib/queries/agents/capabilities.ts @@ -1,16 +1,14 @@ import { throwOnErr } from '@core/util/result'; import { agentHarnessServiceClient } from '@service-agent-harness/client'; import type { DiscoverAgentCapabilitiesRequest } from '@service-agent-harness/generated/schemas'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; -/** Capabilities belong to a harness and selected model, never a provider alone. */ -export function useAgentCapabilitiesQuery( - target: () => DiscoverAgentCapabilitiesRequest | undefined +function agentCapabilitiesQueryOptions( + request: DiscoverAgentCapabilitiesRequest | undefined ) { - return useQuery(() => ({ - queryKey: ['agent-capabilities', target() ?? null] as const, + return queryOptions({ + queryKey: ['agent-capabilities', request ?? null] as const, queryFn: async ({ signal }: { signal: AbortSignal }) => { - const request = target(); if (!request) throw new Error('No capability target'); const response = await throwOnErr(() => agentHarnessServiceClient.discoverAgentCapabilities(request, signal) @@ -42,9 +40,16 @@ export function useAgentCapabilitiesQuery( }), }; }, - enabled: target() !== undefined, + enabled: request !== undefined, staleTime: 0, gcTime: 0, retry: false, - })); + }); +} + +/** Capabilities belong to a harness and selected model, never a provider alone. */ +export function useAgentCapabilitiesQuery( + target: () => DiscoverAgentCapabilitiesRequest | undefined +) { + return useQuery(() => agentCapabilitiesQueryOptions(target())); } diff --git a/apps/web/src/lib/queries/agents/coding-preferences.ts b/apps/web/src/lib/queries/agents/coding-preferences.ts index 51c77c3975e..468dd02399f 100644 --- a/apps/web/src/lib/queries/agents/coding-preferences.ts +++ b/apps/web/src/lib/queries/agents/coding-preferences.ts @@ -4,18 +4,20 @@ import { agentHarnessServiceClient, type CodingPreferencesBody, } from '@service-agent-harness/client'; -import { useMutation, useQuery } from '@tanstack/solid-query'; +import { queryOptions, useMutation, useQuery } from '@tanstack/solid-query'; import { agentKeys } from './keys'; +const codingPreferencesQueryOptions = queryOptions({ + queryKey: agentKeys.codingPreferences.queryKey, + queryFn: async () => + throwOnErr( + async () => await agentHarnessServiceClient.getCodingPreferences() + ), +}); + /** What the user's new coding sessions are told to do beyond their assignment. */ export function useCodingPreferencesQuery() { - return useQuery(() => ({ - queryKey: agentKeys.codingPreferences.queryKey, - queryFn: async () => - throwOnErr( - async () => await agentHarnessServiceClient.getCodingPreferences() - ), - })); + return useQuery(() => codingPreferencesQueryOptions); } /** Replaces the preferences optimistically and rolls back if the save fails. */ diff --git a/apps/web/src/lib/queries/auth/ai-billing.ts b/apps/web/src/lib/queries/auth/ai-billing.ts index 17a6f90188c..caf676dd867 100644 --- a/apps/web/src/lib/queries/auth/ai-billing.ts +++ b/apps/web/src/lib/queries/auth/ai-billing.ts @@ -16,6 +16,20 @@ type UseAiBillingSummaryQueryOptions = { enabled?: boolean | (() => boolean); }; +function aiBillingSummaryQueryOptions(enabled: boolean) { + return { + queryKey: authKeys.aiBillingSummary.queryKey, + queryFn: async () => + await throwOnErr( + async () => await authServiceClient.getAiBillingSummary() + ), + staleTime: AI_BILLING_SUMMARY_STALE_TIME, + throwOnError: false, + retry: 1, + enabled, + }; +} + /** * The signed-in user's AI usage position for the current billing period: * included allowance, usage, credits, and overage settings. The backend @@ -24,34 +38,26 @@ type UseAiBillingSummaryQueryOptions = { export function useAiBillingSummaryQuery( options?: UseAiBillingSummaryQueryOptions ) { - return useQuery(() => { - const enabled = + return useQuery(() => + aiBillingSummaryQueryOptions( typeof options?.enabled === 'function' ? options.enabled() - : (options?.enabled ?? true); - return { - queryKey: authKeys.aiBillingSummary.queryKey, - queryFn: async () => - await throwOnErr( - async () => await authServiceClient.getAiBillingSummary() - ), - staleTime: AI_BILLING_SUMMARY_STALE_TIME, - throwOnError: false, - retry: 1, - enabled, - }; - }); + : (options?.enabled ?? true) + ) + ); } +const aiBillingPlansQueryOptions = { + queryKey: authKeys.aiBillingPlans.queryKey, + queryFn: async () => + await throwOnErr(async () => await authServiceClient.getAiBillingPlans()), + staleTime: AI_BILLING_PLANS_STALE_TIME, + throwOnError: false, +}; + /** The plan catalog, credit packs, and overage cap bounds. */ export function useAiBillingPlansQuery() { - return useQuery(() => ({ - queryKey: authKeys.aiBillingPlans.queryKey, - queryFn: async () => - await throwOnErr(async () => await authServiceClient.getAiBillingPlans()), - staleTime: AI_BILLING_PLANS_STALE_TIME, - throwOnError: false, - })); + return useQuery(() => aiBillingPlansQueryOptions); } /** diff --git a/apps/web/src/lib/queries/bots/profiles.ts b/apps/web/src/lib/queries/bots/profiles.ts index 628e7175459..494d6cca36a 100644 --- a/apps/web/src/lib/queries/bots/profiles.ts +++ b/apps/web/src/lib/queries/bots/profiles.ts @@ -3,7 +3,7 @@ import { throwOnErr } from '@core/util/result'; import { storageServiceClient } from '@service-storage/client'; import type { BotOwnerProfile } from '@service-storage/generated/schemas/botOwnerProfile'; import { AsyncBatcher } from '@tanstack/pacer'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import { isHexHyphenatedId } from './bot-id'; import { botProfileKeys } from './keys'; @@ -85,11 +85,15 @@ class BotProfileLoader { export const botProfileLoader = new BotProfileLoader(); +function botProfileQueryOptions(botId: string) { + return queryOptions({ + queryKey: botProfileKeys.detail(botId).queryKey, + queryFn: () => botProfileLoader.load(botId), + enabled: botId.length > 0, + }); +} + /** Profile for a bare bot UUID. `null` when the backend does not know it. */ export function useBotProfile(botId: () => string) { - return useQuery(() => ({ - queryKey: botProfileKeys.detail(botId()).queryKey, - queryFn: () => botProfileLoader.load(botId()), - enabled: botId().length > 0, - })); + return useQuery(() => botProfileQueryOptions(botId())); } diff --git a/apps/web/src/lib/queries/calendar/invitations.ts b/apps/web/src/lib/queries/calendar/invitations.ts index 0cb666c07c5..5d4254ae00b 100644 --- a/apps/web/src/lib/queries/calendar/invitations.ts +++ b/apps/web/src/lib/queries/calendar/invitations.ts @@ -5,7 +5,7 @@ import { type MutationCallbacks, withCallbacks } from '@queries/utils'; import type { CalendarEvent } from '@service-calendar/generated/schemas/calendarEvent'; import { emailClient } from '@service-email/client'; import type { InvitationResolution } from '@service-email/generated/schemas/invitationResolution'; -import { useMutation, useQuery } from '@tanstack/solid-query'; +import { queryOptions, useMutation, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { copyFailureOutcome } from './copy'; import { calendarKeys } from './keys'; @@ -13,14 +13,18 @@ import { invalidateCalendarOccurrences } from './occurrences'; export type CalendarInvitationsData = Record; -/** One bounded batch per thread. Snapshot rendering never waits for this query. */ -export function useCalendarInvitationsQuery(threadId: Accessor) { - return useQuery(() => ({ - queryKey: calendarKeys.invitations(threadId()).queryKey, +function calendarInvitationsQueryOptions(threadId: string) { + return queryOptions({ + queryKey: calendarKeys.invitations(threadId).queryKey, queryFn: () => - throwOnErr(() => emailClient.getCalendarInvitations(threadId())), + throwOnErr(() => emailClient.getCalendarInvitations(threadId)), staleTime: 15_000, - })); + }); +} + +/** One bounded batch per thread. Snapshot rendering never waits for this query. */ +export function useCalendarInvitationsQuery(threadId: Accessor) { + return useQuery(() => calendarInvitationsQueryOptions(threadId())); } export function invalidateCalendarInvitations() { return queryClient.invalidateQueries({ diff --git a/apps/web/src/lib/queries/calendar/team.ts b/apps/web/src/lib/queries/calendar/team.ts index 0a99d3eb395..1d50a593294 100644 --- a/apps/web/src/lib/queries/calendar/team.ts +++ b/apps/web/src/lib/queries/calendar/team.ts @@ -4,7 +4,12 @@ import { authServiceClient } from '@service-auth/client'; import type { AvailabilityCalendarsResponse } from '@service-calendar/generated/schemas/availabilityCalendarsResponse'; import type { TeamCalendarSharingBody } from '@service-calendar/generated/schemas/teamCalendarSharingBody'; import { emailClient } from '@service-email/client'; -import { type QueryKey, useMutation, useQuery } from '@tanstack/solid-query'; +import { + type QueryKey, + queryOptions, + useMutation, + useQuery, +} from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { queryClient } from '../client'; import { ensureTeamCalendarExpiry } from './team-cache'; @@ -23,42 +28,61 @@ const freshness = { refetchOnReconnect: 'always' as const, }; +function calendarTeamIdentityQueryOptions( + userId: string | undefined, + enabled: boolean +) { + return queryOptions({ + queryKey: calendarTeamKeys.identity(userId ?? '').queryKey, + queryFn: () => throwOnErr(() => authServiceClient.getTeam()), + enabled: Boolean(userId) && enabled, + ...freshness, + }); +} + /** A viewer-scoped roster read avoids the legacy current-team singleton cache. */ export function useCalendarTeamIdentityQuery( userId: Accessor, enabled: Accessor ) { ensureTeamCalendarExpiry(); - return useQuery(() => ({ - queryKey: calendarTeamKeys.identity(userId() ?? '').queryKey, - queryFn: () => throwOnErr(() => authServiceClient.getTeam()), - enabled: Boolean(userId()) && enabled(), - ...freshness, - })); + return useQuery(() => calendarTeamIdentityQueryOptions(userId(), enabled())); } -export function useTeamCalendarSharingQuery( - identity: Accessor +function teamCalendarSharingQueryOptions( + identity: CalendarTeamIdentity | undefined ) { - return useQuery(() => ({ - queryKey: calendarTeamKeys.sharing(identity()).queryKey, + return queryOptions({ + queryKey: calendarTeamKeys.sharing(identity).queryKey, queryFn: ({ signal }) => throwOnErr(() => emailClient.getTeamCalendarSharing(signal)), - enabled: identity() !== undefined, + enabled: identity !== undefined, ...freshness, - })); + }); } -export function useAvailabilityCalendarsQuery( +export function useTeamCalendarSharingQuery( identity: Accessor ) { - return useQuery(() => ({ - queryKey: calendarTeamKeys.availabilityCalendars(identity()).queryKey, + return useQuery(() => teamCalendarSharingQueryOptions(identity())); +} + +function availabilityCalendarsQueryOptions( + identity: CalendarTeamIdentity | undefined +) { + return queryOptions({ + queryKey: calendarTeamKeys.availabilityCalendars(identity).queryKey, queryFn: ({ signal }) => throwOnErr(() => emailClient.getAvailabilityCalendars(signal)), - enabled: identity() !== undefined, + enabled: identity !== undefined, ...freshness, - })); + }); +} + +export function useAvailabilityCalendarsQuery( + identity: Accessor +) { + return useQuery(() => availabilityCalendarsQueryOptions(identity())); } /** Update one cached query now and restore it if the save fails; never blank it. */ diff --git a/apps/web/src/lib/queries/call/meetings.ts b/apps/web/src/lib/queries/call/meetings.ts index 1c848636118..a1136b0b0b8 100644 --- a/apps/web/src/lib/queries/call/meetings.ts +++ b/apps/web/src/lib/queries/call/meetings.ts @@ -5,40 +5,51 @@ import { callServiceClient, type UpdateMeetingRequest, } from '@service-call/client'; -import { useMutation, useQuery } from '@tanstack/solid-query'; +import { queryOptions, useMutation, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { callKeys } from './keys'; -export function useMeetingQuery(shareToken: Accessor) { - return useQuery(() => ({ - queryKey: callKeys.meeting(shareToken()).queryKey, - queryFn: () => throwOnErr(() => callServiceClient.getMeeting(shareToken())), - enabled: shareToken().length > 0, +function meetingQueryOptions(shareToken: string) { + return queryOptions({ + queryKey: callKeys.meeting(shareToken).queryKey, + queryFn: () => throwOnErr(() => callServiceClient.getMeeting(shareToken)), + enabled: shareToken.length > 0, retry: false, - })); + }); } -/** Poll only while setup is visible, with separate caches for guests and members. */ -export function useMeetingParticipantsQuery( - shareToken: Accessor, - viewerId: Accessor, - enabled: Accessor +export function useMeetingQuery(shareToken: Accessor) { + return useQuery(() => meetingQueryOptions(shareToken())); +} + +function meetingParticipantsQueryOptions( + shareToken: string, + viewerId: string | undefined, + enabled: boolean ) { - return useQuery(() => ({ - queryKey: callKeys.meetingParticipants(shareToken(), viewerId()).queryKey, + return queryOptions({ + queryKey: callKeys.meetingParticipants(shareToken, viewerId).queryKey, queryFn: () => throwOnErr(() => - callServiceClient.getMeetingParticipants( - shareToken(), - Boolean(viewerId()) - ) + callServiceClient.getMeetingParticipants(shareToken, Boolean(viewerId)) ), - enabled: Boolean(shareToken()) && enabled(), + enabled: Boolean(shareToken) && enabled, refetchInterval: 15_000, staleTime: 0, gcTime: 0, retry: false, - })); + }); +} + +/** Poll only while setup is visible, with separate caches for guests and members. */ +export function useMeetingParticipantsQuery( + shareToken: Accessor, + viewerId: Accessor, + enabled: Accessor +) { + return useQuery(() => + meetingParticipantsQueryOptions(shareToken(), viewerId(), enabled()) + ); } export function useInviteMeetingUsersMutation() { @@ -51,15 +62,17 @@ export function useInviteMeetingUsersMutation() { })); } +const meetingsQueryOptions = queryOptions({ + queryKey: callKeys.meetings.queryKey, + queryFn: () => throwOnErr(() => callServiceClient.getMeetings()), + retry: (count, error) => + !thrownResultErrorHasCode(error, 'MEETINGS_UNAVAILABLE') && count < 3, + refetchOnWindowFocus: (query) => + !thrownResultErrorHasCode(query.state.error, 'MEETINGS_UNAVAILABLE'), +}); + export function useMeetingsQuery() { - return useQuery(() => ({ - queryKey: callKeys.meetings.queryKey, - queryFn: () => throwOnErr(() => callServiceClient.getMeetings()), - retry: (count, error) => - !thrownResultErrorHasCode(error, 'MEETINGS_UNAVAILABLE') && count < 3, - refetchOnWindowFocus: (query) => - !thrownResultErrorHasCode(query.state.error, 'MEETINGS_UNAVAILABLE'), - })); + return useQuery(() => meetingsQueryOptions); } export function useCreateMeetingMutation() { @@ -82,14 +95,18 @@ export function cancelMeetingPreparation(id: string) { return throwOnErr(() => callServiceClient.cancelMeetingPreparation(id)); } -export function useActiveMeetingsQuery(userId: Accessor) { - return useQuery(() => ({ - queryKey: [...callKeys.activeMeetings.queryKey, userId() ?? ''], +function activeMeetingsQueryOptions(userId: string | undefined) { + return queryOptions({ + queryKey: [...callKeys.activeMeetings.queryKey, userId ?? ''], queryFn: () => throwOnErr(() => callServiceClient.getActiveMeetings()), - enabled: Boolean(userId()), + enabled: Boolean(userId), staleTime: 10_000, refetchInterval: 15_000, - })); + }); +} + +export function useActiveMeetingsQuery(userId: Accessor) { + return useQuery(() => activeMeetingsQueryOptions(userId())); } /** Targeted invite/join events only change the receiving account's live list. */ @@ -137,17 +154,23 @@ export function useCancelMeetingMutation() { })); } +function callLinkQueryOptions(callId: string | undefined, enabled: boolean) { + return queryOptions({ + queryKey: callKeys.link(callId ?? '').queryKey, + queryFn: () => throwOnErr(() => callServiceClient.getCallLink(callId!)), + enabled: Boolean(callId) && enabled, + staleTime: Infinity, + retry: false, + }); +} + export function useCallLinkQuery( callId: Accessor, options?: { enabled?: boolean } ) { - return useQuery(() => ({ - queryKey: callKeys.link(callId() ?? '').queryKey, - queryFn: () => throwOnErr(() => callServiceClient.getCallLink(callId()!)), - enabled: Boolean(callId()) && (options?.enabled ?? true), - staleTime: Infinity, - retry: false, - })); + return useQuery(() => + callLinkQueryOptions(callId(), options?.enabled ?? true) + ); } export function useJoinMeetingMutation() { diff --git a/apps/web/src/lib/queries/email/scheduled.ts b/apps/web/src/lib/queries/email/scheduled.ts index 7338c111f9e..f948e976253 100644 --- a/apps/web/src/lib/queries/email/scheduled.ts +++ b/apps/web/src/lib/queries/email/scheduled.ts @@ -1,7 +1,7 @@ import { throwOnErr } from '@core/util/result'; import { emailClient } from '@service-email/client'; import type { Message } from '@service-email/generated/schemas'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { emailKeys } from './keys'; @@ -43,19 +43,21 @@ export async function fetchScheduledMessages( ); } +function scheduledMessagesQueryOptions(linkIds: string[], enabled: boolean) { + const selectedLinkIds = [...linkIds].sort(); + return queryOptions({ + queryKey: emailKeys.scheduledMessages(selectedLinkIds).queryKey, + enabled: enabled && selectedLinkIds.length > 0, + queryFn: () => fetchScheduledMessages(selectedLinkIds), + refetchInterval: 15_000, + refetchOnReconnect: 'always', + refetchOnWindowFocus: 'always', + }); +} + export function useScheduledMessagesQuery( linkIds: Accessor, enabled: Accessor ) { - return useQuery(() => { - const selectedLinkIds = [...linkIds()].sort(); - return { - queryKey: emailKeys.scheduledMessages(selectedLinkIds).queryKey, - enabled: enabled() && selectedLinkIds.length > 0, - queryFn: () => fetchScheduledMessages(selectedLinkIds), - refetchInterval: 15_000, - refetchOnReconnect: 'always' as const, - refetchOnWindowFocus: 'always' as const, - }; - }); + return useQuery(() => scheduledMessagesQueryOptions(linkIds(), enabled())); } diff --git a/apps/web/src/lib/queries/github-stars.ts b/apps/web/src/lib/queries/github-stars.ts index dd4bdfd1db4..805a8790834 100644 --- a/apps/web/src/lib/queries/github-stars.ts +++ b/apps/web/src/lib/queries/github-stars.ts @@ -1,14 +1,16 @@ -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import { getMacroGithubStars } from '../service-clients/service-github-public/client'; +const githubStarsQueryOptions = queryOptions({ + queryKey: ['public', 'github', 'macro-inc/macro', 'stars'], + queryFn: ({ signal }) => getMacroGithubStars(signal), + staleTime: 60 * 60 * 1000, + gcTime: 24 * 60 * 60 * 1000, + retry: 1, + throwOnError: false, +}); + /** Shared across onboarding mounts; this public request needs no sign-in. */ export function useGithubStarsQuery() { - return useQuery(() => ({ - queryKey: ['public', 'github', 'macro-inc/macro', 'stars'], - queryFn: ({ signal }) => getMacroGithubStars(signal), - staleTime: 60 * 60 * 1000, - gcTime: 24 * 60 * 60 * 1000, - retry: 1, - throwOnError: false, - })); + return useQuery(() => githubStarsQueryOptions); } diff --git a/apps/web/src/lib/queries/import.ts b/apps/web/src/lib/queries/import.ts index 332ec798fc5..3901f92e7e0 100644 --- a/apps/web/src/lib/queries/import.ts +++ b/apps/web/src/lib/queries/import.ts @@ -176,6 +176,17 @@ export function useCancelNotionImportMutation() { })); } +function workspaceImportReportQueryOptions( + source: 'notion' | 'linear' | 'jira' +) { + return queryOptions({ + queryKey: importKeys.workspace(source).queryKey, + queryFn: () => throwOnErr(() => importClient.getWorkspaceReport(source)), + refetchInterval: (query) => + notionImportInFlight(query.state.data) ? 2_000 : false, + }); +} + /** A reviewed workspace report, isolated by source and refreshed on pushes. */ export function useWorkspaceImportReportQuery( source: Accessor<'notion' | 'linear' | 'jira'> @@ -189,12 +200,7 @@ export function useWorkspaceImportReportQuery( 'workspace-import' ); }); - return useQuery(() => ({ - queryKey: importKeys.workspace(source()).queryKey, - queryFn: () => throwOnErr(() => importClient.getWorkspaceReport(source())), - refetchInterval: (query) => - notionImportInFlight(query.state.data) ? 2_000 : false, - })); + return useQuery(() => workspaceImportReportQueryOptions(source())); } /** Cancel only the workspace source displayed by this review. */ diff --git a/apps/web/src/lib/queries/reminders/email-collection.ts b/apps/web/src/lib/queries/reminders/email-collection.ts index 2864eb4bdc5..4baa066e276 100644 --- a/apps/web/src/lib/queries/reminders/email-collection.ts +++ b/apps/web/src/lib/queries/reminders/email-collection.ts @@ -2,40 +2,46 @@ import { throwOnErr } from '@core/util/result'; import { storageServiceClient } from '@service-storage/client'; import type { EmailReminderPage } from '@service-storage/generated/schemas/emailReminderPage'; import type { ListEmailRemindersParams } from '@service-storage/generated/schemas/listEmailRemindersParams'; -import { useInfiniteQuery } from '@tanstack/solid-query'; +import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { queryClient } from '../client'; import { reminderKeys } from './keys'; +type EmailReminderCollectionOptions = { + userId: string | undefined; + enabled: boolean; + filters: ListEmailRemindersParams; +}; + +function emailReminderCollectionQueryOptions({ + userId, + enabled, + filters, +}: EmailReminderCollectionOptions) { + return infiniteQueryOptions({ + queryKey: reminderKeys.emailCollection(userId, filters).queryKey, + enabled: !!userId && enabled, + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }: { pageParam: string | undefined }) => + throwOnErr(() => + storageServiceClient.reminders.listEmailReminders({ + ...filters, + cursor: pageParam, + limit: 100, + }) + ), + getNextPageParam: (page: EmailReminderPage) => page.nextCursor ?? undefined, + refetchInterval: 30_000, + staleTime: 0, + refetchOnWindowFocus: true, + throwOnError: false, + }); +} + export function useEmailReminderCollection( - options: Accessor<{ - userId: string | undefined; - enabled: boolean; - filters: ListEmailRemindersParams; - }> + options: Accessor ) { - return useInfiniteQuery(() => { - const { userId, enabled, filters } = options(); - return { - queryKey: reminderKeys.emailCollection(userId, filters).queryKey, - enabled: !!userId && enabled, - initialPageParam: undefined as string | undefined, - queryFn: ({ pageParam }: { pageParam: string | undefined }) => - throwOnErr(() => - storageServiceClient.reminders.listEmailReminders({ - ...filters, - cursor: pageParam, - limit: 100, - }) - ), - getNextPageParam: (page: EmailReminderPage) => - page.nextCursor ?? undefined, - refetchInterval: 30_000, - staleTime: 0, - refetchOnWindowFocus: true, - throwOnError: false, - }; - }); + return useInfiniteQuery(() => emailReminderCollectionQueryOptions(options())); } /** Refill facet-filtered pages after email mutations commit. */ diff --git a/apps/web/src/lib/queries/storage/database-queries.ts b/apps/web/src/lib/queries/storage/database-queries.ts index e8f361888c5..26556f177c4 100644 --- a/apps/web/src/lib/queries/storage/database-queries.ts +++ b/apps/web/src/lib/queries/storage/database-queries.ts @@ -7,7 +7,7 @@ import { } from '@service-storage/database-queries'; import type { SavedQuery } from '@service-storage/generated/schemas/savedQuery'; import type { SaveQueryRequest } from '@service-storage/generated/schemas/saveQueryRequest'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { ResultAsync } from 'neverthrow'; import type { Accessor } from 'solid-js'; import { queryClient } from '../client'; @@ -26,13 +26,17 @@ export function createSavedDatabaseQuery( }); } -/** A saved query's definition; its errors carry the route's codes. */ -export function useDatabaseQueryDefinition(queryId: Accessor) { - return useQuery(() => ({ - queryKey: savedDatabaseQueryKeys.definition(queryId()).queryKey, - queryFn: () => throwOnErr(() => getDatabaseQuery(queryId())), - enabled: !!queryId(), +function databaseQueryDefinitionQueryOptions(queryId: string) { + return queryOptions({ + queryKey: savedDatabaseQueryKeys.definition(queryId).queryKey, + queryFn: () => throwOnErr(() => getDatabaseQuery(queryId)), + enabled: !!queryId, staleTime: Number.POSITIVE_INFINITY, retry: false, - })); + }); +} + +/** A saved query's definition; its errors carry the route's codes. */ +export function useDatabaseQueryDefinition(queryId: Accessor) { + return useQuery(() => databaseQueryDefinitionQueryOptions(queryId())); } diff --git a/apps/web/src/lib/queries/storage/document-metadata.ts b/apps/web/src/lib/queries/storage/document-metadata.ts index 14e3f00b625..d8b0d4b58af 100644 --- a/apps/web/src/lib/queries/storage/document-metadata.ts +++ b/apps/web/src/lib/queries/storage/document-metadata.ts @@ -28,7 +28,7 @@ async function fetchDocumentAccessLevel( return result.value.userAccessLevel; } -function documentMetadataQueryOptions(documentId: string) { +export function documentMetadataQueryOptions(documentId: string) { return queryOptions({ queryKey: entityKeys.documentMetadata(documentId).queryKey, queryFn: () => fetchDocumentMetadata(documentId), diff --git a/apps/web/src/lib/queries/storage/forms.ts b/apps/web/src/lib/queries/storage/forms.ts index 187cf688b08..c735b3e76d1 100644 --- a/apps/web/src/lib/queries/storage/forms.ts +++ b/apps/web/src/lib/queries/storage/forms.ts @@ -18,7 +18,7 @@ import type { FormLayout } from '@service-storage/generated/schemas/formLayout'; import type { Submission } from '@service-storage/generated/schemas/submission'; import type { SubmissionOutcome } from '@service-storage/generated/schemas/submissionOutcome'; import type { UpdateForm } from '@service-storage/generated/schemas/updateForm'; -import { useQueries, useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQueries, useQuery } from '@tanstack/solid-query'; import { ResultAsync } from 'neverthrow'; import type { Accessor } from 'solid-js'; import { channelParticipantsQueryOptions } from '../channel/channel-participants'; @@ -28,16 +28,20 @@ import { databasesKeys, formsKeys, myResponseKeyOf } from './keys'; const FORM_STALE_TIME = 30 * 1000; const RESPONDENT_REFRESH_INTERVAL = 30 * 1000; -/** Every form the viewer can open, for Drive and Quick Access. */ -export function useFormsQuery() { - const flag = useFeatureFlag(enableForms); - return useQuery(() => ({ +function formsQueryOptions(enabled: boolean) { + return queryOptions({ queryKey: formsKeys.list.queryKey, queryFn: () => throwOnErr(async () => storageServiceClient.forms.listAccessible()), staleTime: FORM_STALE_TIME, - enabled: flag().enabled, - })); + enabled, + }); +} + +/** Every form the viewer can open, for Drive and Quick Access. */ +export function useFormsQuery() { + const flag = useFeatureFlag(enableForms); + return useQuery(() => formsQueryOptions(flag().enabled)); } /** @@ -48,17 +52,18 @@ export function useFormsQuery() { export function useFormsForDatabaseQuery( databaseId: Accessor ) { - return useQuery(() => { - const id = databaseId(); - return { - queryKey: formsKeys.forDatabase(id ?? '').queryKey, - queryFn: (): Promise => - throwOnErr(async () => - storageServiceClient.forms.listForDatabase({ databaseId: id ?? '' }) - ), - staleTime: FORM_STALE_TIME, - enabled: !!id, - }; + return useQuery(() => formsForDatabaseQueryOptions(databaseId())); +} + +function formsForDatabaseQueryOptions(id: string | undefined) { + return queryOptions({ + queryKey: formsKeys.forDatabase(id ?? '').queryKey, + queryFn: (): Promise => + throwOnErr(async () => + storageServiceClient.forms.listForDatabase({ databaseId: id ?? '' }) + ), + staleTime: FORM_STALE_TIME, + enabled: !!id, }); } @@ -71,25 +76,30 @@ export function formDetailQueryOptions(formId: string) { }; } +function watchedFormDetailQueryOptions( + id: string | undefined, + respondentView: boolean +) { + return queryOptions({ + ...formDetailQueryOptions(id ?? ''), + enabled: !!id, + // Respondents stay fresh without joining the gateway's presence roster. + refetchInterval: (query) => + respondentView || query.state.data?.access === 'view' + ? RESPONDENT_REFRESH_INTERVAL + : false, + // A form you cannot open stays unopened; retrying a 404 changes nothing. + retry: false, + }); +} + export function useFormDetailQuery( formId: Accessor, refreshWithoutPresence: Accessor = () => false ) { - return useQuery(() => { - const id = formId(); - const respondentView = refreshWithoutPresence(); - return { - ...formDetailQueryOptions(id ?? ''), - enabled: !!id, - // Respondents stay fresh without joining the gateway's presence roster. - refetchInterval: (query) => - respondentView || query.state.data?.access === 'view' - ? RESPONDENT_REFRESH_INTERVAL - : false, - // A form you cannot open stays unopened; retrying a 404 changes nothing. - retry: false, - }; - }); + return useQuery(() => + watchedFormDetailQueryOptions(formId(), refreshWithoutPresence()) + ); } /** @@ -241,28 +251,40 @@ export function editMyResponse( }); } +function myResponseQueryOptions( + id: string | undefined, + user: string | undefined +) { + return queryOptions({ + queryKey: formsKeys.mine(id ?? '', user ?? '').queryKey, + queryFn: async () => { + const result = await storageServiceClient.forms.getMyResponse({ + id: id ?? '', + }); + if (result.isOk()) return result.value; + if (result.error.some((error) => error.refusal?.code === 'noResponse')) + return null; + throw new ThrownResultError(result.error); + }, + enabled: !!id && !!user, + retry: false, + }); +} + /** The signed-in viewer's own response; `null` until they respond. */ export function useMyResponseQuery( formId: Accessor, userId: Accessor ) { - return useQuery(() => { - const id = formId(); - const user = userId(); - return { - queryKey: formsKeys.mine(id ?? '', user ?? '').queryKey, - queryFn: async () => { - const result = await storageServiceClient.forms.getMyResponse({ - id: id ?? '', - }); - if (result.isOk()) return result.value; - if (result.error.some((error) => error.refusal?.code === 'noResponse')) - return null; - throw new ThrownResultError(result.error); - }, - enabled: !!id && !!user, - retry: false, - }; + return useQuery(() => myResponseQueryOptions(formId(), userId())); +} + +function responseSummaryQueryOptions(id: string, enabled: boolean) { + return queryOptions({ + queryKey: formsKeys.summary(id).queryKey, + queryFn: () => + throwOnErr(async () => storageServiceClient.forms.getSummary({ id })), + enabled, }); } @@ -271,14 +293,20 @@ export function useResponseSummaryQuery( formId: Accessor, enabled: Accessor ) { - return useQuery(() => { - const id = formId(); - return { - queryKey: formsKeys.summary(id).queryKey, - queryFn: () => - throwOnErr(async () => storageServiceClient.forms.getSummary({ id })), - enabled: enabled(), - }; + return useQuery(() => responseSummaryQueryOptions(formId(), enabled())); +} + +function tallyQueryOptions(id: string | undefined, enabled: boolean) { + return queryOptions({ + queryKey: formsKeys.tally(id ?? '').queryKey, + queryFn: () => + throwOnErr(async () => + storageServiceClient.forms.getTally({ id: id ?? '' }) + ), + enabled: !!id && enabled, + // Poll cards and respondents do not join the editor presence roster. + refetchInterval: RESPONDENT_REFRESH_INTERVAL, + retry: false, }); } @@ -286,19 +314,21 @@ export function useTallyQuery( formId: Accessor, enabled: Accessor ) { - return useQuery(() => { - const id = formId(); - return { - queryKey: formsKeys.tally(id ?? '').queryKey, - queryFn: () => - throwOnErr(async () => - storageServiceClient.forms.getTally({ id: id ?? '' }) - ), - enabled: !!id && enabled(), - // Poll cards and respondents do not join the editor presence roster. - refetchInterval: RESPONDENT_REFRESH_INTERVAL, - retry: false, - }; + return useQuery(() => tallyQueryOptions(formId(), enabled())); +} + +function formSharePermissionsQueryOptions( + id: string | undefined, + enabled: boolean +) { + return queryOptions({ + queryKey: formsKeys.permissions(id ?? '').queryKey, + queryFn: () => + throwOnErr(async () => + storageServiceClient.forms.getPermissions({ id: id ?? '' }) + ), + enabled: !!id && enabled, + retry: false, }); } @@ -307,18 +337,7 @@ export function useFormSharePermissionsQuery( formId: Accessor, enabled: Accessor ) { - return useQuery(() => { - const id = formId(); - return { - queryKey: formsKeys.permissions(id ?? '').queryKey, - queryFn: () => - throwOnErr(async () => - storageServiceClient.forms.getPermissions({ id: id ?? '' }) - ), - enabled: !!id && enabled(), - retry: false, - }; - }); + return useQuery(() => formSharePermissionsQueryOptions(formId(), enabled())); } /** diff --git a/apps/web/src/lib/queries/storage/github-pull-request-changes.ts b/apps/web/src/lib/queries/storage/github-pull-request-changes.ts index eb3dfd82fd5..84f54e941fe 100644 --- a/apps/web/src/lib/queries/storage/github-pull-request-changes.ts +++ b/apps/web/src/lib/queries/storage/github-pull-request-changes.ts @@ -8,28 +8,51 @@ import { throwOnErr } from '@core/util/result'; import { storageServiceClient } from '@service-storage/client'; -import { useQuery } from '@tanstack/solid-query'; +import { queryOptions, useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; import { githubPullRequestChangesKeys } from './keys'; const GITHUB_PULL_REQUEST_CHANGES_STALE_TIME = 60 * 1000; +function githubPullRequestChangesQueryOptions(id: string | undefined) { + return queryOptions({ + queryKey: githubPullRequestChangesKeys.summary(id ?? '').queryKey, + queryFn: () => + throwOnErr(() => + storageServiceClient.getGithubPullRequestChanges({ id: id! }) + ), + enabled: Boolean(id), + retry: false, + staleTime: GITHUB_PULL_REQUEST_CHANGES_STALE_TIME, + }); +} + /** The changes of the pull request behind a foreign entity record. */ export function useGithubPullRequestChangesQuery( foreignEntityId: Accessor ) { - return useQuery(() => { - const id = foreignEntityId(); - return { - queryKey: githubPullRequestChangesKeys.summary(id ?? '').queryKey, - queryFn: () => - throwOnErr(() => - storageServiceClient.getGithubPullRequestChanges({ id: id! }) - ), - enabled: Boolean(id), - retry: false, - staleTime: GITHUB_PULL_REQUEST_CHANGES_STALE_TIME, - }; + return useQuery(() => + githubPullRequestChangesQueryOptions(foreignEntityId()) + ); +} + +function githubPullRequestChangesPatchQueryOptions( + id: string | undefined, + changeset: string | undefined +) { + return queryOptions({ + queryKey: githubPullRequestChangesKeys.patch(id ?? '', changeset ?? '') + .queryKey, + queryFn: () => + throwOnErr(() => + storageServiceClient.getGithubPullRequestChangesPatch({ + id: id!, + changeset: changeset!, + }) + ), + enabled: Boolean(id) && Boolean(changeset), + retry: 1, + staleTime: Number.POSITIVE_INFINITY, }); } @@ -41,22 +64,7 @@ export function useGithubPullRequestChangesPatchQuery( foreignEntityId: Accessor, changesetId: Accessor ) { - return useQuery(() => { - const id = foreignEntityId(); - const changeset = changesetId(); - return { - queryKey: githubPullRequestChangesKeys.patch(id ?? '', changeset ?? '') - .queryKey, - queryFn: () => - throwOnErr(() => - storageServiceClient.getGithubPullRequestChangesPatch({ - id: id!, - changeset: changeset!, - }) - ), - enabled: Boolean(id) && Boolean(changeset), - retry: 1, - staleTime: Number.POSITIVE_INFINITY, - }; - }); + return useQuery(() => + githubPullRequestChangesPatchQueryOptions(foreignEntityId(), changesetId()) + ); } diff --git a/rules/ast-grep/ts-no-inline-query-callbacks.yml b/rules/ast-grep/ts-no-inline-query-callbacks.yml new file mode 100644 index 00000000000..ad904d814a0 --- /dev/null +++ b/rules/ast-grep/ts-no-inline-query-callbacks.yml @@ -0,0 +1,97 @@ +id: ts-no-inline-query-callbacks +language: typescript +# Twin of tsx-no-inline-query-callbacks — ast-grep rules are +# single-language, and `typescript` only scans .ts files. +severity: error +message: Build query callbacks in a module-scope options factory over plain values, then call it from the options accessor. +note: |- + Rule FE-69 in .agents/rules/web/data.md. A query keeps its last observer's + options after the view unmounts: for gcTime, or for the whole session. A + callback created in a hook or component keeps that scope alive, and with it + accessors, Solid owners and the closed view's DOM. +files: + - apps/web/src/** +ignores: + # urql observers are per mount and destroyed on cleanup. + - apps/web/src/lib/urql-solid/** + - apps/web/src/**/*.test.ts + - apps/web/src/**/*.test.tsx + - apps/web/src/**/browser-test/** +rule: + all: + - any: + - kind: arrow_function + - kind: function_expression + - kind: method_definition + # An option's value, also behind a ternary, cast or fallback. + - any: + - kind: method_definition + - inside: + kind: pair + stopBy: + not: + any: + - kind: parenthesized_expression + - kind: ternary_expression + - kind: binary_expression + - kind: as_expression + - kind: satisfies_expression + - kind: non_null_expression + # useQueries' combine runs on the per-mount observer, not the cache. + - not: + any: + - has: + field: name + regex: ^combine$ + - inside: + stopBy: end + any: + - kind: pair + has: + field: key + regex: ^combine$ + - kind: method_definition + has: + field: name + regex: ^combine$ + - inside: + stopBy: end + any: + # Query hooks only run in a hook or component scope. + - kind: call_expression + has: + field: function + regex: ^(use|create)(Query|InfiniteQuery|Queries)$ + # Factories and client calls are fine at module scope, not in a hook, + # a component, or a method that runs in one. + - kind: call_expression + has: + field: function + regex: (^queryOptions|^infiniteQueryOptions|\.(fetchQuery|prefetchQuery|ensureQueryData|fetchInfiniteQuery|prefetchInfiniteQuery|ensureInfiniteQueryData))$ + inside: + stopBy: end + any: + - kind: method_definition + - kind: function_declaration + all: + - has: + field: name + regex: ^(use|create)[A-Z0-9]|^[A-Z] + - not: + has: + field: name + regex: Options$ + - kind: variable_declarator + all: + - has: + field: name + regex: ^(use|create)[A-Z0-9]|^[A-Z] + - not: + has: + field: name + regex: Options$ + - has: + field: value + any: + - kind: arrow_function + - kind: function_expression diff --git a/rules/ast-grep/tsx-no-inline-query-callbacks.yml b/rules/ast-grep/tsx-no-inline-query-callbacks.yml new file mode 100644 index 00000000000..dad1ba16d50 --- /dev/null +++ b/rules/ast-grep/tsx-no-inline-query-callbacks.yml @@ -0,0 +1,97 @@ +id: tsx-no-inline-query-callbacks +language: tsx +# Twin of ts-no-inline-query-callbacks — ast-grep rules are +# single-language, and `tsx` only scans .tsx files. +severity: error +message: Build query callbacks in a module-scope options factory over plain values, then call it from the options accessor. +note: |- + Rule FE-69 in .agents/rules/web/data.md. A query keeps its last observer's + options after the view unmounts: for gcTime, or for the whole session. A + callback created in a hook or component keeps that scope alive, and with it + accessors, Solid owners and the closed view's DOM. +files: + - apps/web/src/** +ignores: + # urql observers are per mount and destroyed on cleanup. + - apps/web/src/lib/urql-solid/** + - apps/web/src/**/*.test.ts + - apps/web/src/**/*.test.tsx + - apps/web/src/**/browser-test/** +rule: + all: + - any: + - kind: arrow_function + - kind: function_expression + - kind: method_definition + # An option's value, also behind a ternary, cast or fallback. + - any: + - kind: method_definition + - inside: + kind: pair + stopBy: + not: + any: + - kind: parenthesized_expression + - kind: ternary_expression + - kind: binary_expression + - kind: as_expression + - kind: satisfies_expression + - kind: non_null_expression + # useQueries' combine runs on the per-mount observer, not the cache. + - not: + any: + - has: + field: name + regex: ^combine$ + - inside: + stopBy: end + any: + - kind: pair + has: + field: key + regex: ^combine$ + - kind: method_definition + has: + field: name + regex: ^combine$ + - inside: + stopBy: end + any: + # Query hooks only run in a hook or component scope. + - kind: call_expression + has: + field: function + regex: ^(use|create)(Query|InfiniteQuery|Queries)$ + # Factories and client calls are fine at module scope, not in a hook, + # a component, or a method that runs in one. + - kind: call_expression + has: + field: function + regex: (^queryOptions|^infiniteQueryOptions|\.(fetchQuery|prefetchQuery|ensureQueryData|fetchInfiniteQuery|prefetchInfiniteQuery|ensureInfiniteQueryData))$ + inside: + stopBy: end + any: + - kind: method_definition + - kind: function_declaration + all: + - has: + field: name + regex: ^(use|create)[A-Z0-9]|^[A-Z] + - not: + has: + field: name + regex: Options$ + - kind: variable_declarator + all: + - has: + field: name + regex: ^(use|create)[A-Z0-9]|^[A-Z] + - not: + has: + field: name + regex: Options$ + - has: + field: value + any: + - kind: arrow_function + - kind: function_expression