diff --git a/.changeset/config.json b/.changeset/config.json index d3af6f132a4..984e1d89f1c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,14 +1,43 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", "changelog": [ - "@svitejs/changesets-changelog-github-compact", - { "repo": "TanStack/query" } + "@changesets/changelog-github", + { "repo": "TanStack/query", "disableThanks": true } ], "commit": false, "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "fixed": [], + "fixed": [ + [ + "@tanstack/angular-query-experimental", + "@tanstack/angular-query-persist-client", + "@tanstack/eslint-plugin-query", + "@tanstack/preact-query", + "@tanstack/preact-query-devtools", + "@tanstack/preact-query-persist-client", + "@tanstack/query-async-storage-persister", + "@tanstack/query-broadcast-client-experimental", + "@tanstack/query-core", + "@tanstack/query-devtools", + "@tanstack/query-persist-client-core", + "@tanstack/query-sync-storage-persister", + "@tanstack/react-query", + "@tanstack/react-query-devtools", + "@tanstack/react-query-next-experimental", + "@tanstack/react-query-persist-client", + "@tanstack/solid-query", + "@tanstack/solid-query-devtools", + "@tanstack/solid-query-persist-client", + "@tanstack/vue-query" + ], + [ + "@tanstack/vue-query-devtools", + "@tanstack/svelte-query", + "@tanstack/svelte-query-devtools", + "@tanstack/svelte-query-persist-client" + ] + ], "linked": [], "ignore": [], "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..ec376708852 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +.github/ @TanStack/tanstack-core +.nx/ @TanStack/tanstack-core +nx.json @TanStack/tanstack-core +.changeset/config.json @TanStack/tanstack-core +scripts/ @TanStack/tanstack-core +.npmrc @TanStack/tanstack-core \ No newline at end of file diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index fd98e9fb08b..b2db7d18b2c 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -18,9 +18,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Tools - uses: TanStack/config/.github/setup@main + uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Fix formatting run: pnpm run format - name: Apply fixes diff --git a/.github/workflows/detect-agent.yml b/.github/workflows/detect-agent.yml deleted file mode 100644 index a0aef83e186..00000000000 --- a/.github/workflows/detect-agent.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Detect Agent - -on: - pull_request_target: - types: [opened] - workflow_dispatch: {} - -permissions: - issues: write - pull-requests: write - -jobs: - detect: - if: github.event_name != 'workflow_dispatch' - uses: bombshell-dev/automation/.github/workflows/detect-agent.yml@a1553cebd9318d416f6a8e9f38f363b6aaa19c72 - - backfill: - if: github.event_name == 'workflow_dispatch' - uses: bombshell-dev/automation/.github/workflows/detect-agent-backfill.yml@a1553cebd9318d416f6a8e9f38f363b6aaa19c72 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index 0665d3b7f84..00000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Labeler - -on: - pull_request_target: - -permissions: - contents: read - pull-requests: write - -jobs: - labeler: - name: Labeler - runs-on: ubuntu-latest - steps: - - name: Labeler - uses: actions/labeler@v6.0.1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - configuration-path: labeler-config.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6e7d8c80a63..8429337ad27 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,26 +10,27 @@ concurrency: env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} -permissions: - contents: read - pull-requests: write - issues: write +permissions: {} jobs: test: name: Test runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: Start Nx Agents run: npx nx-cloud start-ci-run --distribute-on=".nx/workflows/dynamic-changesets.yaml" - name: Setup Tools - uses: TanStack/config/.github/setup@main + uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Get base and head commits for `nx affected` - uses: nrwl/nx-set-shas@v4.4.0 + uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4.4.0 with: main-branch-name: main - name: Run Checks @@ -40,42 +41,44 @@ jobs: preview: name: Preview runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Tools - uses: TanStack/config/.github/setup@main + uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Build Packages run: pnpm run build:all - name: Publish Previews run: pnpx pkg-pr-new publish --pnpm --compact './packages/*' --template './examples/*/*' - name: Determine commit SHA id: determine-sha - run: | - echo "COMMIT_SHA=${{ github.event.pull_request.head.sha || github.sha }}" >> $GITHUB_ENV + run: echo "COMMIT_SHA=${COMMIT_SHA}" >> "$GITHUB_ENV" + env: + COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - name: Size Limit uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d with: github_token: ${{ secrets.GITHUB_TOKEN }} skip_step: install build_script: build:all - provenance: - name: Provenance - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6.0.2 - - name: Check Provenance - uses: danielroe/provenance-action@v0.1.1 - with: - fail-on-downgrade: true version-preview: name: Version Preview runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Tools - uses: TanStack/config/.github/setup@main + uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Changeset Preview - uses: TanStack/config/.github/changeset-preview@main + uses: TanStack/config/.github/changeset-preview@e4b48f16568324f76f467aa4c2aac2f05db632c3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b54f6119daa..8f950f033ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,21 +12,25 @@ env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} permissions: - contents: write - id-token: write - pull-requests: write + contents: read jobs: release: name: Release + if: github.repository_owner == 'TanStack' runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + pull-requests: write steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: true # changesets/action pushes version/release changes - name: Setup Tools - uses: TanStack/config/.github/setup@main + uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Run Build run: pnpm run build:all - name: Determine dist-tag @@ -35,6 +39,7 @@ jobs: BRANCH="${GITHUB_REF_NAME}" if [[ "$BRANCH" == *-pre ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "tag=pre" >> "$GITHUB_OUTPUT" elif [[ "$BRANCH" == *-maint ]]; then echo "tag=maint" >> "$GITHUB_OUTPUT" elif [[ "$BRANCH" =~ ^v[0-9]+$ ]]; then @@ -44,7 +49,7 @@ jobs: fi - name: Create Release Pull Request or Publish id: changesets - uses: changesets/action@v1 + uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 with: version: pnpm run changeset:version publish: pnpm run changeset:publish ${{ steps.dist-tag.outputs.tag && format('--tag {0}', steps.dist-tag.outputs.tag) }} @@ -54,7 +59,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create GitHub Release if: steps.changesets.outputs.published == 'true' - run: node scripts/create-github-release.mjs ${{ steps.dist-tag.outputs.prerelease == 'true' && '--prerelease' }} ${{ steps.dist-tag.outputs.latest == 'true' && '--latest' }} + run: node scripts/create-github-release.mjs ${PRERELEASE_FLAG} ${LATEST_FLAG} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRERELEASE_FLAG: ${{ steps.dist-tag.outputs.prerelease == 'true' && '--prerelease' }} + LATEST_FLAG: ${{ steps.dist-tag.outputs.latest == 'true' && '--latest' }} diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 00000000000..86d4b5b30a2 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,24 @@ +name: GitHub Actions Security Analysis + +on: + push: + branches: [main] + pull_request: + branches: ['**'] + +permissions: {} + +jobs: + zizmor: + name: Run zizmor + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Run zizmor + uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + with: + advanced-security: false + annotations: true diff --git a/.gitignore b/.gitignore index 6e71fdf278c..638a5ac304f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ yarn.lock build coverage dist +dist-cjs dist-ts # misc diff --git a/.nvmrc b/.nvmrc index b4040276043..b832e4001db 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.8.0 +24.16.0 diff --git a/docs/community-resources.md b/docs/community-resources.md index 9b591ba5cd9..3ce503b41a5 100644 --- a/docs/community-resources.md +++ b/docs/community-resources.md @@ -175,6 +175,11 @@ others: url: 'https://github.com/rametta/rapini', description: '🄬 OpenAPI to React Query (or SWR) & Axios', }, + { + title: 'React Query Visualizer', + url: 'https://marketplace.visualstudio.com/items?itemName=fe-dudu.react-query-visualizer', + description: 'VS Code extension for TanStack Query (React Query): visualize query keys, cache invalidation/refetch flows, and file impact graph', + }, { title: 'Tanstack Query Visualizer', url: 'https://tanstack-query-visualizer.sofi.coop/', diff --git a/docs/config.json b/docs/config.json index 54eeb77e838..2b1561d691a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -135,6 +135,27 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "Overview", + "to": "framework/lit/overview" + }, + { + "label": "Installation", + "to": "framework/lit/installation" + }, + { + "label": "Quick Start", + "to": "framework/lit/quick-start" + }, + { + "label": "TypeScript", + "to": "framework/lit/typescript" + } + ] + }, { "label": "angular", "children": [ @@ -246,6 +267,10 @@ "label": "Window Focus Refetching", "to": "framework/react/guides/window-focus-refetching" }, + { + "label": "Polling", + "to": "framework/react/guides/polling" + }, { "label": "Disabling/Pausing Queries", "to": "framework/react/guides/disabling-queries" @@ -399,6 +424,10 @@ "label": "Window Focus Refetching", "to": "framework/solid/guides/window-focus-refetching" }, + { + "label": "Polling", + "to": "framework/solid/guides/polling" + }, { "label": "Disabling/Pausing Queries", "to": "framework/solid/guides/disabling-queries" @@ -536,6 +565,10 @@ "label": "Window Focus Refetching", "to": "framework/vue/guides/window-focus-refetching" }, + { + "label": "Polling", + "to": "framework/vue/guides/polling" + }, { "label": "Disabling/Pausing Queries", "to": "framework/vue/guides/disabling-queries" @@ -747,6 +780,47 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "Reactive Controllers vs Hooks", + "to": "framework/lit/guides/reactive-controllers-vs-hooks" + }, + { + "label": "Queries", + "to": "framework/lit/guides/queries" + }, + { + "label": "Parallel Queries", + "to": "framework/lit/guides/parallel-queries" + }, + { + "label": "Query Keys", + "to": "framework/lit/guides/query-keys" + }, + { + "label": "Query Functions", + "to": "framework/lit/guides/query-functions" + }, + { + "label": "Mutations", + "to": "framework/lit/guides/mutations" + }, + { + "label": "Query Invalidation", + "to": "framework/lit/guides/query-invalidation" + }, + { + "label": "Infinite Queries", + "to": "framework/lit/guides/infinite-queries" + }, + { + "label": "SSR", + "to": "framework/lit/guides/ssr" + } + ] + }, { "label": "preact", "children": [ @@ -1055,6 +1129,18 @@ "label": "infiniteQueryOptions", "to": "framework/vue/reference/infiniteQueryOptions" }, + { + "label": "mutationOptions", + "to": "framework/vue/reference/mutationOptions" + }, + { + "label": "usePrefetchQuery", + "to": "framework/vue/reference/usePrefetchQuery" + }, + { + "label": "usePrefetchInfiniteQuery", + "to": "framework/vue/reference/usePrefetchInfiniteQuery" + }, { "label": "hydration", "to": "framework/vue/reference/hydration" @@ -1100,6 +1186,10 @@ "label": "infiniteQueryOptions", "to": "framework/solid/reference/infiniteQueryOptions" }, + { + "label": "mutationOptions", + "to": "framework/solid/reference/mutationOptions" + }, { "label": "hydration", "to": "framework/solid/reference/hydration" @@ -1155,6 +1245,83 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "Lit Reference", + "to": "framework/lit/reference/index" + }, + { + "label": "QueryClientProvider", + "to": "framework/lit/reference/classes/QueryClientProvider" + }, + { + "label": "Functions / createQueryController", + "to": "framework/lit/reference/functions/createQueryController" + }, + { + "label": "Functions / createQueriesController", + "to": "framework/lit/reference/functions/createQueriesController" + }, + { + "label": "Functions / createInfiniteQueryController", + "to": "framework/lit/reference/functions/createInfiniteQueryController" + }, + { + "label": "Functions / createMutationController", + "to": "framework/lit/reference/functions/createMutationController" + }, + { + "label": "Functions / useIsFetching", + "to": "framework/lit/reference/functions/useIsFetching" + }, + { + "label": "Functions / useIsMutating", + "to": "framework/lit/reference/functions/useIsMutating" + }, + { + "label": "Functions / useMutationState", + "to": "framework/lit/reference/functions/useMutationState" + }, + { + "label": "Functions / useQueryClient", + "to": "framework/lit/reference/functions/useQueryClient" + }, + { + "label": "Functions / queryOptions", + "to": "framework/lit/reference/functions/queryOptions" + }, + { + "label": "Functions / infiniteQueryOptions", + "to": "framework/lit/reference/functions/infiniteQueryOptions" + }, + { + "label": "Functions / mutationOptions", + "to": "framework/lit/reference/functions/mutationOptions" + }, + { + "label": "Context / queryClientContext", + "to": "framework/lit/reference/variables/queryClientContext" + }, + { + "label": "Context / getDefaultQueryClient", + "to": "framework/lit/reference/functions/getDefaultQueryClient" + }, + { + "label": "Context / registerDefaultQueryClient", + "to": "framework/lit/reference/functions/registerDefaultQueryClient" + }, + { + "label": "Context / unregisterDefaultQueryClient", + "to": "framework/lit/reference/functions/unregisterDefaultQueryClient" + }, + { + "label": "Context / resolveQueryClient", + "to": "framework/lit/reference/functions/resolveQueryClient" + } + ] + }, { "label": "angular", "children": [ @@ -1293,6 +1460,10 @@ { "label": "Mutation Property Order", "to": "eslint/mutation-property-order" + }, + { + "label": "Prefer Query Options", + "to": "eslint/prefer-query-options" } ] }, @@ -1500,6 +1671,23 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "Basic", + "to": "framework/lit/examples/basic" + }, + { + "label": "Pagination", + "to": "framework/lit/examples/pagination" + }, + { + "label": "SSR", + "to": "framework/lit/examples/ssr" + } + ] + }, { "label": "angular", "children": [ diff --git a/docs/eslint/eslint-plugin-query.md b/docs/eslint/eslint-plugin-query.md index 8b6ae637dec..8a1d726884b 100644 --- a/docs/eslint/eslint-plugin-query.md +++ b/docs/eslint/eslint-plugin-query.md @@ -46,6 +46,19 @@ export default [ ] ``` +### Recommended strict setup + +The `flat/recommended-strict` config extends `flat/recommended` with additional opinionated rules that enforce best practices more aggressively. + +```js +import pluginQuery from '@tanstack/eslint-plugin-query' + +export default [ + ...pluginQuery.configs['flat/recommended-strict'], + // Any other config... +] +``` + ### Custom setup Alternatively, you can load the plugin and configure only the rules you want to use: @@ -78,6 +91,16 @@ To enable all of the recommended rules for our plugin, add `plugin:@tanstack/que } ``` +### Recommended strict setup + +The `recommendedStrict` config extends `recommended` with additional opinionated rules: + +```json +{ + "extends": ["plugin:@tanstack/query/recommendedStrict"] +} +``` + ### Custom setup Alternatively, add `@tanstack/query` to the plugins section, and configure the rules you want to use: @@ -100,3 +123,4 @@ Alternatively, add `@tanstack/query` to the plugins section, and configure the r - [@tanstack/query/infinite-query-property-order](./infinite-query-property-order.md) - [@tanstack/query/no-void-query-fn](./no-void-query-fn.md) - [@tanstack/query/mutation-property-order](./mutation-property-order.md) +- [@tanstack/query/prefer-query-options](./prefer-query-options.md) diff --git a/docs/eslint/exhaustive-deps.md b/docs/eslint/exhaustive-deps.md index a4b1bd12147..3fa56f1a54f 100644 --- a/docs/eslint/exhaustive-deps.md +++ b/docs/eslint/exhaustive-deps.md @@ -26,13 +26,64 @@ const todoQueries = { Examples of **correct** code for this rule: ```tsx -useQuery({ - queryKey: ['todo', todoId], - queryFn: () => api.getTodo(todoId), -}) +const Component = ({ todoId }) => { + const todos = useTodos() + useQuery({ + queryKey: ['todo', todos, todoId], + queryFn: () => todos.getTodo(todoId), + }) +} +``` +```tsx +const todos = createTodos() const todoQueries = { - detail: (id) => ({ queryKey: ['todo', id], queryFn: () => api.getTodo(id) }), + detail: (id) => ({ + queryKey: ['todo', id], + queryFn: () => todos.getTodo(id), + }), +} +``` + +```tsx +// with { allowlist: { variables: ["todos"] }} +const Component = ({ todoId }) => { + const todos = useTodos() + useQuery({ + queryKey: ['todo', todoId], + queryFn: () => todos.getTodo(todoId), + }) +} +``` + +```tsx +// with { allowlist: { types: ["TodosClient"] }} +class TodosClient { ... } +const Component = ({ todoId }) => { + const todos: TodosClient = new TodosClient() + useQuery({ + queryKey: ['todo', todoId], + queryFn: () => todos.getTodo(todoId), + }) +} +``` + +### Options + +- `allowlist.variables`: An array of variable names that should be ignored when checking dependencies +- `allowlist.types`: An array of TypeScript type names that should be ignored when checking dependencies + +```json +{ + "@tanstack/query/exhaustive-deps": [ + "error", + { + "allowlist": { + "variables": ["api", "config"], + "types": ["ApiClient", "Config"] + } + } + ] } ``` diff --git a/docs/eslint/no-rest-destructuring.md b/docs/eslint/no-rest-destructuring.md index 6b09218cc97..2eafc40e326 100644 --- a/docs/eslint/no-rest-destructuring.md +++ b/docs/eslint/no-rest-destructuring.md @@ -34,6 +34,8 @@ const todosQuery = useQuery({ const { data: todos } = todosQuery ``` +When [typed linting](https://typescript-eslint.io/getting-started/typed-linting/) is enabled, the rule also flags rest destructuring on custom hooks that return a TanStack Query result. + ## When Not To Use It If you set the `notifyOnChangeProps` options manually, you can disable this rule. diff --git a/docs/eslint/no-unstable-deps.md b/docs/eslint/no-unstable-deps.md index 529f82def67..a3f006d9b73 100644 --- a/docs/eslint/no-unstable-deps.md +++ b/docs/eslint/no-unstable-deps.md @@ -22,7 +22,7 @@ Examples of **incorrect** code for this rule: ```tsx /* eslint "@tanstack/query/no-unstable-deps": "warn" */ -import { useCallback } from 'React' +import { useCallback } from 'react' import { useMutation } from '@tanstack/react-query' function Component() { @@ -38,7 +38,7 @@ Examples of **correct** code for this rule: ```tsx /* eslint "@tanstack/query/no-unstable-deps": "warn" */ -import { useCallback } from 'React' +import { useCallback } from 'react' import { useMutation } from '@tanstack/react-query' function Component() { diff --git a/docs/eslint/prefer-query-options.md b/docs/eslint/prefer-query-options.md new file mode 100644 index 00000000000..3228774bf7d --- /dev/null +++ b/docs/eslint/prefer-query-options.md @@ -0,0 +1,145 @@ +--- +id: prefer-query-options +title: Prefer the use of queryOptions +--- + +Separating `queryKey` and `queryFn` can cause unexpected runtime issues when the same query key is accidentally used with more than one `queryFn`. Wrapping them in `queryOptions` (or `infiniteQueryOptions`) co-locates the key and function, making queries safer and easier to reuse. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function Component({ id }) { + const query = useQuery({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) + // ... +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function useFooQuery(id) { + return useQuery({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} +``` + +Examples of **correct** code for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function getFooOptions(id) { + return queryOptions({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} + +function Component({ id }) { + const query = useQuery(getFooOptions(id)) + // ... +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function getFooOptions(id) { + return queryOptions({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} + +function useFooQuery(id) { + return useQuery({ ...getFooOptions(id), select: (data) => data.foo }) +} +``` + +The rule also enforces reusing `queryKey` from a `queryOptions` result instead of typing it manually in `QueryClient` methods or filters. + +Examples of **incorrect** `queryKey` references for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} + +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.getQueryData(['todo', id]) +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} + +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.invalidateQueries({ queryKey: ['todo', id] }) +} +``` + +Examples of **correct** `queryKey` references for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} + +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.getQueryData(todoOptions(id).queryKey) +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} + +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.invalidateQueries({ queryKey: todoOptions(id).queryKey }) +} +``` + +## When Not To Use It + +If you do not want to enforce the use of `queryOptions` in your codebase, you will not need this rule. + +## Attributes + +- [x] āœ… Recommended (strict) +- [ ] šŸ”§ Fixable diff --git a/docs/framework/lit/guides/infinite-queries.md b/docs/framework/lit/guides/infinite-queries.md new file mode 100644 index 00000000000..ab70c03178b --- /dev/null +++ b/docs/framework/lit/guides/infinite-queries.md @@ -0,0 +1,87 @@ +--- +id: infinite-queries +title: Infinite Queries +--- + +Infinite queries are for lists that load more data into one cache entry. Use [`createInfiniteQueryController`](../reference/functions/createInfiniteQueryController.md). + +An infinite query result contains: + +- `data.pages`: fetched pages +- `data.pageParams`: page parameters used for those pages +- `fetchNextPage` and `fetchPreviousPage` +- `hasNextPage` and `hasPreviousPage` +- `isFetchingNextPage` and `isFetchingPreviousPage` + +## Load More Example + +```ts +import { LitElement, html } from 'lit' +import { createInfiniteQueryController } from '@tanstack/lit-query' + +class ProjectsList extends LitElement { + private readonly projects = createInfiniteQueryController(this, { + queryKey: ['projects'], + queryFn: ({ pageParam }) => fetchProjectsPage(pageParam), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.page + 1 : undefined, + }) + + render() { + const query = this.projects() + + if (query.isPending) return html`Loading...` + if (query.isError) return html`Error: ${query.error.message}` + + return html` + ${query.data.pages.map( + (page) => html` + ${page.projects.map((project) => html`

${project.name}

`)} + `, + )} + + + ` + } +} +``` + +## Page Parameters + +`initialPageParam` is required. `getNextPageParam` decides whether another page exists and what value should be passed as `pageParam` to the next query function call. + +```ts +createInfiniteQueryController(this, { + queryKey: ['projects'], + queryFn: ({ pageParam }) => fetchProjectsPage(pageParam), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.page + 1 : undefined, +}) +``` + +Returning `undefined` or `null` means there is no next page. + +## Avoid Overlapping Fetches + +There is one ongoing fetch for an infinite query cache entry. If you call `fetchNextPage` while a background refetch is running, you can overwrite data. Disable the button or check `!query.isFetching` before loading more: + +```ts +if (query.hasNextPage && !query.isFetching) { + this.projects.fetchNextPage() +} +``` + +## Paginated Alternative + +If your UI shows one page at a time, a normal query with a page in the key can be a better fit. The [Pagination example](../examples/pagination) uses `createQueryController`, `placeholderData: keepPreviousData`, prefetching, and mutations to demonstrate that pattern. diff --git a/docs/framework/lit/guides/mutations.md b/docs/framework/lit/guides/mutations.md new file mode 100644 index 00000000000..8e5c3fdfa42 --- /dev/null +++ b/docs/framework/lit/guides/mutations.md @@ -0,0 +1,166 @@ +--- +id: mutations +title: Mutations +--- + +Unlike queries, mutations are used to create, update, delete, or otherwise perform server side effects. In Lit, use [`createMutationController`](../reference/functions/createMutationController.md). + +```ts +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createMutationController, + createQueryController, +} from '@tanstack/lit-query' + +const queryClient = new QueryClient() + +class AppQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } +} + +customElements.define('app-query-provider', AppQueryProvider) + +class TodosView extends LitElement { + private readonly todos = createQueryController(this, { + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + private readonly addTodo = createMutationController(this, { + mutationFn: createTodo, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, + }) + + render() { + const query = this.todos() + const mutation = this.addTodo() + const todos = query.data ?? [] + + return html` + ${mutation.isError ? html`

${mutation.error.message}

` : null} + ${mutation.isSuccess ? html`

Todo added

` : null} + + + + + ` + } +} + +customElements.define('todos-view', TodosView) +``` + +Render the element under the provider so the controllers can resolve the same `QueryClient` from Lit context: + +```html + + + +``` + +## Mutation States + +A mutation can be in one of these primary states: + +- `isIdle` or `status === 'idle'`: no mutation has run or it has been reset +- `isPending` or `status === 'pending'`: the mutation is running +- `isError` or `status === 'error'`: the mutation failed and `error` is available +- `isSuccess` or `status === 'success'`: the mutation finished and `data` is available + +## Variables + +Pass variables to the mutation function by calling `mutate`: + +```ts +this.addTodo.mutate({ + title: this.nextTitle, +}) +``` + +`mutate` throws synchronously if the controller cannot resolve a `QueryClient`, such as when the element is not under a connected `QueryClientProvider` and no explicit client was passed. `mutateAsync` reports the same setup problem as a rejected promise. + +Use `mutateAsync` when you want a promise: + +```ts +try { + const created = await this.addTodo.mutateAsync({ title: this.nextTitle }) + this.nextTitle = created.title +} catch (error) { + this.errorMessage = String(error) +} +``` + +## Resetting Mutation State + +The accessor includes `reset`: + +```ts +html` + ${mutation.isError + ? html`` + : null} +` +``` + +## Side Effects + +Mutation options support `onMutate`, `onError`, `onSuccess`, and `onSettled`. The pagination example passes an explicit `queryClient` to the controller and uses the same in-scope client for optimistic updates and rollback: + +```ts +private readonly favoriteMutation = createMutationController( + this, + { + mutationKey: ['toggle-project-favorite'], + mutationFn: async (input) => { + const response = await toggleProjectFavoriteOnServer(input) + return response.project + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey: ['projects'] }) + const snapshots = queryClient.getQueriesData({ + queryKey: ['projects'], + }) + + for (const [key, existing] of snapshots) { + if (!existing) continue + + queryClient.setQueryData(key, { + ...existing, + projects: existing.projects.map((project) => + project.id === variables.id + ? { ...project, isFavorite: variables.isFavorite } + : project, + ), + }) + } + + return { snapshots } + }, + onError: (_error, _variables, context) => { + for (const [key, snapshot] of context?.snapshots ?? []) { + queryClient.setQueryData(key, snapshot) + } + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: ['projects'] }) + }, + }, + queryClient, +) +``` + +For the exact runnable flow, see the [Pagination example](../examples/pagination). diff --git a/docs/framework/lit/guides/parallel-queries.md b/docs/framework/lit/guides/parallel-queries.md new file mode 100644 index 00000000000..2d9bf967f7b --- /dev/null +++ b/docs/framework/lit/guides/parallel-queries.md @@ -0,0 +1,134 @@ +--- +id: parallel-queries +title: Parallel Queries +--- + +Parallel queries are queries that run at the same time so the UI does not wait for one request before starting the next. + +## Manual Parallel Queries + +When the number of queries is fixed, create multiple query controllers on the same host. They will all subscribe when the host connects. + +```ts +import { LitElement, html } from 'lit' +import { createQueryController } from '@tanstack/lit-query' + +class DashboardView extends LitElement { + private readonly users = createQueryController(this, { + queryKey: ['users'], + queryFn: fetchUsers, + }) + + private readonly teams = createQueryController(this, { + queryKey: ['teams'], + queryFn: fetchTeams, + }) + + private readonly projects = createQueryController(this, { + queryKey: ['projects'], + queryFn: fetchProjects, + }) + + render() { + const users = this.users() + const teams = this.teams() + const projects = this.projects() + + if (users.isPending || teams.isPending || projects.isPending) { + return html`Loading...` + } + + if (users.isError || teams.isError || projects.isError) { + return html`Unable to load dashboard` + } + + return html` + + ` + } +} +``` + +Each controller receives the same `ReactiveControllerHost`. If no explicit `QueryClient` is passed, each controller resolves the nearest connected `QueryClientProvider`. + +## Dynamic Parallel Queries + +When the number of queries changes with host state, use [`createQueriesController`](../reference/functions/createQueriesController.md). It accepts a `queries` array and returns an accessor for the array of query results. + +Use an options getter when the query list depends on reactive host fields: + +```ts +import { LitElement, html } from 'lit' +import { createQueriesController } from '@tanstack/lit-query' + +class UsersDetails extends LitElement { + static properties = { + userIds: { attribute: false }, + } + + userIds: Array = [] + + private readonly users = createQueriesController(this, () => ({ + queries: this.userIds.map((id) => ({ + queryKey: ['user', id], + queryFn: () => fetchUserById(id), + })), + })) + + render() { + const userQueries = this.users() + + return html` +
    + ${userQueries.map((query, index) => { + if (query.isPending) return html`
  • Loading...
  • ` + if (query.isError) return html`
  • Error loading user
  • ` + + return html`
  • ${this.userIds[index]}: ${query.data.name}
  • ` + })} +
+ ` + } +} +``` + +The order of the results matches the order of the input queries. + +## Combining Results + +Use `combine` when a component wants one derived value instead of an array of query results: + +```ts +private readonly dashboard = createQueriesController(this, { + queries: [ + { queryKey: ['stats'], queryFn: fetchStats }, + { queryKey: ['projects'], queryFn: fetchProjects }, + ], + combine: ([stats, projects]) => ({ + activeUsers: stats.data?.activeUsers ?? 0, + projects: projects.data ?? [], + isPending: stats.isPending || projects.isPending, + isError: stats.isError || projects.isError, + }), +}) +``` + +```ts +render() { + const dashboard = this.dashboard() + + if (dashboard.isPending) return html`Loading...` + if (dashboard.isError) return html`Unable to load dashboard` + + return html` +

Total projects: ${dashboard.projects.length}

+

Active users: ${dashboard.activeUsers}

+ ` +} +``` + +Having the same query key more than once in the `queries` array can cause those entries to share cached data. Deduplicate repeated keys first if each rendered row needs independent query state. diff --git a/docs/framework/lit/guides/queries.md b/docs/framework/lit/guides/queries.md new file mode 100644 index 00000000000..592cdcfd029 --- /dev/null +++ b/docs/framework/lit/guides/queries.md @@ -0,0 +1,141 @@ +--- +id: queries +title: Queries +--- + +New to Lit Query? Start with [Installation](../installation.md) and [Quick Start](../quick-start.md) before wiring query controllers into your elements. + +## Query Basics + +A query is a declarative dependency on an asynchronous source of data tied to a unique key. Use queries for reading server state. If a function creates, updates, or deletes server data, use a [mutation](./mutations.md) instead. + +In Lit, subscribe to a query with [`createQueryController`](../reference/functions/createQueryController.md): + +```ts +import { LitElement, html } from 'lit' +import { createQueryController } from '@tanstack/lit-query' + +class TodosView extends LitElement { + private readonly todos = createQueryController(this, { + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + render() { + const query = this.todos() + + if (query.isPending) return html`Loading...` + if (query.isError) return html`Error: ${query.error.message}` + + return html` +
    + ${query.data.map((todo) => html`
  • ${todo.title}
  • `)} +
+ ` + } +} +``` + +The controller needs: + +- A `ReactiveControllerHost`, usually `this` inside a `LitElement` +- A unique `queryKey` +- A `queryFn` that returns a promise and throws on errors + +The returned accessor exposes the current `QueryObserverResult`. Call it in `render`, or read `.current`: + +```ts +const query = this.todos() +const sameQuery = this.todos.current +``` + +## Query States + +A query can be in one primary state at a time: + +- `isPending` or `status === 'pending'`: no data is available yet +- `isError` or `status === 'error'`: the query failed and `error` is available +- `isSuccess` or `status === 'success'`: data is available + +The result also includes `isFetching`, which can be true during the initial load or a background refetch. + +```ts +render() { + const query = this.todos() + + if (query.status === 'pending') { + return html`Loading...` + } + + if (query.status === 'error') { + return html`Error: ${query.error.message}` + } + + return html`` +} +``` + +TypeScript will narrow `query.data` after you check `pending` and `error` before reading it. + +## Fetch Status + +The `status` field describes whether data is available. The `fetchStatus` field describes what the query function is doing: + +- `fetchStatus === 'fetching'`: the query is currently fetching. +- `fetchStatus === 'paused'`: the query wanted to fetch, but fetching is paused. +- `fetchStatus === 'idle'`: the query is not fetching. + +These states are intentionally separate. Background refetching and stale-while-revalidate behavior can produce combinations like: + +- A successful query with cached data can have `status === 'success'` and `fetchStatus === 'fetching'` while a background refetch is running. +- A query with no data can have `status === 'pending'` and `fetchStatus === 'paused'` if fetching cannot start yet. + +Use `status` when deciding whether data can be rendered, and use `fetchStatus` or `isFetching` when deciding whether to show a network activity indicator: + +```ts +render() { + const query = this.todos() + + if (query.isPending) return html`Loading...` + if (query.isError) return html`Error: ${query.error.message}` + + return html` + ${query.fetchStatus === 'fetching' + ? html`Refreshing...` + : null} + + ` +} +``` + +## Reactive Query Options + +Use an options getter when the query key or query function depends on host state: + +```ts +class UserTodos extends LitElement { + static properties = { + userId: { type: String }, + } + + userId = '' + + private readonly todos = createQueryController(this, () => ({ + queryKey: ['todos', this.userId], + queryFn: () => fetchTodos(this.userId), + enabled: this.userId.length > 0, + })) +} +``` + +The query key is used for caching, refetching, and sharing data between controllers. + +## Refetching + +The accessor includes `refetch`: + +```ts +html`` +``` + +For multiple queries that should run at the same time, see [Parallel Queries](./parallel-queries.md). diff --git a/docs/framework/lit/guides/query-functions.md b/docs/framework/lit/guides/query-functions.md new file mode 100644 index 00000000000..74845737e82 --- /dev/null +++ b/docs/framework/lit/guides/query-functions.md @@ -0,0 +1,81 @@ +--- +id: query-functions +title: Query Functions +--- + +A query function can be any function that returns a promise. The promise should resolve data or throw an error. + +```ts +createQueryController(this, { + queryKey: ['todos'], + queryFn: fetchTodos, +}) + +createQueryController(this, () => ({ + queryKey: ['todo', this.todoId], + queryFn: () => fetchTodo(this.todoId), +})) +``` + +## Handling Errors + +TanStack Query needs failed query functions to throw or return a rejected promise. Some clients do that automatically. The browser `fetch` API does not, so check `response.ok` yourself: + +```ts +async function fetchTodos(): Promise { + const response = await fetch('/api/todos') + + if (!response.ok) { + throw new Error('Failed to fetch todos') + } + + return response.json() as Promise +} +``` + +The thrown error is available on the query result: + +```ts +const query = this.todos() + +if (query.isError) { + return html`Error: ${query.error.message}` +} +``` + +## Query Function Context + +TanStack Query passes a context object to every query function. It includes: + +- `queryKey`: the current query key +- `client`: the `QueryClient` +- `signal`: an `AbortSignal` for cancellation +- `meta`: optional query metadata + +```ts +createQueryController(this, { + queryKey: ['todos', { status: 'open' }], + queryFn: async ({ queryKey, signal }) => { + const [, filters] = queryKey + const response = await fetch(`/api/todos?status=${filters.status}`, { + signal, + }) + if (!response.ok) throw new Error('Failed to fetch todos') + return response.json() as Promise + }, +}) +``` + +Infinite query functions also receive `pageParam`: + +```ts +createInfiniteQueryController(this, { + queryKey: ['projects'], + queryFn: ({ pageParam }) => fetchProjectsPage(pageParam), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.page + 1 : undefined, +}) +``` + +See [Infinite Queries](./infinite-queries.md) for the controller-specific behavior. diff --git a/docs/framework/lit/guides/query-invalidation.md b/docs/framework/lit/guides/query-invalidation.md new file mode 100644 index 00000000000..0227469ad41 --- /dev/null +++ b/docs/framework/lit/guides/query-invalidation.md @@ -0,0 +1,83 @@ +--- +id: query-invalidation +title: Query Invalidation +--- + +Waiting for queries to become stale is not always enough. After a mutation succeeds, you often know that related cached data is out of date. Use `queryClient.invalidateQueries` to mark matching queries stale and refetch active observers. + +```ts +queryClient.invalidateQueries() + +queryClient.invalidateQueries({ + queryKey: ['todos'], +}) +``` + +When a query is invalidated: + +- It is marked stale, overriding any `staleTime` +- If a matching query is active in a controller, it can refetch in the background + +## Invalidation from Mutations + +```ts +private readonly addTodo = createMutationController(this, { + mutationFn: addTodo, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, +}) +``` + +Use this pattern when the mutation result tells you related cached data is stale. The [Pagination example](../examples/pagination) shows invalidation after project mutations. + +## Query Matching + +Match a group of queries by prefix: + +```ts +queryClient.invalidateQueries({ queryKey: ['projects'] }) +``` + +Both of these query keys match: + +```ts +const projectsListKey = ['projects'] +const projectsPageKey = ['projects', 1, 250, false] +``` + +Use a more specific key when only one slice should be invalidated: + +```ts +queryClient.invalidateQueries({ + queryKey: ['projects', this.page], +}) +``` + +Use `exact: true` to match only the exact key: + +```ts +queryClient.invalidateQueries({ + queryKey: ['projects'], + exact: true, +}) +``` + +## Manual Cache Updates + +Invalidation is usually simpler than normalized cache updates. When you do need immediate UI updates, combine targeted cache writes with invalidation: + +```ts +queryClient.setQueryData(['todos'], (existing) => { + if (!existing) return existing + + return { + ...existing, + items: [...existing.items, createdTodo], + } +}) + +await queryClient.invalidateQueries({ queryKey: ['todos'] }) +``` + +For rollback with optimistic updates, see the mutation guide and the [Pagination example](../examples/pagination). diff --git a/docs/framework/lit/guides/query-keys.md b/docs/framework/lit/guides/query-keys.md new file mode 100644 index 00000000000..8a532e93e7e --- /dev/null +++ b/docs/framework/lit/guides/query-keys.md @@ -0,0 +1,85 @@ +--- +id: query-keys +title: Query Keys +--- + +TanStack Query manages caching by query key. Query keys must be arrays at the top level, and they should uniquely describe the data returned by the query function. + +## Simple Keys + +Use simple keys for list resources or non-hierarchical data: + +```ts +createQueryController(this, { + queryKey: ['todos'], + queryFn: fetchTodos, +}) + +createQueryController(this, { + queryKey: ['settings'], + queryFn: fetchSettings, +}) +``` + +## Keys with Variables + +Include variables when they change what the query fetches: + +```ts +createQueryController(this, () => ({ + queryKey: ['todo', this.todoId], + queryFn: () => fetchTodo(this.todoId), +})) + +createQueryController(this, () => ({ + queryKey: ['projects', { page: this.page, filter: this.filter }], + queryFn: () => fetchProjects({ page: this.page, filter: this.filter }), +})) +``` + +The pagination example uses a key shaped like this: + +```ts +type ProjectsQueryKey = readonly ['projects', number, number, boolean] + +function projectsQueryKey( + page: number, + delayMs: number, + forceError: boolean, +): ProjectsQueryKey { + return ['projects', page, delayMs, forceError] as const +} +``` + +## Deterministic Hashing + +Object key order does not matter inside a query key: + +```ts +const keyA = ['todos', { status, page }] +const keyB = ['todos', { page, status }] +``` + +Array item order does matter: + +```ts +const keyA = ['todos', status, page] +const keyB = ['todos', page, status] +``` + +## Query Keys as Dependencies + +If your query function reads a reactive host property, include that value in the query key: + +```ts +class UserTodos extends LitElement { + userId = '' + + private readonly todos = createQueryController(this, () => ({ + queryKey: ['todos', this.userId], + queryFn: () => fetchTodos(this.userId), + })) +} +``` + +This lets Lit Query cache each user's todos separately and refetch when the host state changes. diff --git a/docs/framework/lit/guides/reactive-controllers-vs-hooks.md b/docs/framework/lit/guides/reactive-controllers-vs-hooks.md new file mode 100644 index 00000000000..0cb0530ed0b --- /dev/null +++ b/docs/framework/lit/guides/reactive-controllers-vs-hooks.md @@ -0,0 +1,111 @@ +--- +id: reactive-controllers-vs-hooks +title: Reactive Controllers vs Hooks +--- + +React Query examples use hooks. Lit Query uses reactive controllers. + +The job is similar: subscribe a component to a `QueryClient`, read the latest result, and update the component when the cache changes. The integration point is different because Lit components use the `ReactiveControllerHost` interface instead of React's render and hook system. + +## Mapping the Concepts + +| React Query | Lit Query | +| --------------------------- | ---------------------------------------------- | +| `useQuery(options)` | `createQueryController(this, options)` | +| `useQueries(options)` | `createQueriesController(this, options)` | +| `useMutation(options)` | `createMutationController(this, options)` | +| `useInfiniteQuery(options)` | `createInfiniteQueryController(this, options)` | +| `useIsFetching(options)` | `useIsFetching(this, options)` | +| `useIsMutating(options)` | `useIsMutating(this, options)` | +| `useMutationState(options)` | `useMutationState(this, options)` | +| Hook result object | Callable result accessor | +| React context provider | `QueryClientProvider` custom element | +| Component render rerun | `host.requestUpdate()` | + +## Host-Bound APIs + +Lit APIs that subscribe a component to query or mutation state receive a `host` as the first argument: + +```ts +class TodosView extends LitElement { + private readonly todos = createQueryController(this, { + queryKey: ['todos'], + queryFn: fetchTodos, + }) +} +``` + +`this` is valid because `LitElement` implements `ReactiveControllerHost`. The controller attaches to the host, subscribes when the host connects, requests updates when the query result changes, and unsubscribes when the host disconnects. + +The host-bound APIs are [`createQueryController`](../reference/functions/createQueryController.md), [`createQueriesController`](../reference/functions/createQueriesController.md), [`createInfiniteQueryController`](../reference/functions/createInfiniteQueryController.md), [`createMutationController`](../reference/functions/createMutationController.md), [`useIsFetching`](../reference/functions/useIsFetching.md), [`useIsMutating`](../reference/functions/useIsMutating.md), and [`useMutationState`](../reference/functions/useMutationState.md). + +[`useQueryClient`](../reference/functions/useQueryClient.md) is different. It is not a reactive controller, does not accept a host, does not subscribe, and throws synchronously if no single default client is available. Use it only for imperative code that runs while exactly one `QueryClientProvider` is connected. Inside host-bound APIs, prefer the provider context or pass an explicit `QueryClient`. + +## Reading Results + +Lit Query controller creators return a callable accessor with a `current` property: + +```ts +const query = this.todos() +const sameQuery = this.todos.current +``` + +Render methods normally call the accessor: + +```ts +render() { + const query = this.todos() + + if (query.isPending) return html`Loading...` + if (query.isError) return html`Error: ${query.error.message}` + + return html`` +} +``` + +## Reactive Options + +If query options depend on host state, pass a function. Lit Query re-reads function accessors during host updates: + +```ts +class ProjectView extends LitElement { + static properties = { + projectId: { type: Number }, + } + + projectId = 1 + + private readonly project = createQueryController(this, () => ({ + queryKey: ['project', this.projectId], + queryFn: () => fetchProject(this.projectId), + })) +} +``` + +If options are static, pass an object. If you mutate a static options object yourself, call the controller helper that causes the observer to see the new options, such as `refetch`, or prefer a function accessor for reactive state. + +## Provider Context + +Host-bound APIs can receive an explicit `QueryClient`, but most apps render under [`QueryClientProvider`](../reference/classes/QueryClientProvider.md). The provider uses Lit context to deliver the client to descendant controllers. + +```ts +customElements.define('query-client-provider', QueryClientProvider) +``` + +```ts +html` + + + +` +``` + +Custom element registration is always the application's responsibility. + +`QueryClientProvider` also registers its client in a process-local fallback store for [`useQueryClient`](../reference/functions/useQueryClient.md) and [`resolveQueryClient`](../reference/functions/resolveQueryClient.md). That fallback is intentionally conservative: + +- If no provider is connected, `useQueryClient()` throws. +- If exactly one distinct client is connected, `useQueryClient()` returns it. +- If multiple distinct clients are connected in the same JavaScript context, `useQueryClient()` and `resolveQueryClient()` throw because the fallback would be ambiguous. + +Multiple roots, micro-frontends, test suites with shared modules, and nested apps should avoid relying on the process-local fallback. Render host-bound controllers under the right provider, pass an explicit `QueryClient` to the controller, or cleanly disconnect providers between tests. diff --git a/docs/framework/lit/guides/ssr.md b/docs/framework/lit/guides/ssr.md new file mode 100644 index 00000000000..0c6cce2e476 --- /dev/null +++ b/docs/framework/lit/guides/ssr.md @@ -0,0 +1,145 @@ +--- +id: ssr +title: Server Rendering & Hydration +--- + +Lit Query can be used with server rendering by combining Lit SSR with TanStack Query Core hydration APIs re-exported from `@tanstack/lit-query`. + +The runnable source for this guide is the [SSR example](../examples/ssr). + +## Flow + +Server rendering has three phases: + +1. Create a per-request `QueryClient`. +2. Prefetch queries on the server and render Lit HTML with that client. +3. Dehydrate the cache into the HTML, then hydrate a browser `QueryClient` before the client app renders. + +Never share one server `QueryClient` between users or requests. + +## Server Prefetch and Render + +```ts +import { render } from '@lit-labs/ssr' +import { collectResult } from '@lit-labs/ssr/lib/render-result.js' +import { html } from 'lit' +import { QueryClient, dehydrate } from '@tanstack/lit-query' +import { createDataQueryOptions } from './api.js' +import './app.js' + +async function renderPage() { + const apiBaseUrl = 'https://example.com' + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + }, + }, + }) + + await queryClient.prefetchQuery(createDataQueryOptions(apiBaseUrl)) + + const appHtml = await collectResult( + render( + html``, + ), + ) + + const dehydratedState = dehydrate(queryClient) + + return { appHtml, dehydratedState } +} +``` + +The server passes the same client into the Lit element with a property binding. This lets `createQueryController` read the prefetched cache during server render. If your query function calls `fetch` during SSR, pass an absolute API origin instead of relying on a browser-relative URL. + +## Client Hydration + +```ts +import '@lit-labs/ssr-client/lit-element-hydrate-support.js' +import { QueryClient, hydrate, type DehydratedState } from '@tanstack/lit-query' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + }, + }, +}) + +const dehydratedState = JSON.parse( + document.getElementById('__QUERY_STATE__')?.textContent ?? 'null', +) as DehydratedState + +queryClient.mount() +hydrate(queryClient, dehydratedState) + +const appElement = document.querySelector('ssr-app') as + | (HTMLElement & { queryClient?: QueryClient }) + | null + +if (!appElement) { + throw new Error('Expected the SSR app element to exist before hydration.') +} + +appElement.queryClient = queryClient +await import('./app.js') +``` + +Unmount the client when the page is unloaded if you mounted it manually: + +```ts +window.addEventListener( + 'pagehide', + () => { + queryClient.unmount() + }, + { once: true }, +) +``` + +## Component Pattern + +The SSR example creates its controller only after a `queryClient` property is available: + +```ts +import { LitElement } from 'lit' +import { + createQueryController, + type QueryClient, + type QueryResultAccessor, +} from '@tanstack/lit-query' +import { createDataQueryOptions, type DataResponse } from './api.js' + +class SsrApp extends LitElement { + static properties = { + apiBaseUrl: { attribute: 'api-base-url' }, + queryClient: { attribute: false }, + } + + apiBaseUrl = '' + queryClient?: QueryClient + private dataQuery?: QueryResultAccessor + + protected override willUpdate(): void { + if (!this.dataQuery && this.queryClient) { + this.dataQuery = createQueryController( + this, + createDataQueryOptions(this.apiBaseUrl), + this.queryClient, + ) + } + } +} +``` + +This explicit-client pattern is useful for SSR because the client is created by the renderer rather than discovered from a connected DOM provider. + +## Serialization + +Embed dehydrated state as JSON in the HTML and escape characters that can break out of a script tag. The example server uses a small serializer before replacing `__QUERY_STATE_JSON__` in the built HTML template. + +Lit Query re-exports `dehydrate` and `hydrate` from TanStack Query Core. Use `dehydrate(queryClient)` after server prefetching to capture the cache state. In the browser, parse that state, create a fresh `QueryClient`, call `hydrate(queryClient, dehydratedState)`, assign the client to the server-rendered element, and only then import the Lit component so it upgrades with the prefetched cache available. diff --git a/docs/framework/lit/installation.md b/docs/framework/lit/installation.md new file mode 100644 index 00000000000..dacbadb9e58 --- /dev/null +++ b/docs/framework/lit/installation.md @@ -0,0 +1,109 @@ +--- +id: installation +title: Installation +--- + +> IMPORTANT: The Lit adapter is currently experimental and v0.1. Pin exact versions if you need extra release stability while the API is early. + +Install Lit Query with Lit and TanStack Query Core: + +```bash +npm i @tanstack/lit-query @tanstack/query-core lit +``` + +or + +```bash +pnpm add @tanstack/lit-query @tanstack/query-core lit +``` + +or + +```bash +yarn add @tanstack/lit-query @tanstack/query-core lit +``` + +or + +```bash +bun add @tanstack/lit-query @tanstack/query-core lit +``` + +`@tanstack/query-core` is a peer dependency of `@tanstack/lit-query`. Even though the Lit docs import user-facing APIs from `@tanstack/lit-query`, your app should install `@tanstack/query-core` explicitly. + +## Requirements + +Lit Query is intended for Lit 2.8 and newer, including Lit 3. It uses Lit reactive controllers and Lit context, so query consumers should be `ReactiveControllerHost` instances such as `LitElement`. + +TanStack Query is optimized for modern browsers: + +```txt +Chrome >= 91 +Firefox >= 90 +Edge >= 91 +Safari >= 15 +iOS >= 15 +Opera >= 77 +``` + +## Provider Setup + +Create a `QueryClient`, provide it with `QueryClientProvider`, and register your custom element. The package exports the provider class but does not call `customElements.define` for you. + +### Subclass Pattern + +```ts +import { QueryClient, QueryClientProvider } from '@tanstack/lit-query' + +const queryClient = new QueryClient() + +class AppQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } +} + +customElements.define('app-query-provider', AppQueryProvider) +``` + +```html + + + +``` + +### Direct Provider Element + +You can register the provider class directly and bind its `client` property from a Lit template. The dot is important: `.client=${queryClient}` is a property binding, not an HTML attribute. + +```ts +import { LitElement, html } from 'lit' +import { QueryClient, QueryClientProvider } from '@tanstack/lit-query' + +const queryClient = new QueryClient() + +customElements.define('query-client-provider', QueryClientProvider) + +class AppRoot extends LitElement { + render() { + return html` + + + + ` + } +} +``` + +If a connected provider has no `client`, it throws. See the generated [`QueryClientProvider` reference](./reference/classes/QueryClientProvider.md) for the full contract. + +## Render Roots + +The snippets in these docs use Lit's default shadow DOM. Lit Query controllers and `QueryClientProvider` work with shadow DOM and light DOM because they use the host lifecycle and Lit context, not global selectors. + +Some runnable examples override `createRenderRoot()` and return `this` so their demo markup stays in light DOM for shared page styles and test selectors. That override is not required for Lit Query. Use light DOM only when your app has a separate reason to expose a component's internal markup to global CSS, tests, or non-shadow-DOM integration code. + +## Devtools Status + +Lit Devtools are not available yet. This is a current adapter limitation, not an installation step. diff --git a/docs/framework/lit/overview.md b/docs/framework/lit/overview.md new file mode 100644 index 00000000000..354848d6231 --- /dev/null +++ b/docs/framework/lit/overview.md @@ -0,0 +1,93 @@ +--- +id: overview +title: Overview +--- + +> IMPORTANT: The `@tanstack/lit-query` package is currently experimental and v0.1. Expect the Lit adapter API and docs to keep evolving. If you use it in production while it is early, pin the package to a patch version and upgrade deliberately. + +The `@tanstack/lit-query` package is the Lit adapter for TanStack Query. It gives Lit applications reactive controller APIs for fetching, caching, synchronizing, and updating server state. + +TanStack Query manages server state: data that is owned by a remote system, fetched asynchronously, shared across screens, and potentially changed by someone else at any time. It handles caching, request deduplication, stale data, background refetching, mutations, invalidation, pagination, and garbage collection. + +Lit Query exposes those features through [Lit reactive controllers](https://lit.dev/docs/composition/controllers/). A Lit reactive controller is attached to a `ReactiveControllerHost`, usually a `LitElement`. Lit Query controllers subscribe to the `QueryClient`, request host updates when results change, and are cleaned up with the host lifecycle. + +## Core APIs + +Most Lit applications use these APIs: + +- [`QueryClientProvider`](./reference/classes/QueryClientProvider.md) to provide a `QueryClient` through Lit context +- [`createQueryController`](./reference/functions/createQueryController.md) for queries +- [`createQueriesController`](./reference/functions/createQueriesController.md) for dynamic parallel queries +- [`createMutationController`](./reference/functions/createMutationController.md) for mutations +- [`createInfiniteQueryController`](./reference/functions/createInfiniteQueryController.md) for infinite queries +- [`useIsFetching`](./reference/functions/useIsFetching.md), [`useIsMutating`](./reference/functions/useIsMutating.md), and [`useMutationState`](./reference/functions/useMutationState.md) for cache state indicators + +The adapter also re-exports TanStack Query Core APIs from `@tanstack/lit-query`, so examples in the Lit docs use `@tanstack/lit-query` as the user-facing import path. + +## A First Query + +```ts +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createQueryController, +} from '@tanstack/lit-query' + +const queryClient = new QueryClient() + +class AppQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } +} + +customElements.define('app-query-provider', AppQueryProvider) + +class RepoStats extends LitElement { + private readonly repo = createQueryController(this, { + queryKey: ['repoData'], + queryFn: async () => { + const response = await fetch( + 'https://api.github.com/repos/TanStack/query', + ) + if (!response.ok) throw new Error('Failed to fetch repo data') + return response.json() as Promise<{ + name: string + description: string + stargazers_count: number + }> + }, + }) + + render() { + const query = this.repo() + + if (query.isPending) return html`Loading...` + if (query.isError) return html`Error: ${query.error.message}` + + return html` +

${query.data.name}

+

${query.data.description}

+ ${query.data.stargazers_count} stars + ` + } +} + +customElements.define('repo-stats', RepoStats) +``` + +Render the provider above your query consumers: + +```html + + + +``` + +## Status Notes + +Lit Devtools are not available yet. Use the cache APIs and the generated API reference while the adapter matures. + +Start with [Installation](./installation.md), then [Quick Start](./quick-start.md), and use the [Reactive Controllers vs Hooks](./guides/reactive-controllers-vs-hooks.md) guide if you are coming from React Query. diff --git a/docs/framework/lit/quick-start.md b/docs/framework/lit/quick-start.md new file mode 100644 index 00000000000..69bff6c8f11 --- /dev/null +++ b/docs/framework/lit/quick-start.md @@ -0,0 +1,83 @@ +--- +id: quick-start +title: Quick Start +--- + +This snippet shows the three core Lit Query concepts: + +- [Queries](./guides/queries.md) +- [Mutations](./guides/mutations.md) +- [Query Invalidation](./guides/query-invalidation.md) + +For complete runnable examples, see [Basic](./examples/basic), [Pagination](./examples/pagination), and [SSR](./examples/ssr). + +```ts +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createMutationController, + createQueryController, +} from '@tanstack/lit-query' +import { addTodo, getTodos } from './api' + +const queryClient = new QueryClient() + +class AppQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } +} + +customElements.define('app-query-provider', AppQueryProvider) + +class TodosView extends LitElement { + private readonly todos = createQueryController(this, { + queryKey: ['todos'], + queryFn: getTodos, + }) + + private readonly createTodo = createMutationController(this, { + mutationFn: addTodo, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, + }) + + render() { + const query = this.todos() + const mutation = this.createTodo() + + if (query.isPending) return html`Loading...` + if (query.isError) return html`Error: ${query.error.message}` + + return html` +
    + ${query.data.map((todo) => html`
  • ${todo.title}
  • `)} +
+ + + ` + } +} + +customElements.define('todos-view', TodosView) +``` + +Mount the provider around your component: + +```html + + + +``` + +The controllers are created with `this` because a `LitElement` is a `ReactiveControllerHost`. Lit Query uses the host lifecycle to subscribe, request updates, and clean up when the element disconnects. + +Continue with [Reactive Controllers vs Hooks](./guides/reactive-controllers-vs-hooks.md) if you know React Query, or go straight to [Queries](./guides/queries.md). diff --git a/docs/framework/lit/reference/classes/QueryClientProvider.md b/docs/framework/lit/reference/classes/QueryClientProvider.md new file mode 100644 index 00000000000..df14c2769bb --- /dev/null +++ b/docs/framework/lit/reference/classes/QueryClientProvider.md @@ -0,0 +1,98 @@ +--- +id: QueryClientProvider +title: QueryClientProvider +--- + +# Class: QueryClientProvider + +Defined in: [packages/lit-query/src/QueryClientProvider.ts:64](https://github.com/TanStack/query/blob/main/packages/lit-query/src/QueryClientProvider.ts#L64) + +Lit element that provides a `QueryClient` to descendant Lit Query +controllers through Lit context. + +The `client` is a property, not an attribute. When rendering this element in +a Lit template, bind it with property binding: `.client=${queryClient}`. +The provider throws if it connects without a client, or if an already +connected provider has its client cleared. + +This class is not registered as a custom element by the package. Applications +must register either a subclass or the class itself with +`customElements.define`. + +## Examples + +```ts +import { html, LitElement } from 'lit' +import { QueryClient, QueryClientProvider } from '@tanstack/lit-query' + +const queryClient = new QueryClient() + +class AppQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } +} + +customElements.define('app-query-provider', AppQueryProvider) + +class AppRoot extends LitElement { + render() { + return html`` + } +} +``` + +```ts +import { html } from 'lit' +import { QueryClient, QueryClientProvider } from '@tanstack/lit-query' + +const queryClient = new QueryClient() + +customElements.define('query-client-provider', QueryClientProvider) + +const view = html` + + + +` +``` + +## Extends + +- `LitElement` + +## Constructors + +### Constructor + +```ts +new QueryClientProvider(): QueryClientProvider; +``` + +Defined in: [packages/lit-query/src/QueryClientProvider.ts:82](https://github.com/TanStack/query/blob/main/packages/lit-query/src/QueryClientProvider.ts#L82) + +#### Returns + +`QueryClientProvider` + +#### Overrides + +```ts +LitElement.constructor +``` + +## Properties + +### client + +```ts +client: QueryClient; +``` + +Defined in: [packages/lit-query/src/QueryClientProvider.ts:76](https://github.com/TanStack/query/blob/main/packages/lit-query/src/QueryClientProvider.ts#L76) + +The `QueryClient` provided to descendant controllers and global fallback +helpers while this provider is connected. + +Bind this as a property in Lit templates with `.client=${queryClient}`. diff --git a/docs/framework/lit/reference/functions/createInfiniteQueryController.md b/docs/framework/lit/reference/functions/createInfiniteQueryController.md new file mode 100644 index 00000000000..63bccc835af --- /dev/null +++ b/docs/framework/lit/reference/functions/createInfiniteQueryController.md @@ -0,0 +1,104 @@ +--- +id: createInfiniteQueryController +title: createInfiniteQueryController +--- + +# Function: createInfiniteQueryController() + +```ts +function createInfiniteQueryController( + host, + options, +queryClient?): InfiniteQueryResultAccessor; +``` + +Defined in: [packages/lit-query/src/createInfiniteQueryController.ts:364](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createInfiniteQueryController.ts#L364) + +Creates a Lit reactive controller that subscribes the host to an infinite +query. + +The returned accessor is callable and also exposes `current`, `refetch`, +`fetchNextPage`, `fetchPreviousPage`, and `destroy`. When `options` is a +function, it is re-read during host updates so query keys and options can +follow reactive host state. + +If `queryClient` is omitted, the controller resolves the client from the +nearest connected `QueryClientProvider`. + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `Error` + +### TData + +`TData` = `InfiniteData`\<`TQueryFnData`, `unknown`\> + +### TQueryKey + +`TQueryKey` *extends* readonly `unknown`[] = readonly `unknown`[] + +### TPageParam + +`TPageParam` = `unknown` + +## Parameters + +### host + +`ReactiveControllerHost` + +The Lit reactive controller host that owns the infinite query +subscription. + +### options + +[`Accessor`](../type-aliases/Accessor.md)\<[`CreateInfiniteQueryOptions`](../type-aliases/CreateInfiniteQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`, `TPageParam`\>\> + +Infinite query observer options, or a getter that returns +options. + +### queryClient? + +`QueryClient` + +Optional explicit query client. Provide this for +controllers that should not resolve a client from Lit context. + +## Returns + +[`InfiniteQueryResultAccessor`](../type-aliases/InfiniteQueryResultAccessor.md)\<`TData`, `TError`\> + +An accessor for the latest infinite query result with page helper +methods. + +## Example + +```ts +import { LitElement, html } from 'lit' +import { createInfiniteQueryController } from '@tanstack/lit-query' + +class ProjectsView extends LitElement { + private readonly projects = createInfiniteQueryController(this, { + queryKey: ['projects'], + queryFn: ({ pageParam }) => fetchProjects(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + + render() { + const query = this.projects() + + return html` + + ` + } +} +``` diff --git a/docs/framework/lit/reference/functions/createMutationController.md b/docs/framework/lit/reference/functions/createMutationController.md new file mode 100644 index 00000000000..3c870e06fe2 --- /dev/null +++ b/docs/framework/lit/reference/functions/createMutationController.md @@ -0,0 +1,96 @@ +--- +id: createMutationController +title: createMutationController +--- + +# Function: createMutationController() + +```ts +function createMutationController( + host, + options, +queryClient?): MutationResultAccessor; +``` + +Defined in: [packages/lit-query/src/createMutationController.ts:338](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createMutationController.ts#L338) + +Creates a Lit reactive controller that subscribes the host to a mutation. + +The returned accessor is callable and also exposes `current`, `mutate`, +`mutateAsync`, `reset`, and `destroy`. When `options` is a function, it is +re-read during host updates so mutation options can follow reactive host +state. + +If `queryClient` is omitted, the controller resolves the client from the +nearest connected `QueryClientProvider`. + +## Type Parameters + +### TData + +`TData` = `unknown` + +### TError + +`TError` = `Error` + +### TVariables + +`TVariables` = `void` + +### TOnMutateResult + +`TOnMutateResult` = `unknown` + +## Parameters + +### host + +`ReactiveControllerHost` + +The Lit reactive controller host that owns the mutation +subscription. + +### options + +[`Accessor`](../type-aliases/Accessor.md)\<[`CreateMutationOptions`](../type-aliases/CreateMutationOptions.md)\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\>\> + +Mutation observer options, or a getter that returns options. + +### queryClient? + +`QueryClient` + +Optional explicit query client. Provide this for +controllers that should not resolve a client from Lit context. + +## Returns + +[`MutationResultAccessor`](../type-aliases/MutationResultAccessor.md)\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\> + +An accessor for the latest mutation result with mutation helper +methods. + +## Example + +```ts +import { LitElement, html } from 'lit' +import { createMutationController } from '@tanstack/lit-query' + +class AddTodoForm extends LitElement { + private readonly addTodo = createMutationController(this, { + mutationFn: (title: string) => + fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }), + }) + + render() { + const mutation = this.addTodo() + + return html` + + ` + } +} +``` diff --git a/docs/framework/lit/reference/functions/createQueriesController.md b/docs/framework/lit/reference/functions/createQueriesController.md new file mode 100644 index 00000000000..db5e74399cb --- /dev/null +++ b/docs/framework/lit/reference/functions/createQueriesController.md @@ -0,0 +1,90 @@ +--- +id: createQueriesController +title: createQueriesController +--- + +# Function: createQueriesController() + +```ts +function createQueriesController( + host, + options, +queryClient?): QueriesResultAccessor; +``` + +Defined in: [packages/lit-query/src/createQueriesController.ts:615](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueriesController.ts#L615) + +Creates a Lit reactive controller that subscribes the host to multiple +queries. + +The returned accessor is callable and also exposes `current` and `destroy`. +When `options` or `options.queries` is a function, it is re-read during host +updates so the query list can follow reactive host state. + +If `queryClient` is omitted, the controller resolves the client from the +nearest connected `QueryClientProvider`. + +## Type Parameters + +### TQueryOptions + +`TQueryOptions` *extends* `any`[] + +### TCombinedResult + +`TCombinedResult` = `CreateQueriesResults`\<`TQueryOptions`\> + +## Parameters + +### host + +`ReactiveControllerHost` + +The Lit reactive controller host that owns the queries +subscription. + +### options + +[`Accessor`](../type-aliases/Accessor.md)\<[`CreateQueriesControllerOptions`](../type-aliases/CreateQueriesControllerOptions.md)\<`TQueryOptions`, `TCombinedResult`\>\> + +Queries controller options, or a getter that returns options. + +### queryClient? + +`QueryClient` + +Optional explicit query client. Provide this for +controllers that should not resolve a client from Lit context. + +## Returns + +[`QueriesResultAccessor`](../type-aliases/QueriesResultAccessor.md)\<`TCombinedResult`\> + +An accessor for the latest query results, or the value returned by +`combine`. + +## Example + +```ts +import { LitElement, html } from 'lit' +import { createQueriesController } from '@tanstack/lit-query' + +class DashboardView extends LitElement { + private readonly dashboard = createQueriesController(this, { + queries: [ + { queryKey: ['stats'], queryFn: fetchStats }, + { queryKey: ['projects'], queryFn: fetchProjects }, + ], + combine: ([stats, projects]) => ({ + stats: stats.data, + projects: projects.data ?? [], + isPending: stats.isPending || projects.isPending, + }), + }) + + render() { + const dashboard = this.dashboard() + return html`

Projects: ${dashboard.projects.length}

` + } +} +``` diff --git a/docs/framework/lit/reference/functions/createQueryController.md b/docs/framework/lit/reference/functions/createQueryController.md new file mode 100644 index 00000000000..11e52770bbd --- /dev/null +++ b/docs/framework/lit/reference/functions/createQueryController.md @@ -0,0 +1,97 @@ +--- +id: createQueryController +title: createQueryController +--- + +# Function: createQueryController() + +```ts +function createQueryController( + host, + options, +queryClient?): QueryResultAccessor; +``` + +Defined in: [packages/lit-query/src/createQueryController.ts:319](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueryController.ts#L319) + +Creates a Lit reactive controller that subscribes the host to a single query. + +The returned accessor is callable and also exposes `current`, `refetch`, +`suspense`, and `destroy`. When `options` is a function, it is re-read during +host updates so query keys and options can follow reactive host state. + +If `queryClient` is omitted, the controller resolves the client from the +nearest connected `QueryClientProvider`. + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `Error` + +### TData + +`TData` = `TQueryFnData` + +### TQueryData + +`TQueryData` = `TQueryFnData` + +### TQueryKey + +`TQueryKey` *extends* readonly `unknown`[] = readonly `unknown`[] + +## Parameters + +### host + +`ReactiveControllerHost` + +The Lit reactive controller host that owns the query +subscription. + +### options + +[`Accessor`](../type-aliases/Accessor.md)\<[`CreateQueryOptions`](../type-aliases/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryData`, `TQueryKey`\>\> + +Query observer options, or a getter that returns options. + +### queryClient? + +`QueryClient` + +Optional explicit query client. Provide this for +controllers that should not resolve a client from Lit context. + +## Returns + +[`QueryResultAccessor`](../type-aliases/QueryResultAccessor.md)\<`TData`, `TError`\> + +An accessor for the latest query result with query helper methods. + +## Example + +```ts +import { LitElement, html } from 'lit' +import { createQueryController } from '@tanstack/lit-query' + +class TodosView extends LitElement { + private readonly todos = createQueryController(this, { + queryKey: ['todos'], + queryFn: async () => fetch('/api/todos').then((r) => r.json()), + }) + + render() { + const query = this.todos() + + if (query.isPending) return html`Loading...` + if (query.isError) return html`Error` + + return html`
    ${query.data.map((todo) => html`
  • ${todo.title}
  • `)}
` + } +} +``` diff --git a/docs/framework/lit/reference/functions/getDefaultQueryClient.md b/docs/framework/lit/reference/functions/getDefaultQueryClient.md new file mode 100644 index 00000000000..4c9ff19db48 --- /dev/null +++ b/docs/framework/lit/reference/functions/getDefaultQueryClient.md @@ -0,0 +1,22 @@ +--- +id: getDefaultQueryClient +title: getDefaultQueryClient +--- + +# Function: getDefaultQueryClient() + +```ts +function getDefaultQueryClient(): QueryClient | undefined; +``` + +Defined in: [packages/lit-query/src/context.ts:72](https://github.com/TanStack/query/blob/main/packages/lit-query/src/context.ts#L72) + +Returns the registered default `QueryClient`, if exactly one default client is +available. + +## Returns + +`QueryClient` \| `undefined` + +The default query client, or `undefined` when there is no registered +client or more than one registered client. diff --git a/docs/framework/lit/reference/functions/infiniteQueryOptions.md b/docs/framework/lit/reference/functions/infiniteQueryOptions.md new file mode 100644 index 00000000000..3ac26702f12 --- /dev/null +++ b/docs/framework/lit/reference/functions/infiniteQueryOptions.md @@ -0,0 +1,63 @@ +--- +id: infiniteQueryOptions +title: infiniteQueryOptions +--- + +# Function: infiniteQueryOptions() + +```ts +function infiniteQueryOptions(options): InfiniteQueryObserverOptions; +``` + +Defined in: [packages/lit-query/src/infiniteQueryOptions.ts:26](https://github.com/TanStack/query/blob/main/packages/lit-query/src/infiniteQueryOptions.ts#L26) + +Preserves and types infinite query options for reuse across Lit Query APIs. + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `Error` + +### TData + +`TData` = `InfiniteData`\<`TQueryFnData`, `unknown`\> + +### TQueryKey + +`TQueryKey` *extends* readonly `unknown`[] = readonly `unknown`[] + +### TPageParam + +`TPageParam` = `unknown` + +## Parameters + +### options + +`InfiniteQueryObserverOptions`\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`, `TPageParam`\> + +Infinite query options to preserve. + +## Returns + +`InfiniteQueryObserverOptions`\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`, `TPageParam`\> + +The same options object. + +## Example + +```ts +import { infiniteQueryOptions } from '@tanstack/lit-query' + +const projectsOptions = infiniteQueryOptions({ + queryKey: ['projects'], + queryFn: ({ pageParam }) => fetchProjects(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, +}) +``` diff --git a/docs/framework/lit/reference/functions/mutationOptions.md b/docs/framework/lit/reference/functions/mutationOptions.md new file mode 100644 index 00000000000..966fb849098 --- /dev/null +++ b/docs/framework/lit/reference/functions/mutationOptions.md @@ -0,0 +1,57 @@ +--- +id: mutationOptions +title: mutationOptions +--- + +# Function: mutationOptions() + +```ts +function mutationOptions(options): MutationObserverOptions; +``` + +Defined in: [packages/lit-query/src/mutationOptions.ts:22](https://github.com/TanStack/query/blob/main/packages/lit-query/src/mutationOptions.ts#L22) + +Preserves and types mutation options for reuse across Lit Query APIs. + +## Type Parameters + +### TData + +`TData` = `unknown` + +### TError + +`TError` = `Error` + +### TVariables + +`TVariables` = `void` + +### TOnMutateResult + +`TOnMutateResult` = `unknown` + +## Parameters + +### options + +`MutationObserverOptions`\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\> + +Mutation options to preserve. + +## Returns + +`MutationObserverOptions`\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\> + +The same options object. + +## Example + +```ts +import { mutationOptions } from '@tanstack/lit-query' + +const addTodoOptions = mutationOptions({ + mutationKey: ['add-todo'], + mutationFn: (title: string) => addTodo(title), +}) +``` diff --git a/docs/framework/lit/reference/functions/queryOptions.md b/docs/framework/lit/reference/functions/queryOptions.md new file mode 100644 index 00000000000..2c43233ca26 --- /dev/null +++ b/docs/framework/lit/reference/functions/queryOptions.md @@ -0,0 +1,147 @@ +--- +id: queryOptions +title: queryOptions +--- + +# Function: queryOptions() + +## Call Signature + +```ts +function queryOptions(options): Omit, "queryFn"> & object & object; +``` + +Defined in: [packages/lit-query/src/queryOptions.ts:94](https://github.com/TanStack/query/blob/main/packages/lit-query/src/queryOptions.ts#L94) + +Brands query options so the `queryKey` carries the query function data and +error types across TanStack Query APIs. + +### Type Parameters + +#### TQueryFnData + +`TQueryFnData` = `unknown` + +#### TError + +`TError` = `Error` + +#### TData + +`TData` = `TQueryFnData` + +#### TQueryKey + +`TQueryKey` *extends* readonly `unknown`[] = readonly `unknown`[] + +### Parameters + +#### options + +[`DefinedInitialDataOptions`](../type-aliases/DefinedInitialDataOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\> + +Query options to preserve and brand. + +### Returns + +`Omit`\<`QueryObserverOptions`\<`TQueryFnData`, `TError`, `TData`, `TQueryFnData`, `TQueryKey`, `never`\>, `"queryFn"`\> & `object` & `object` + +The same options object with a typed `queryKey`. + +### Example + +```ts +import { queryOptions } from '@tanstack/lit-query' + +const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: fetchTodos, + initialData: [], +}) +``` + +## Call Signature + +```ts +function queryOptions(options): OmitKeyof, "queryFn"> & object & object; +``` + +Defined in: [packages/lit-query/src/queryOptions.ts:112](https://github.com/TanStack/query/blob/main/packages/lit-query/src/queryOptions.ts#L112) + +Brands query options so the `queryKey` carries the query function data and +error types across TanStack Query APIs. + +### Type Parameters + +#### TQueryFnData + +`TQueryFnData` = `unknown` + +#### TError + +`TError` = `Error` + +#### TData + +`TData` = `TQueryFnData` + +#### TQueryKey + +`TQueryKey` *extends* readonly `unknown`[] = readonly `unknown`[] + +### Parameters + +#### options + +[`UnusedSkipTokenOptions`](../type-aliases/UnusedSkipTokenOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\> + +Query options to preserve and brand. + +### Returns + +`OmitKeyof`\<`QueryObserverOptions`\<`TQueryFnData`, `TError`, `TData`, `TQueryFnData`, `TQueryKey`, `never`\>, `"queryFn"`\> & `object` & `object` + +The same options object with a typed `queryKey`. + +## Call Signature + +```ts +function queryOptions(options): QueryObserverOptions & object & object; +``` + +Defined in: [packages/lit-query/src/queryOptions.ts:130](https://github.com/TanStack/query/blob/main/packages/lit-query/src/queryOptions.ts#L130) + +Brands query options so the `queryKey` carries the query function data and +error types across TanStack Query APIs. + +### Type Parameters + +#### TQueryFnData + +`TQueryFnData` = `unknown` + +#### TError + +`TError` = `Error` + +#### TData + +`TData` = `TQueryFnData` + +#### TQueryKey + +`TQueryKey` *extends* readonly `unknown`[] = readonly `unknown`[] + +### Parameters + +#### options + +[`UndefinedInitialDataOptions`](../type-aliases/UndefinedInitialDataOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\> + +Query options to preserve and brand. + +### Returns + +`QueryObserverOptions`\<`TQueryFnData`, `TError`, `TData`, `TQueryFnData`, `TQueryKey`, `never`\> & `object` & `object` + +The same options object with a typed `queryKey`. diff --git a/docs/framework/lit/reference/functions/registerDefaultQueryClient.md b/docs/framework/lit/reference/functions/registerDefaultQueryClient.md new file mode 100644 index 00000000000..d3174f600ca --- /dev/null +++ b/docs/framework/lit/reference/functions/registerDefaultQueryClient.md @@ -0,0 +1,30 @@ +--- +id: registerDefaultQueryClient +title: registerDefaultQueryClient +--- + +# Function: registerDefaultQueryClient() + +```ts +function registerDefaultQueryClient(client): void; +``` + +Defined in: [packages/lit-query/src/context.ts:32](https://github.com/TanStack/query/blob/main/packages/lit-query/src/context.ts#L32) + +Registers a `QueryClient` as a process-local fallback for APIs that resolve a +client without an explicit argument. + +`QueryClientProvider` calls this automatically while it is connected. Prefer +passing an explicit client or rendering under a provider when possible. + +## Parameters + +### client + +`QueryClient` + +The query client to register as the current default. + +## Returns + +`void` diff --git a/docs/framework/lit/reference/functions/resolveQueryClient.md b/docs/framework/lit/reference/functions/resolveQueryClient.md new file mode 100644 index 00000000000..cfb0ba226ac --- /dev/null +++ b/docs/framework/lit/reference/functions/resolveQueryClient.md @@ -0,0 +1,29 @@ +--- +id: resolveQueryClient +title: resolveQueryClient +--- + +# Function: resolveQueryClient() + +```ts +function resolveQueryClient(explicit?): QueryClient; +``` + +Defined in: [packages/lit-query/src/context.ts:118](https://github.com/TanStack/query/blob/main/packages/lit-query/src/context.ts#L118) + +Resolves an explicit `QueryClient` or falls back to `useQueryClient`. + +## Parameters + +### explicit? + +`QueryClient` + +Optional client supplied by the caller. + +## Returns + +`QueryClient` + +The explicit client when provided, otherwise the current default +client. diff --git a/docs/framework/lit/reference/functions/unregisterDefaultQueryClient.md b/docs/framework/lit/reference/functions/unregisterDefaultQueryClient.md new file mode 100644 index 00000000000..8c82f497c35 --- /dev/null +++ b/docs/framework/lit/reference/functions/unregisterDefaultQueryClient.md @@ -0,0 +1,29 @@ +--- +id: unregisterDefaultQueryClient +title: unregisterDefaultQueryClient +--- + +# Function: unregisterDefaultQueryClient() + +```ts +function unregisterDefaultQueryClient(client): void; +``` + +Defined in: [packages/lit-query/src/context.ts:45](https://github.com/TanStack/query/blob/main/packages/lit-query/src/context.ts#L45) + +Unregisters a client previously registered with +`registerDefaultQueryClient`. + +`QueryClientProvider` calls this automatically when it disconnects. + +## Parameters + +### client + +`QueryClient` + +The query client registration to release. + +## Returns + +`void` diff --git a/docs/framework/lit/reference/functions/useIsFetching.md b/docs/framework/lit/reference/functions/useIsFetching.md new file mode 100644 index 00000000000..6cfd72bb4e5 --- /dev/null +++ b/docs/framework/lit/reference/functions/useIsFetching.md @@ -0,0 +1,67 @@ +--- +id: useIsFetching +title: useIsFetching +--- + +# Function: useIsFetching() + +```ts +function useIsFetching( + host, + filters, + queryClient?): IsFetchingAccessor; +``` + +Defined in: [packages/lit-query/src/useIsFetching.ts:147](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useIsFetching.ts#L147) + +Creates a Lit reactive controller that tracks how many matching queries are +currently fetching. + +When `filters` is a function, it is re-read during host updates so the count +can follow reactive host state. If `queryClient` is omitted, the controller +resolves the client from the nearest connected `QueryClientProvider`. + +## Parameters + +### host + +`ReactiveControllerHost` + +The Lit reactive controller host that owns the cache +subscription. + +### filters + +[`Accessor`](../type-aliases/Accessor.md)\<`QueryFilters`\\> = `{}` + +Query filters, or a getter that returns query filters. + +### queryClient? + +`QueryClient` + +Optional explicit query client. Provide this for +controllers that should not resolve a client from Lit context. + +## Returns + +[`IsFetchingAccessor`](../type-aliases/IsFetchingAccessor.md) + +An accessor for the current number of matching fetching queries. + +## Example + +```ts +import { LitElement, html } from 'lit' +import { useIsFetching } from '@tanstack/lit-query' + +class TodosStatus extends LitElement { + private readonly todosFetching = useIsFetching(this, { + queryKey: ['todos'], + }) + + render() { + return html`${this.todosFetching()} active todo fetches` + } +} +``` diff --git a/docs/framework/lit/reference/functions/useIsMutating.md b/docs/framework/lit/reference/functions/useIsMutating.md new file mode 100644 index 00000000000..1991ee25266 --- /dev/null +++ b/docs/framework/lit/reference/functions/useIsMutating.md @@ -0,0 +1,67 @@ +--- +id: useIsMutating +title: useIsMutating +--- + +# Function: useIsMutating() + +```ts +function useIsMutating( + host, + filters, + queryClient?): IsMutatingAccessor; +``` + +Defined in: [packages/lit-query/src/useIsMutating.ts:147](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useIsMutating.ts#L147) + +Creates a Lit reactive controller that tracks how many matching mutations are +currently pending. + +When `filters` is a function, it is re-read during host updates so the count +can follow reactive host state. If `queryClient` is omitted, the controller +resolves the client from the nearest connected `QueryClientProvider`. + +## Parameters + +### host + +`ReactiveControllerHost` + +The Lit reactive controller host that owns the cache +subscription. + +### filters + +[`Accessor`](../type-aliases/Accessor.md)\<`MutationFilters`\<`unknown`, `Error`, `unknown`, `unknown`\>\> = `{}` + +Mutation filters, or a getter that returns mutation filters. + +### queryClient? + +`QueryClient` + +Optional explicit query client. Provide this for +controllers that should not resolve a client from Lit context. + +## Returns + +[`IsMutatingAccessor`](../type-aliases/IsMutatingAccessor.md) + +An accessor for the current number of matching pending mutations. + +## Example + +```ts +import { LitElement, html } from 'lit' +import { useIsMutating } from '@tanstack/lit-query' + +class MutationStatus extends LitElement { + private readonly savesPending = useIsMutating(this, { + mutationKey: ['save-project'], + }) + + render() { + return html`${this.savesPending()} saves pending` + } +} +``` diff --git a/docs/framework/lit/reference/functions/useMutationState.md b/docs/framework/lit/reference/functions/useMutationState.md new file mode 100644 index 00000000000..f12f55d38b2 --- /dev/null +++ b/docs/framework/lit/reference/functions/useMutationState.md @@ -0,0 +1,75 @@ +--- +id: useMutationState +title: useMutationState +--- + +# Function: useMutationState() + +```ts +function useMutationState( + host, + options, +queryClient?): MutationStateAccessor; +``` + +Defined in: [packages/lit-query/src/useMutationState.ts:187](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useMutationState.ts#L187) + +Creates a Lit reactive controller that selects state from matching mutations +in the mutation cache. + +When `options.filters` is a function, it is re-read during host updates so +the selection can follow reactive host state. If `queryClient` is omitted, +the controller resolves the client from the nearest connected +`QueryClientProvider`. + +## Type Parameters + +### TResult + +`TResult` = `MutationState`\<`unknown`, `unknown`, `unknown`, `unknown`\> + +## Parameters + +### host + +`ReactiveControllerHost` + +The Lit reactive controller host that owns the mutation cache +subscription. + +### options + +[`MutationStateOptions`](../type-aliases/MutationStateOptions.md)\<`TResult`\> = `{}` + +Mutation state filters and optional selector. + +### queryClient? + +`QueryClient` + +Optional explicit query client. Provide this for +controllers that should not resolve a client from Lit context. + +## Returns + +[`MutationStateAccessor`](../type-aliases/MutationStateAccessor.md)\<`TResult`\> + +An accessor for the selected mutation state array. + +## Example + +```ts +import { LitElement, html } from 'lit' +import { useMutationState } from '@tanstack/lit-query' + +class PendingUploads extends LitElement { + private readonly uploads = useMutationState(this, { + filters: { mutationKey: ['upload'], status: 'pending' }, + select: (mutation) => mutation.state.variables as File, + }) + + render() { + return html`${this.uploads().length} uploads pending` + } +} +``` diff --git a/docs/framework/lit/reference/functions/useQueryClient.md b/docs/framework/lit/reference/functions/useQueryClient.md new file mode 100644 index 00000000000..72db893d0d5 --- /dev/null +++ b/docs/framework/lit/reference/functions/useQueryClient.md @@ -0,0 +1,25 @@ +--- +id: useQueryClient +title: useQueryClient +--- + +# Function: useQueryClient() + +```ts +function useQueryClient(): QueryClient; +``` + +Defined in: [packages/lit-query/src/context.ts:98](https://github.com/TanStack/query/blob/main/packages/lit-query/src/context.ts#L98) + +Resolves the current default `QueryClient` registered by a connected +`QueryClientProvider`. + +This helper is useful outside a Lit reactive controller when a single +provider is mounted. It throws if no client is registered or if multiple +clients are mounted and the default would be ambiguous. + +## Returns + +`QueryClient` + +The single registered query client. diff --git a/docs/framework/lit/reference/index.md b/docs/framework/lit/reference/index.md new file mode 100644 index 00000000000..77ef5851377 --- /dev/null +++ b/docs/framework/lit/reference/index.md @@ -0,0 +1,59 @@ +--- +id: "@tanstack/lit-query" +title: "@tanstack/lit-query" +--- + +# @tanstack/lit-query + +## Classes + +- [QueryClientProvider](classes/QueryClientProvider.md) + +## Type Aliases + +- [Accessor](type-aliases/Accessor.md) +- [CreateInfiniteQueryOptions](type-aliases/CreateInfiniteQueryOptions.md) +- [CreateMutationOptions](type-aliases/CreateMutationOptions.md) +- [CreateQueriesControllerOptions](type-aliases/CreateQueriesControllerOptions.md) +- [CreateQueriesInput](type-aliases/CreateQueriesInput.md) +- [CreateQueryOptions](type-aliases/CreateQueryOptions.md) +- [DefinedInitialDataOptions](type-aliases/DefinedInitialDataOptions.md) +- [InfiniteQueryControllerOptions](type-aliases/InfiniteQueryControllerOptions.md) +- [InfiniteQueryResultAccessor](type-aliases/InfiniteQueryResultAccessor.md) +- [IsFetchingAccessor](type-aliases/IsFetchingAccessor.md) +- [IsMutatingAccessor](type-aliases/IsMutatingAccessor.md) +- [MutationControllerOptions](type-aliases/MutationControllerOptions.md) +- [MutationControllerResult](type-aliases/MutationControllerResult.md) +- [MutationResultAccessor](type-aliases/MutationResultAccessor.md) +- [MutationStateAccessor](type-aliases/MutationStateAccessor.md) +- [MutationStateOptions](type-aliases/MutationStateOptions.md) +- [QueriesControllerOptions](type-aliases/QueriesControllerOptions.md) +- [QueriesResultAccessor](type-aliases/QueriesResultAccessor.md) +- [QueryControllerOptions](type-aliases/QueryControllerOptions.md) +- [QueryControllerResult](type-aliases/QueryControllerResult.md) +- [QueryResultAccessor](type-aliases/QueryResultAccessor.md) +- [UndefinedInitialDataOptions](type-aliases/UndefinedInitialDataOptions.md) +- [UnusedSkipTokenOptions](type-aliases/UnusedSkipTokenOptions.md) +- [ValueAccessor](type-aliases/ValueAccessor.md) + +## Variables + +- [queryClientContext](variables/queryClientContext.md) + +## Functions + +- [createInfiniteQueryController](functions/createInfiniteQueryController.md) +- [createMutationController](functions/createMutationController.md) +- [createQueriesController](functions/createQueriesController.md) +- [createQueryController](functions/createQueryController.md) +- [getDefaultQueryClient](functions/getDefaultQueryClient.md) +- [infiniteQueryOptions](functions/infiniteQueryOptions.md) +- [mutationOptions](functions/mutationOptions.md) +- [queryOptions](functions/queryOptions.md) +- [registerDefaultQueryClient](functions/registerDefaultQueryClient.md) +- [resolveQueryClient](functions/resolveQueryClient.md) +- [unregisterDefaultQueryClient](functions/unregisterDefaultQueryClient.md) +- [useIsFetching](functions/useIsFetching.md) +- [useIsMutating](functions/useIsMutating.md) +- [useMutationState](functions/useMutationState.md) +- [useQueryClient](functions/useQueryClient.md) diff --git a/docs/framework/lit/reference/type-aliases/Accessor.md b/docs/framework/lit/reference/type-aliases/Accessor.md new file mode 100644 index 00000000000..95d77dee47a --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/Accessor.md @@ -0,0 +1,30 @@ +--- +id: Accessor +title: Accessor +--- + +# Type Alias: Accessor\ + +```ts +type Accessor = T | () => T; +``` + +Defined in: [packages/lit-query/src/accessor.ts:13](https://github.com/TanStack/query/blob/main/packages/lit-query/src/accessor.ts#L13) + +A value that can be passed directly or read from a zero-argument getter. + +Lit Query APIs read function accessors during host updates, so the getter can +depend on reactive host state. + +## Type Parameters + +### T + +`T` + +## Example + +```ts +const staticKey: Accessor = ['todos'] +const reactiveKey: Accessor = () => ['todos', this.userId] +``` diff --git a/docs/framework/lit/reference/type-aliases/CreateInfiniteQueryOptions.md b/docs/framework/lit/reference/type-aliases/CreateInfiniteQueryOptions.md new file mode 100644 index 00000000000..ba09bb1126c --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/CreateInfiniteQueryOptions.md @@ -0,0 +1,39 @@ +--- +id: CreateInfiniteQueryOptions +title: CreateInfiniteQueryOptions +--- + +# Type Alias: CreateInfiniteQueryOptions\ + +```ts +type CreateInfiniteQueryOptions = InfiniteQueryObserverOptions; +``` + +Defined in: [packages/lit-query/src/createInfiniteQueryController.ts:27](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createInfiniteQueryController.ts#L27) + +Options accepted by `createInfiniteQueryController`. + +This is the Lit adapter shape for `InfiniteQueryObserverOptions`. Pass it +directly or through an `Accessor` when the options depend on Lit host state. + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `InfiniteData`\<`TQueryFnData`\> + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` + +### TPageParam + +`TPageParam` = `unknown` diff --git a/docs/framework/lit/reference/type-aliases/CreateMutationOptions.md b/docs/framework/lit/reference/type-aliases/CreateMutationOptions.md new file mode 100644 index 00000000000..94d611682b5 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/CreateMutationOptions.md @@ -0,0 +1,35 @@ +--- +id: CreateMutationOptions +title: CreateMutationOptions +--- + +# Type Alias: CreateMutationOptions\ + +```ts +type CreateMutationOptions = MutationObserverOptions; +``` + +Defined in: [packages/lit-query/src/createMutationController.ts:25](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createMutationController.ts#L25) + +Options accepted by `createMutationController`. + +This is the Lit adapter shape for `MutationObserverOptions`. Pass it directly +or through an `Accessor` when the options depend on Lit host state. + +## Type Parameters + +### TData + +`TData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TVariables + +`TVariables` = `void` + +### TOnMutateResult + +`TOnMutateResult` = `unknown` diff --git a/docs/framework/lit/reference/type-aliases/CreateQueriesControllerOptions.md b/docs/framework/lit/reference/type-aliases/CreateQueriesControllerOptions.md new file mode 100644 index 00000000000..1f69e15b84a --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/CreateQueriesControllerOptions.md @@ -0,0 +1,64 @@ +--- +id: CreateQueriesControllerOptions +title: CreateQueriesControllerOptions +--- + +# Type Alias: CreateQueriesControllerOptions\ + +```ts +type CreateQueriesControllerOptions = object; +``` + +Defined in: [packages/lit-query/src/createQueriesController.ts:194](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueriesController.ts#L194) + +Options accepted by `createQueriesController`. + +`queries` can be a static list or a getter that returns the current list. +`combine` can reshape the array of query results into a single value for the +returned accessor. + +## Type Parameters + +### TQueryOptions + +`TQueryOptions` *extends* `any`[] = `any`[] + +### TCombinedResult + +`TCombinedResult` = `CreateQueriesResults`\<`TQueryOptions`\> + +## Properties + +### combine()? + +```ts +optional combine: (result) => TCombinedResult; +``` + +Defined in: [packages/lit-query/src/createQueriesController.ts:208](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueriesController.ts#L208) + +Optional function that combines the query result array into one value. + +#### Parameters + +##### result + +`CreateQueriesResults`\<`TQueryOptions`\> + +#### Returns + +`TCombinedResult` + +*** + +### queries + +```ts +queries: Accessor< + | readonly [...CreateQueriesOptions] +| readonly [...{ [K in keyof TQueryOptions]: GetCreateQueriesInput }]>; +``` + +Defined in: [packages/lit-query/src/createQueriesController.ts:199](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueriesController.ts#L199) + +Query options to observe, or a getter that returns the current options. diff --git a/docs/framework/lit/reference/type-aliases/CreateQueriesInput.md b/docs/framework/lit/reference/type-aliases/CreateQueriesInput.md new file mode 100644 index 00000000000..0f698f79617 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/CreateQueriesInput.md @@ -0,0 +1,35 @@ +--- +id: CreateQueriesInput +title: CreateQueriesInput +--- + +# Type Alias: CreateQueriesInput\ + +```ts +type CreateQueriesInput = QueryObserverOptions; +``` + +Defined in: [packages/lit-query/src/createQueriesController.ts:30](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueriesController.ts#L30) + +Options for one query inside `createQueriesController`. + +This mirrors `QueryObserverOptions` and is used by the tuple inference that +maps each input query to its corresponding result. + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `TQueryFnData` + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` diff --git a/docs/framework/lit/reference/type-aliases/CreateQueryOptions.md b/docs/framework/lit/reference/type-aliases/CreateQueryOptions.md new file mode 100644 index 00000000000..a967e779ac8 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/CreateQueryOptions.md @@ -0,0 +1,40 @@ +--- +id: CreateQueryOptions +title: CreateQueryOptions +--- + +# Type Alias: CreateQueryOptions\ + +```ts +type CreateQueryOptions = QueryObserverOptions; +``` + +Defined in: [packages/lit-query/src/createQueryController.ts:27](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueryController.ts#L27) + +Options accepted by `createQueryController`. + +This is the Lit adapter shape for `QueryObserverOptions`. It can be passed +directly to `createQueryController`, or wrapped in an `Accessor` when the +options depend on Lit host state. + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `TQueryFnData` + +### TQueryData + +`TQueryData` = `TQueryFnData` + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` diff --git a/docs/framework/lit/reference/type-aliases/DefinedInitialDataOptions.md b/docs/framework/lit/reference/type-aliases/DefinedInitialDataOptions.md new file mode 100644 index 00000000000..b369207dbb3 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/DefinedInitialDataOptions.md @@ -0,0 +1,48 @@ +--- +id: DefinedInitialDataOptions +title: DefinedInitialDataOptions +--- + +# Type Alias: DefinedInitialDataOptions\ + +```ts +type DefinedInitialDataOptions = Omit, "queryFn"> & object; +``` + +Defined in: [packages/lit-query/src/queryOptions.ts:16](https://github.com/TanStack/query/blob/main/packages/lit-query/src/queryOptions.ts#L16) + +Query options with `initialData` that guarantees defined query data. + +## Type Declaration + +### initialData + +```ts +initialData: + | NonUndefinedGuard +| () => NonUndefinedGuard; +``` + +### queryFn? + +```ts +optional queryFn: QueryFunction; +``` + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `TQueryFnData` + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` diff --git a/docs/framework/lit/reference/type-aliases/InfiniteQueryControllerOptions.md b/docs/framework/lit/reference/type-aliases/InfiniteQueryControllerOptions.md new file mode 100644 index 00000000000..f2b6bbe0b58 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/InfiniteQueryControllerOptions.md @@ -0,0 +1,36 @@ +--- +id: InfiniteQueryControllerOptions +title: InfiniteQueryControllerOptions +--- + +# Type Alias: InfiniteQueryControllerOptions\ + +```ts +type InfiniteQueryControllerOptions = Accessor>; +``` + +Defined in: [packages/lit-query/src/types.ts:41](https://github.com/TanStack/query/blob/main/packages/lit-query/src/types.ts#L41) + +Accessor-wrapped options accepted by `createInfiniteQueryController`. + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `InfiniteData`\<`TQueryFnData`\> + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` + +### TPageParam + +`TPageParam` = `unknown` diff --git a/docs/framework/lit/reference/type-aliases/InfiniteQueryResultAccessor.md b/docs/framework/lit/reference/type-aliases/InfiniteQueryResultAccessor.md new file mode 100644 index 00000000000..a45900de25f --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/InfiniteQueryResultAccessor.md @@ -0,0 +1,66 @@ +--- +id: InfiniteQueryResultAccessor +title: InfiniteQueryResultAccessor +--- + +# Type Alias: InfiniteQueryResultAccessor\ + +```ts +type InfiniteQueryResultAccessor = ValueAccessor> & object; +``` + +Defined in: [packages/lit-query/src/createInfiniteQueryController.ts:48](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createInfiniteQueryController.ts#L48) + +Accessor returned by `createInfiniteQueryController`. + +Call the accessor or read its `current` property to get the latest infinite +query result. The attached methods delegate to the active infinite query +observer. + +## Type Declaration + +### destroy() + +```ts +destroy: () => void; +``` + +Removes the controller from its Lit host and unsubscribes observers. + +#### Returns + +`void` + +### fetchNextPage + +```ts +fetchNextPage: InfiniteQueryObserverResult["fetchNextPage"]; +``` + +Fetches the next page for the current infinite query. + +### fetchPreviousPage + +```ts +fetchPreviousPage: InfiniteQueryObserverResult["fetchPreviousPage"]; +``` + +Fetches the previous page for the current infinite query. + +### refetch + +```ts +refetch: InfiniteQueryObserverResult["refetch"]; +``` + +Refetches the current infinite query. + +## Type Parameters + +### TData + +`TData` + +### TError + +`TError` diff --git a/docs/framework/lit/reference/type-aliases/IsFetchingAccessor.md b/docs/framework/lit/reference/type-aliases/IsFetchingAccessor.md new file mode 100644 index 00000000000..99c09c43502 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/IsFetchingAccessor.md @@ -0,0 +1,29 @@ +--- +id: IsFetchingAccessor +title: IsFetchingAccessor +--- + +# Type Alias: IsFetchingAccessor + +```ts +type IsFetchingAccessor = ValueAccessor & object; +``` + +Defined in: [packages/lit-query/src/useIsFetching.ts:17](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useIsFetching.ts#L17) + +Accessor returned by `useIsFetching`. + +Call the accessor or read its `current` property to get the number of +currently fetching queries that match the filters. + +## Type Declaration + +### destroy() + +```ts +destroy: () => void; +``` + +#### Returns + +`void` diff --git a/docs/framework/lit/reference/type-aliases/IsMutatingAccessor.md b/docs/framework/lit/reference/type-aliases/IsMutatingAccessor.md new file mode 100644 index 00000000000..1f0ff1194f7 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/IsMutatingAccessor.md @@ -0,0 +1,29 @@ +--- +id: IsMutatingAccessor +title: IsMutatingAccessor +--- + +# Type Alias: IsMutatingAccessor + +```ts +type IsMutatingAccessor = ValueAccessor & object; +``` + +Defined in: [packages/lit-query/src/useIsMutating.ts:17](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useIsMutating.ts#L17) + +Accessor returned by `useIsMutating`. + +Call the accessor or read its `current` property to get the number of +currently pending mutations that match the filters. + +## Type Declaration + +### destroy() + +```ts +destroy: () => void; +``` + +#### Returns + +`void` diff --git a/docs/framework/lit/reference/type-aliases/MutationControllerOptions.md b/docs/framework/lit/reference/type-aliases/MutationControllerOptions.md new file mode 100644 index 00000000000..6482d82a13a --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/MutationControllerOptions.md @@ -0,0 +1,32 @@ +--- +id: MutationControllerOptions +title: MutationControllerOptions +--- + +# Type Alias: MutationControllerOptions\ + +```ts +type MutationControllerOptions = Accessor>; +``` + +Defined in: [packages/lit-query/src/types.ts:54](https://github.com/TanStack/query/blob/main/packages/lit-query/src/types.ts#L54) + +Accessor-wrapped options accepted by `createMutationController`. + +## Type Parameters + +### TData + +`TData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TVariables + +`TVariables` = `void` + +### TOnMutateResult + +`TOnMutateResult` = `unknown` diff --git a/docs/framework/lit/reference/type-aliases/MutationControllerResult.md b/docs/framework/lit/reference/type-aliases/MutationControllerResult.md new file mode 100644 index 00000000000..8b63b098fba --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/MutationControllerResult.md @@ -0,0 +1,32 @@ +--- +id: MutationControllerResult +title: MutationControllerResult +--- + +# Type Alias: MutationControllerResult\ + +```ts +type MutationControllerResult = MutationObserverResult; +``` + +Defined in: [packages/lit-query/src/types.ts:64](https://github.com/TanStack/query/blob/main/packages/lit-query/src/types.ts#L64) + +Result object produced by a Lit mutation controller. + +## Type Parameters + +### TData + +`TData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TVariables + +`TVariables` = `void` + +### TOnMutateResult + +`TOnMutateResult` = `unknown` diff --git a/docs/framework/lit/reference/type-aliases/MutationResultAccessor.md b/docs/framework/lit/reference/type-aliases/MutationResultAccessor.md new file mode 100644 index 00000000000..6846aec42aa --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/MutationResultAccessor.md @@ -0,0 +1,91 @@ +--- +id: MutationResultAccessor +title: MutationResultAccessor +--- + +# Type Alias: MutationResultAccessor\ + +```ts +type MutationResultAccessor = ValueAccessor> & object; +``` + +Defined in: [packages/lit-query/src/createMutationController.ts:38](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createMutationController.ts#L38) + +Accessor returned by `createMutationController`. + +Call the accessor or read its `current` property to get the latest mutation +result. The attached methods delegate to the active mutation observer. + +## Type Declaration + +### destroy() + +```ts +destroy: () => void; +``` + +Removes the controller from its Lit host and unsubscribes observers. + +#### Returns + +`void` + +### mutate() + +```ts +mutate: (variables, options?) => void; +``` + +Starts the mutation and swallows the returned promise. + +Throws synchronously if no `QueryClient` can be resolved. + +#### Parameters + +##### variables + +`TVariables` + +##### options? + +`MutateOptions`\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\> + +#### Returns + +`void` + +### mutateAsync + +```ts +mutateAsync: MutationObserverResult["mutate"]; +``` + +Starts the mutation and returns the observer promise. + +Rejects if no `QueryClient` can be resolved. + +### reset + +```ts +reset: MutationObserverResult["reset"]; +``` + +Resets the mutation observer to its idle state. + +## Type Parameters + +### TData + +`TData` + +### TError + +`TError` + +### TVariables + +`TVariables` + +### TOnMutateResult + +`TOnMutateResult` diff --git a/docs/framework/lit/reference/type-aliases/MutationStateAccessor.md b/docs/framework/lit/reference/type-aliases/MutationStateAccessor.md new file mode 100644 index 00000000000..6c9f9298caa --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/MutationStateAccessor.md @@ -0,0 +1,37 @@ +--- +id: MutationStateAccessor +title: MutationStateAccessor +--- + +# Type Alias: MutationStateAccessor\ + +```ts +type MutationStateAccessor = ValueAccessor & object; +``` + +Defined in: [packages/lit-query/src/useMutationState.ts:32](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useMutationState.ts#L32) + +Accessor returned by `useMutationState`. + +Call the accessor or read its `current` property to get the selected state for +matching mutations. + +## Type Declaration + +### destroy() + +```ts +destroy: () => void; +``` + +Removes the controller from its Lit host and unsubscribes observers. + +#### Returns + +`void` + +## Type Parameters + +### TResult + +`TResult` diff --git a/docs/framework/lit/reference/type-aliases/MutationStateOptions.md b/docs/framework/lit/reference/type-aliases/MutationStateOptions.md new file mode 100644 index 00000000000..7ae3228ea66 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/MutationStateOptions.md @@ -0,0 +1,54 @@ +--- +id: MutationStateOptions +title: MutationStateOptions +--- + +# Type Alias: MutationStateOptions\ + +```ts +type MutationStateOptions = object; +``` + +Defined in: [packages/lit-query/src/useMutationState.ts:19](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useMutationState.ts#L19) + +Options accepted by `useMutationState`. + +## Type Parameters + +### TResult + +`TResult` + +## Properties + +### filters? + +```ts +optional filters: Accessor; +``` + +Defined in: [packages/lit-query/src/useMutationState.ts:21](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useMutationState.ts#L21) + +Filters used to select mutations from the mutation cache. + +*** + +### select()? + +```ts +optional select: (mutation) => TResult; +``` + +Defined in: [packages/lit-query/src/useMutationState.ts:23](https://github.com/TanStack/query/blob/main/packages/lit-query/src/useMutationState.ts#L23) + +Maps each matching mutation to the value returned by the accessor. + +#### Parameters + +##### mutation + +`Mutation` + +#### Returns + +`TResult` diff --git a/docs/framework/lit/reference/type-aliases/QueriesControllerOptions.md b/docs/framework/lit/reference/type-aliases/QueriesControllerOptions.md new file mode 100644 index 00000000000..70ef5a77f2b --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/QueriesControllerOptions.md @@ -0,0 +1,24 @@ +--- +id: QueriesControllerOptions +title: QueriesControllerOptions +--- + +# Type Alias: QueriesControllerOptions\ + +```ts +type QueriesControllerOptions = Accessor>; +``` + +Defined in: [packages/lit-query/src/types.ts:74](https://github.com/TanStack/query/blob/main/packages/lit-query/src/types.ts#L74) + +Accessor-wrapped options accepted by `createQueriesController`. + +## Type Parameters + +### TQueryOptions + +`TQueryOptions` *extends* `any`[] = `any`[] + +### TCombinedResult + +`TCombinedResult` = `CreateQueriesResults`\<`TQueryOptions`\> diff --git a/docs/framework/lit/reference/type-aliases/QueriesResultAccessor.md b/docs/framework/lit/reference/type-aliases/QueriesResultAccessor.md new file mode 100644 index 00000000000..c780231ee2f --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/QueriesResultAccessor.md @@ -0,0 +1,37 @@ +--- +id: QueriesResultAccessor +title: QueriesResultAccessor +--- + +# Type Alias: QueriesResultAccessor\ + +```ts +type QueriesResultAccessor = ValueAccessor & object; +``` + +Defined in: [packages/lit-query/src/createQueriesController.ts:217](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueriesController.ts#L217) + +Accessor returned by `createQueriesController`. + +Call the accessor or read its `current` property to get the latest combined +value. + +## Type Declaration + +### destroy() + +```ts +destroy: () => void; +``` + +Removes the controller from its Lit host and unsubscribes observers. + +#### Returns + +`void` + +## Type Parameters + +### TCombinedResult + +`TCombinedResult` diff --git a/docs/framework/lit/reference/type-aliases/QueryControllerOptions.md b/docs/framework/lit/reference/type-aliases/QueryControllerOptions.md new file mode 100644 index 00000000000..104d98c5fb9 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/QueryControllerOptions.md @@ -0,0 +1,36 @@ +--- +id: QueryControllerOptions +title: QueryControllerOptions +--- + +# Type Alias: QueryControllerOptions\ + +```ts +type QueryControllerOptions = Accessor>; +``` + +Defined in: [packages/lit-query/src/types.ts:20](https://github.com/TanStack/query/blob/main/packages/lit-query/src/types.ts#L20) + +Accessor-wrapped options accepted by `createQueryController`. + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `TQueryFnData` + +### TQueryData + +`TQueryData` = `TQueryFnData` + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` diff --git a/docs/framework/lit/reference/type-aliases/QueryControllerResult.md b/docs/framework/lit/reference/type-aliases/QueryControllerResult.md new file mode 100644 index 00000000000..853212d0536 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/QueryControllerResult.md @@ -0,0 +1,24 @@ +--- +id: QueryControllerResult +title: QueryControllerResult +--- + +# Type Alias: QueryControllerResult\ + +```ts +type QueryControllerResult = QueryObserverResult; +``` + +Defined in: [packages/lit-query/src/types.ts:33](https://github.com/TanStack/query/blob/main/packages/lit-query/src/types.ts#L33) + +Result object produced by a Lit query controller. + +## Type Parameters + +### TData + +`TData` = `unknown` + +### TError + +`TError` = `DefaultError` diff --git a/docs/framework/lit/reference/type-aliases/QueryResultAccessor.md b/docs/framework/lit/reference/type-aliases/QueryResultAccessor.md new file mode 100644 index 00000000000..ce806098556 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/QueryResultAccessor.md @@ -0,0 +1,61 @@ +--- +id: QueryResultAccessor +title: QueryResultAccessor +--- + +# Type Alias: QueryResultAccessor\ + +```ts +type QueryResultAccessor = ValueAccessor> & object; +``` + +Defined in: [packages/lit-query/src/createQueryController.ts:41](https://github.com/TanStack/query/blob/main/packages/lit-query/src/createQueryController.ts#L41) + +Accessor returned by `createQueryController`. + +Call the accessor or read its `current` property to get the latest query +result. The attached methods delegate to the active query observer. + +## Type Declaration + +### destroy() + +```ts +destroy: () => void; +``` + +Removes the controller from its Lit host and unsubscribes observers. + +#### Returns + +`void` + +### refetch + +```ts +refetch: QueryObserverResult["refetch"]; +``` + +Refetches the current query. + +### suspense() + +```ts +suspense: () => Promise>; +``` + +Resolves with an optimistic query result, fetching first when needed. + +#### Returns + +`Promise`\<`QueryObserverResult`\<`TData`, `TError`\>\> + +## Type Parameters + +### TData + +`TData` + +### TError + +`TError` diff --git a/docs/framework/lit/reference/type-aliases/UndefinedInitialDataOptions.md b/docs/framework/lit/reference/type-aliases/UndefinedInitialDataOptions.md new file mode 100644 index 00000000000..75aa69f4139 --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/UndefinedInitialDataOptions.md @@ -0,0 +1,42 @@ +--- +id: UndefinedInitialDataOptions +title: UndefinedInitialDataOptions +--- + +# Type Alias: UndefinedInitialDataOptions\ + +```ts +type UndefinedInitialDataOptions = QueryObserverOptions & object; +``` + +Defined in: [packages/lit-query/src/queryOptions.ts:58](https://github.com/TanStack/query/blob/main/packages/lit-query/src/queryOptions.ts#L58) + +Query options where `initialData` can be omitted or undefined. + +## Type Declaration + +### initialData? + +```ts +optional initialData: + | InitialDataFunction> +| NonUndefinedGuard; +``` + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `TQueryFnData` + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` diff --git a/docs/framework/lit/reference/type-aliases/UnusedSkipTokenOptions.md b/docs/framework/lit/reference/type-aliases/UnusedSkipTokenOptions.md new file mode 100644 index 00000000000..53acb8d9f5c --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/UnusedSkipTokenOptions.md @@ -0,0 +1,40 @@ +--- +id: UnusedSkipTokenOptions +title: UnusedSkipTokenOptions +--- + +# Type Alias: UnusedSkipTokenOptions\ + +```ts +type UnusedSkipTokenOptions = OmitKeyof, "queryFn"> & object; +``` + +Defined in: [packages/lit-query/src/queryOptions.ts:34](https://github.com/TanStack/query/blob/main/packages/lit-query/src/queryOptions.ts#L34) + +Query options where `queryFn` is present and not a `skipToken`. + +## Type Declaration + +### queryFn? + +```ts +optional queryFn: Exclude["queryFn"], SkipToken | undefined>; +``` + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `TQueryFnData` + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` diff --git a/docs/framework/lit/reference/type-aliases/ValueAccessor.md b/docs/framework/lit/reference/type-aliases/ValueAccessor.md new file mode 100644 index 00000000000..642c42f37da --- /dev/null +++ b/docs/framework/lit/reference/type-aliases/ValueAccessor.md @@ -0,0 +1,39 @@ +--- +id: ValueAccessor +title: ValueAccessor +--- + +# Type Alias: ValueAccessor\ + +```ts +type ValueAccessor = () => T & object; +``` + +Defined in: [packages/lit-query/src/accessor.ts:32](https://github.com/TanStack/query/blob/main/packages/lit-query/src/accessor.ts#L32) + +A callable accessor with a `current` property for reading the latest +controller result. + +Controller creators and cache state helpers return this shape so render code +can use either `result()` or `result.current`. + +## Type Declaration + +### current + +```ts +readonly current: T; +``` + +## Type Parameters + +### T + +`T` + +## Example + +```ts +const query = this.todos() +const sameQuery = this.todos.current +``` diff --git a/docs/framework/lit/reference/variables/queryClientContext.md b/docs/framework/lit/reference/variables/queryClientContext.md new file mode 100644 index 00000000000..56395991d49 --- /dev/null +++ b/docs/framework/lit/reference/variables/queryClientContext.md @@ -0,0 +1,18 @@ +--- +id: queryClientContext +title: queryClientContext +--- + +# Variable: queryClientContext + +```ts +const queryClientContext: object; +``` + +Defined in: [packages/lit-query/src/context.ts:11](https://github.com/TanStack/query/blob/main/packages/lit-query/src/context.ts#L11) + +Lit context key used by `QueryClientProvider` and host-bound APIs to share a +`QueryClient` through the DOM tree. + +Most applications use `QueryClientProvider` instead of interacting with this +context directly. diff --git a/docs/framework/lit/typescript.md b/docs/framework/lit/typescript.md new file mode 100644 index 00000000000..6a59b09b1d7 --- /dev/null +++ b/docs/framework/lit/typescript.md @@ -0,0 +1,132 @@ +--- +id: typescript +title: TypeScript +--- + +Lit Query is written in TypeScript and reuses TanStack Query Core's type system. The most important rule is the same as every other adapter: give your query and mutation functions well-defined return types, and the result accessors will infer from them. + +## Query Inference + +```ts +import { LitElement } from 'lit' +import { createQueryController } from '@tanstack/lit-query' + +type Todo = { + id: number + title: string +} + +async function fetchTodos(): Promise { + const response = await fetch('/api/todos') + if (!response.ok) throw new Error('Failed to fetch todos') + return response.json() as Promise +} + +class TodosView extends LitElement { + private readonly todos = createQueryController(this, { + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + render() { + const query = this.todos() + // query.data is Todo[] | undefined until success is known. + } +} +``` + +Checking `isSuccess`, `isPending`, `isError`, or `status` narrows the result just like TanStack Query Core result types: + +```ts +const query = this.todos() + +if (query.isSuccess) { + query.data + // Todo[] +} +``` + +## Mutation Inference + +```ts +import { LitElement } from 'lit' +import { createMutationController } from '@tanstack/lit-query' + +type CreateTodoInput = { + title: string +} + +type Todo = { + id: number + title: string +} + +async function addTodo(input: CreateTodoInput): Promise { + const response = await fetch('/api/todos', { + method: 'POST', + body: JSON.stringify(input), + }) + if (!response.ok) throw new Error('Failed to create todo') + return response.json() as Promise +} + +class AddTodoButton extends LitElement { + private readonly mutation = createMutationController(this, { + mutationFn: addTodo, + }) + + private add() { + this.mutation.mutate({ title: 'Learn Lit Query' }) + } +} +``` + +## Extracting Options + +Use [`queryOptions`](./reference/functions/queryOptions.md), [`infiniteQueryOptions`](./reference/functions/infiniteQueryOptions.md), and [`mutationOptions`](./reference/functions/mutationOptions.md) when you want to share typed options between controllers and `QueryClient` calls. + +```ts +import { LitElement } from 'lit' +import { + QueryClient, + createQueryController, + queryOptions, +} from '@tanstack/lit-query' + +function todosOptions() { + return queryOptions({ + queryKey: ['todos'], + queryFn: fetchTodos, + staleTime: 5_000, + }) +} + +const queryClient = new QueryClient() + +class TodosView extends LitElement { + private readonly todos = createQueryController(this, todosOptions()) +} + +void queryClient.prefetchQuery(todosOptions()) +``` + +The branded `queryKey` returned from `queryOptions` also helps APIs like `queryClient.getQueryData` understand the data type. + +## Global Register Types + +Because `@tanstack/lit-query` re-exports TanStack Query Core, module augmentation is written against `@tanstack/lit-query` in Lit apps: + +```ts +import '@tanstack/lit-query' + +type AppQueryKey = ['todos' | 'projects', ...ReadonlyArray] + +declare module '@tanstack/lit-query' { + interface Register { + queryKey: AppQueryKey + mutationKey: AppQueryKey + } +} +``` + +See the generated reference for Lit-specific [option and result types](./reference/index.md). diff --git a/docs/framework/preact/devtools.md b/docs/framework/preact/devtools.md index 4ea851d0fa6..4fc60265dc4 100644 --- a/docs/framework/preact/devtools.md +++ b/docs/framework/preact/devtools.md @@ -70,18 +70,68 @@ function App() { - `initialIsOpen: boolean` - Set this `true` if you want the dev tools to default to being open -- `buttonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right"` +- `buttonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right" | "relative"` - Defaults to `bottom-right` - - The position of the Preact Query logo to open and close the devtools panel + - The position of the TanStack logo to open and close the devtools panel - `position?: "top" | "bottom" | "left" | "right"` - Defaults to `bottom` - The position of the Preact Query devtools panel - `client?: QueryClient`, - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. -- `errorTypes?: { name: string; initializer: (query: Query) => TError}` +- `errorTypes?: { name: string; initializer: (query: Query) => TError}[]` - Use this to predefine some errors that can be triggered on your queries. Initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. - `styleNonce?: string` - Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. - `shadowDOMTarget?: ShadowRoot` - Default behavior will apply the devtool's styles to the head tag within the DOM. - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. +- `theme?: "light" | "dark" | "system"` + - Defaults to `system`. + - Set this to change the theme of the devtools panel. + +## Embedded Mode + +Embedded mode will show the development tools as a fixed element in your application, so you can use our panel in your own development tools. + +Place the following code as high in your Preact app as you can. The closer it is to the root of the page, the better it will work! + +```tsx +import { useState } from 'preact/hooks' +import { PreactQueryDevtoolsPanel } from '@tanstack/preact-query-devtools' + +function App() { + const [isOpen, setIsOpen] = useState(false) + + return ( + + {/* The rest of your application */} + + {isOpen && setIsOpen(false)} />} + + ) +} +``` + +### Options + +- `style?: CSSProperties` + - Custom styles for the devtools panel + - Default: `{ height: '500px' }` + - Example: `{ height: '100%' }` + - Example: `{ height: '100%', width: '100%' }` +- `onClose?: () => void` + - Callback function that is called when the devtools panel is closed +- `client?: QueryClient`, + - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. +- `errorTypes?: { name: string; initializer: (query: Query) => TError}[]` + - Use this to predefine some errors that can be triggered on your queries. Initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. +- `styleNonce?: string` + - Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. +- `shadowDOMTarget?: ShadowRoot` + - Default behavior will apply the devtool's styles to the head tag within the DOM. + - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. +- `theme?: "light" | "dark" | "system"` + - Defaults to `system`. + - Set this to change the theme of the devtools panel. diff --git a/docs/framework/react/devtools.md b/docs/framework/react/devtools.md index b1d49d37fdc..ccdde019087 100644 --- a/docs/framework/react/devtools.md +++ b/docs/framework/react/devtools.md @@ -78,7 +78,7 @@ function App() { - Set this `true` if you want the dev tools to default to being open - `buttonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right" | "relative"` - Defaults to `bottom-right` - - The position of the React Query logo to open and close the devtools panel + - The position of the TanStack logo to open and close the devtools panel - If `relative`, the button is placed in the location that you render the devtools. - `position?: "top" | "bottom" | "left" | "right"` - Defaults to `bottom` @@ -92,6 +92,9 @@ function App() { - `shadowDOMTarget?: ShadowRoot` - Default behavior will apply the devtool's styles to the head tag within the DOM. - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. +- `theme?: "light" | "dark" | "system"` + - Defaults to `system`. + - Set this to change the theme of the devtools panel. ## Embedded Mode @@ -124,7 +127,7 @@ function App() { - Default: `{ height: '500px' }` - Example: `{ height: '100%' }` - Example: `{ height: '100%', width: '100%' }` -- `onClose?: () => unknown` +- `onClose?: () => void` - Callback function that is called when the devtools panel is closed - `client?: QueryClient`, - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. @@ -135,6 +138,9 @@ function App() { - `shadowDOMTarget?: ShadowRoot` - Default behavior will apply the devtool's styles to the head tag within the DOM. - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. +- `theme?: "light" | "dark" | "system"` + - Defaults to `system`. + - Set this to change the theme of the devtools panel. ## Devtools in production diff --git a/docs/framework/react/guides/advanced-ssr.md b/docs/framework/react/guides/advanced-ssr.md index 3e1fdedff21..e0fc37544c7 100644 --- a/docs/framework/react/guides/advanced-ssr.md +++ b/docs/framework/react/guides/advanced-ssr.md @@ -31,7 +31,7 @@ The first step of any React Query setup is always to create a `queryClient` and // Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top import { - isServer, + environmentManager, QueryClient, QueryClientProvider, } from '@tanstack/react-query' @@ -51,7 +51,7 @@ function makeQueryClient() { let browserQueryClient: QueryClient | undefined = undefined function getQueryClient() { - if (isServer) { + if (environmentManager.isServer()) { // Server: always make a new query client return makeQueryClient() } else { @@ -216,9 +216,9 @@ One neat thing about the examples above is that the only thing that is Next.js-s In the SSR guide, we noted that you could get rid of the boilerplate of having `` in every route. This is not possible with Server Components. -> NOTE: If you encounter a type error while using async Server Components with TypeScript versions lower than `5.1.3` and `@types/react` versions lower than `18.2.8`, it is recommended to update to the latest versions of both. Alternatively, you can use the temporary workaround of adding `{/* @ts-expect-error Server Component */}` when calling this component inside another. For more information, see [Async Server Component TypeScript Error](https://nextjs.org/docs/app/building-your-application/configuring/typescript#async-server-component-typescript-error) in the Next.js 13 docs. +> NOTE: If you encounter a type error while using async Server Components with TypeScript versions lower than `5.1.3` and `@types/react` versions lower than `18.2.8`, it is recommended to update to the latest versions of both. Alternatively, you can use the temporary workaround of adding `{/* @ts-expect-error Server Component */}` when calling this component inside another. For more information, see [Async Server Component TypeScript Error](https://nextjs.org/docs/app/building-your-application/configuring/typescript#async-server-component-typescript-error) in the Next.js TypeScript docs. -> NOTE: If you encounter an error `Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.` make sure that you're **not** passing to queryFn a function reference, instead call the function because queryFn args has a bunch of properties and not all of it would be serializable. see [Server Action only works when queryFn isn't a reference](https://github.com/TanStack/query/issues/6264). +> WARNING: We do **not** recommend using Next.js Server Actions to _fetch_ data in a `queryFn`. When called from the client, Server Actions [run serially, not in parallel](https://react.dev/reference/rsc/use-server#caveats), which conflicts with how React Query fetches and refetches queries. This can leave queries stuck in a pending state or cause the action to never run at all (see [#7934](https://github.com/TanStack/query/issues/7934)). Passing a Server Action reference to `queryFn` can also fail with `Only plain objects, and a few built-ins, can be passed to Server Actions...`, since you have to _call_ the action rather than pass it as a reference (see [#6264](https://github.com/TanStack/query/issues/6264)). For fetching data on the client, `fetch` from an API route or use an RPC layer such as tRPC instead. Server Actions remain a good fit for **mutations** (`useMutation`). ### Nesting Server Components @@ -376,7 +376,7 @@ We will also need to move the `getQueryClient()` function out of our `app/provid ```tsx // app/get-query-client.ts import { - isServer, + environmentManager, QueryClient, defaultShouldDehydrateQuery, } from '@tanstack/react-query' @@ -408,7 +408,7 @@ function makeQueryClient() { let browserQueryClient: QueryClient | undefined = undefined export function getQueryClient() { - if (isServer) { + if (environmentManager.isServer()) { // Server: always make a new query client return makeQueryClient() } else { @@ -555,7 +555,7 @@ This ensures that only successfully resolved queries are persisted to storage, p While we recommend the prefetching solution detailed above because it flattens request waterfalls both on the initial page load **and** any subsequent page navigation, there is an experimental way to skip prefetching altogether and still have streaming SSR work: `@tanstack/react-query-next-experimental` -This package will allow you to fetch data on the server (in a Client Component) by just calling `useSuspenseQuery` in your component. Results will then be streamed from the server to the client as SuspenseBoundaries resolve. If you call `useSuspenseQuery` without wrapping it in a `` boundary, the HTML response won't start until the fetch resolves. This can be when you want depending on the situation, but keep in mind that this will hurt your TTFB. +This package will allow you to fetch data on the server (in a Client Component) by just calling `useSuspenseQuery` in your component. Results will then be streamed from the server to the client as SuspenseBoundaries resolve. If you call `useSuspenseQuery` without wrapping it in a `` boundary, the HTML response won't start until the fetch resolves. This can be what you want depending on the situation, but keep in mind that this will hurt your TTFB. To achieve this, wrap your app in the `ReactQueryStreamedHydration` component: @@ -564,7 +564,7 @@ To achieve this, wrap your app in the `ReactQueryStreamedHydration` component: 'use client' import { - isServer, + environmentManager, QueryClient, QueryClientProvider, } from '@tanstack/react-query' @@ -586,7 +586,7 @@ function makeQueryClient() { let browserQueryClient: QueryClient | undefined = undefined function getQueryClient() { - if (isServer) { + if (environmentManager.isServer()) { // Server: always make a new query client return makeQueryClient() } else { diff --git a/docs/framework/react/guides/important-defaults.md b/docs/framework/react/guides/important-defaults.md index f17e777e566..1458ff9791d 100644 --- a/docs/framework/react/guides/important-defaults.md +++ b/docs/framework/react/guides/important-defaults.md @@ -14,6 +14,8 @@ Out of the box, TanStack Query is configured with **aggressive but sane** defaul - set `staleTime` to `Infinity` to never trigger a refetch until the Query is [invalidated manually](./query-invalidation.md). - set `staleTime` to `'static'` to **never** trigger a refetch, even if the Query is [invalidated manually](./query-invalidation.md). +> `'static'` and `Infinity` both prevent staleness-based refetches, but `'static'` is stricter: `queryClient.invalidateQueries()` can invalidate a query with `staleTime: Infinity`, but has no effect on `staleTime: 'static'`. `refetchOnMount`, `refetchOnWindowFocus`, and `refetchOnReconnect` set to `"always"` are also blocked by `'static'`. Use `'static'` for data that cannot change while the app is running: feature flags fetched at boot, user permissions loaded at login, static reference tables. Use `Infinity` when you still want manual invalidation to work. + - Stale queries are refetched automatically in the background when: - New instances of the query mount - The window is refocused @@ -21,7 +23,7 @@ Out of the box, TanStack Query is configured with **aggressive but sane** defaul > Setting `staleTime` is the recommended way to avoid excessive refetches, but you can also customize the points in time for refetches by setting options like `refetchOnMount`, `refetchOnWindowFocus` and `refetchOnReconnect`. -- Queries can optionally be configured with a `refetchInterval` to trigger refetches periodically, which is independent of the `staleTime` setting. +- Queries can optionally be configured with a `refetchInterval` to trigger refetches periodically, which is independent of the `staleTime` setting. See [Polling](./polling.md) for details. - Query results that have no more active instances of `useQuery`, `useInfiniteQuery` or query observers are labeled as "inactive" and remain in the cache in case they are used again at a later time. - By default, "inactive" queries are garbage collected after **5 minutes**. diff --git a/docs/framework/react/guides/polling.md b/docs/framework/react/guides/polling.md new file mode 100644 index 00000000000..487a42f227e --- /dev/null +++ b/docs/framework/react/guides/polling.md @@ -0,0 +1,109 @@ +--- +id: polling +title: Polling +--- + +`refetchInterval` makes a query refetch on a timer. Set it to a number in milliseconds and the query runs every N ms while there's at least one active observer: + +[//]: # 'Example1' + +```tsx +useQuery({ + queryKey: ['prices'], + queryFn: fetchPrices, + refetchInterval: 5_000, // every 5 seconds +}) +``` + +[//]: # 'Example1' + +Polling is independent of `staleTime`. A query can be fresh and still poll on schedule; see [Important Defaults](./important-defaults.md) for how `staleTime` interacts with other refetch behaviors. `refetchInterval` fires on its own clock regardless of freshness. + +## Adapting the interval to query state + +Pass a function instead of a number to compute the interval from the current query. The function receives the `Query` object and should return a number in ms or `false` to stop polling: + +[//]: # 'Example2' + +```tsx +useQuery({ + queryKey: ['job', jobId], + queryFn: () => fetchJobStatus(jobId), + refetchInterval: (query) => { + // Stop polling once the job finishes + if (query.state.data?.status === 'complete') return false + return 2_000 + }, +}) +``` + +[//]: # 'Example2' + +Returning `false` clears the interval timer. If the query result changes so the function would return a positive number again, polling resumes automatically. + +## Background polling + +By default, polling pauses when the browser tab loses focus. For dashboards or any interface where data needs to stay current even while the user is in another tab, disable that behavior: + +[//]: # 'Example3' + +```tsx +useQuery({ + queryKey: ['portfolio'], + queryFn: fetchPortfolio, + refetchInterval: 30_000, + refetchIntervalInBackground: true, +}) +``` + +[//]: # 'Example3' + +## Pausing polling + +Pass a function to `refetchInterval` and close over component state to control when polling runs: + +[//]: # 'Example4' + +```tsx +useQuery({ + queryKey: ['prices', tokenAddress], + queryFn: () => fetchPrice(tokenAddress), + refetchInterval: () => { + if (!tokenAddress || isPaused) return false + return 15_000 + }, +}) +``` + +[//]: # 'Example4' + +## Polling with offline support + +TanStack Query detects connectivity by listening to the browser's `online` and `offline` events. In environments where those events don't fire reliably (Electron, some embedded WebViews), set `networkMode: 'always'` to skip the connectivity check: + +[//]: # 'Example5' + +```tsx +useQuery({ + queryKey: ['chainStatus'], + queryFn: fetchChainStatus, + refetchInterval: 10_000, + networkMode: 'always', +}) +``` + +[//]: # 'Example5' + +For more on network modes, see [Network Mode](./network-mode.md). + +## Note on deduplication + +Each `QueryObserver` (each component using `useQuery` with `refetchInterval`) runs its own timer. Two components subscribed to the same key with `refetchInterval: 5000` each fire their timer every 5 seconds. What gets deduplicated is concurrent in-flight fetches: if two timers fire at the same time, only one network request goes out. The timers are observer-level; the deduplication is query-level. + +[//]: # 'ReactNative' + +## Non-browser environments + +For non-browser runtimes like React Native, the standard `online`/`offline` and focus events aren't available. The [React Native guide](../react-native.md) covers how to connect `focusManager` and `onlineManager` to native app state APIs. + +[//]: # 'ReactNative' diff --git a/docs/framework/react/guides/query-functions.md b/docs/framework/react/guides/query-functions.md index 4fa621c6977..e121bab54a2 100644 --- a/docs/framework/react/guides/query-functions.md +++ b/docs/framework/react/guides/query-functions.md @@ -5,6 +5,8 @@ title: Query Functions A query function can be literally any function that **returns a promise**. The promise that is returned should either **resolve the data** or **throw an error**. +On success, the resolved value may be anything **except `undefined`**. Queries that resolve to `undefined` will be [treated as failed](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-react-query-4#undefined-is-an-illegal-cache-value-for-successful-queries). To store "nothing" as a successful result in the query cache, resolve `null` instead. + All of the following are valid query function configurations: [//]: # 'Example' diff --git a/docs/framework/react/guides/suspense.md b/docs/framework/react/guides/suspense.md index 7e8e0456619..7c48e65a14c 100644 --- a/docs/framework/react/guides/suspense.md +++ b/docs/framework/react/guides/suspense.md @@ -120,7 +120,7 @@ To achieve this, wrap your app in the `ReactQueryStreamedHydration` component: 'use client' import { - isServer, + environmentManager, QueryClient, QueryClientProvider, } from '@tanstack/react-query' @@ -142,7 +142,7 @@ function makeQueryClient() { let browserQueryClient: QueryClient | undefined = undefined function getQueryClient() { - if (isServer) { + if (environmentManager.isServer()) { // Server: always make a new query client return makeQueryClient() } else { diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index c17fe8b79db..b02775a62cf 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -86,8 +86,9 @@ const { - If set to a `number`, e.g. `3`, failed queries will retry until the failed query count meets that number. - If set to a function, it will be called with `failureCount` (starting at `0` for the first retry) and `error` to determine if a retry should be attempted. - defaults to `3` on the client and `0` on the server -- `retryOnMount: boolean` - - If set to `false`, the query will not be retried on mount if it contains an error. Defaults to `true`. +- `retryOnMount: boolean | (query: Query) => boolean` + - If set to `false`, the query will not be retried on mount if it contains an error and has no data. Defaults to `true`. + - If set to a function, the function will be executed with the query to compute the value. - `retryDelay: number | (retryAttempt: number, error: TError) => number` - This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds. - A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff. diff --git a/docs/framework/solid/devtools.md b/docs/framework/solid/devtools.md index 97b465d6727..b6a2cca2480 100644 --- a/docs/framework/solid/devtools.md +++ b/docs/framework/solid/devtools.md @@ -70,18 +70,70 @@ function App() { - `initialIsOpen: boolean` - Set this `true` if you want the dev tools to default to being open -- `buttonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right"` +- `buttonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right" | "relative"` - Defaults to `bottom-right` - - The position of the Solid Query logo to open and close the devtools panel + - The position of the TanStack logo to open and close the devtools panel - `position?: "top" | "bottom" | "left" | "right"` - Defaults to `bottom` - The position of the Solid Query devtools panel - `client?: QueryClient`, - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. -- `errorTypes?: { name: string; initializer: (query: Query) => TError}` +- `errorTypes?: { name: string; initializer: (query: Query) => TError}[]` - Use this to predefine some errors that can be triggered on your queries. Initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. - `styleNonce?: string` - Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. - `shadowDOMTarget?: ShadowRoot` - Default behavior will apply the devtool's styles to the head tag within the DOM. - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. +- `theme?: "light" | "dark" | "system"` + - Defaults to `system`. + - Set this to change the theme of the devtools panel. + +## Embedded Mode + +Embedded mode will show the development tools as a fixed element in your application, so you can use our panel in your own development tools. + +Place the following code as high in your Solid app as you can. The closer it is to the root of the page, the better it will work! + +```tsx +import { createSignal, Show } from 'solid-js' +import { SolidQueryDevtoolsPanel } from '@tanstack/solid-query-devtools' + +function App() { + const [isOpen, setIsOpen] = createSignal(false) + + return ( + + {/* The rest of your application */} + + + setIsOpen(false)} /> + + + ) +} +``` + +### Options + +- `style?: JSX.CSSProperties` + - Custom styles for the devtools panel + - Default: `{ height: '500px' }` + - Example: `{ height: '100%' }` + - Example: `{ height: '100%', width: '100%' }` +- `onClose?: () => void` + - Callback function that is called when the devtools panel is closed +- `client?: QueryClient`, + - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. +- `errorTypes?: { name: string; initializer: (query: Query) => TError}[]` + - Use this to predefine some errors that can be triggered on your queries. Initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. +- `styleNonce?: string` + - Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. +- `shadowDOMTarget?: ShadowRoot` + - Default behavior will apply the devtool's styles to the head tag within the DOM. + - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. +- `theme?: "light" | "dark" | "system"` + - Defaults to `system`. + - Set this to change the theme of the devtools panel. diff --git a/docs/framework/solid/guides/polling.md b/docs/framework/solid/guides/polling.md new file mode 100644 index 00000000000..9410627ebfc --- /dev/null +++ b/docs/framework/solid/guides/polling.md @@ -0,0 +1,73 @@ +--- +id: polling +title: Polling +ref: docs/framework/react/guides/polling.md +replace: { '@tanstack/react-query': '@tanstack/solid-query' } +--- + +[//]: # 'Example1' + +```tsx +useQuery(() => ({ + queryKey: ['prices'], + queryFn: fetchPrices, + refetchInterval: 5_000, // every 5 seconds +})) +``` + +[//]: # 'Example1' +[//]: # 'Example2' + +```tsx +useQuery(() => ({ + queryKey: ['job', jobId], + queryFn: () => fetchJobStatus(jobId), + refetchInterval: (query) => { + // Stop polling once the job finishes + if (query.state.data?.status === 'complete') return false + return 2_000 + }, +})) +``` + +[//]: # 'Example2' +[//]: # 'Example3' + +```tsx +useQuery(() => ({ + queryKey: ['portfolio'], + queryFn: fetchPortfolio, + refetchInterval: 30_000, + refetchIntervalInBackground: true, +})) +``` + +[//]: # 'Example3' +[//]: # 'Example4' + +```tsx +useQuery(() => ({ + queryKey: ['prices', tokenAddress], + queryFn: () => fetchPrice(tokenAddress), + refetchInterval: () => { + if (!tokenAddress || isPaused) return false + return 15_000 + }, +})) +``` + +[//]: # 'Example4' +[//]: # 'Example5' + +```tsx +useQuery(() => ({ + queryKey: ['chainStatus'], + queryFn: fetchChainStatus, + refetchInterval: 10_000, + networkMode: 'always', +})) +``` + +[//]: # 'Example5' +[//]: # 'ReactNative' +[//]: # 'ReactNative' diff --git a/docs/framework/solid/reference/useQuery.md b/docs/framework/solid/reference/useQuery.md index 38b9751d758..876c5c99729 100644 --- a/docs/framework/solid/reference/useQuery.md +++ b/docs/framework/solid/reference/useQuery.md @@ -277,8 +277,9 @@ function App() { - If `true`, failed queries will retry infinitely. - If set to a `number`, e.g. `3`, failed queries will retry until the failed query count meets that number. - defaults to `3` on the client and `0` on the server - - ##### `retryOnMount: boolean` - - If set to `false`, the query will not be retried on mount if it contains an error. Defaults to `true`. + - ##### `retryOnMount: boolean | (query: Query) => boolean` + - If set to `false`, the query will not be retried on mount if it contains an error and has no data. Defaults to `true`. + - If set to a function, the function will be executed with the query to compute the value. - ##### `retryDelay: number | (retryAttempt: number, error: TError) => number` - This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds. - A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff. diff --git a/docs/framework/svelte/devtools.md b/docs/framework/svelte/devtools.md index efdb77e922d..179690c2a63 100644 --- a/docs/framework/svelte/devtools.md +++ b/docs/framework/svelte/devtools.md @@ -72,7 +72,7 @@ Place the following code as high in your Svelte app as you can. The closer it is - The position of the Svelte Query devtools panel - `client?: QueryClient`, - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. -- `errorTypes?: { name: string; initializer: (query: Query) => TError}` +- `errorTypes?: { name: string; initializer: (query: Query) => TError}[]` - Use this to predefine some errors that can be triggered on your queries. Initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. - `styleNonce?: string` - Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. diff --git a/docs/framework/vue/devtools.md b/docs/framework/vue/devtools.md index 666058e27a9..a32a5e0f5ba 100644 --- a/docs/framework/vue/devtools.md +++ b/docs/framework/vue/devtools.md @@ -65,52 +65,56 @@ import { VueQueryDevtools } from '@tanstack/vue-query-devtools' - `initialIsOpen: boolean` - Set this `true` if you want the dev tools to default to being open. -- `buttonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right"` +- `buttonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right" | "relative"` - Defaults to `bottom-right`. - - The position of the React Query logo to open and close the devtools panel. + - The position of the TanStack logo to open and close the devtools panel. - `position?: "top" | "bottom" | "left" | "right"` - Defaults to `bottom`. - - The position of the React Query devtools panel. + - The position of the Vue Query devtools panel. - `client?: QueryClient` - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. -- `errorTypes?: { name: string; initializer: (query: Query) => TError}` +- `errorTypes?: { name: string; initializer: (query: Query) => TError}[]` - Use this to predefine some errors that can be triggered on your queries. The initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. - `styleNonce?: string` - Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. - `shadowDOMTarget?: ShadowRoot` - Default behavior will apply the devtool's styles to the head tag within the DOM. - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. +- `theme?: "light" | "dark" | "system"` + - Defaults to `system`. + - Set this to change the theme of the devtools panel. ## Embedded Mode Embedded mode will show the development tools as a fixed element in your application, so you can use our panel in your own development tools. -Place the following code as high in your React app as you can. The closer it is to the root of the page, the better it will work! +Place the following code as high in your Vue app as you can. The closer it is to the root of the page, the better it will work! ```vue ``` ### Options -- `style?: React.CSSProperties` +- `style?: Partial` - Custom styles for the devtools panel - Default: `{ height: '500px' }` - Example: `{ height: '100%' }` - Example: `{ height: '100%', width: '100%' }` -- `onClose?: () => unknown` +- `onClose?: () => void` - Callback function that is called when the devtools panel is closed - `client?: QueryClient`, - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. @@ -121,6 +125,9 @@ function toggleDevtools() { - `shadowDOMTarget?: ShadowRoot` - Default behavior will apply the devtool's styles to the head tag within the DOM. - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. +- `theme?: "light" | "dark" | "system"` + - Defaults to `system`. + - Set this to change the theme of the devtools panel. ## Traditional Devtools diff --git a/docs/framework/vue/guides/polling.md b/docs/framework/vue/guides/polling.md new file mode 100644 index 00000000000..1f096cd007d --- /dev/null +++ b/docs/framework/vue/guides/polling.md @@ -0,0 +1,9 @@ +--- +id: polling +title: Polling +ref: docs/framework/react/guides/polling.md +replace: { '@tanstack/react-query': '@tanstack/vue-query' } +--- + +[//]: # 'ReactNative' +[//]: # 'ReactNative' diff --git a/docs/framework/vue/reference/mutationOptions.md b/docs/framework/vue/reference/mutationOptions.md new file mode 100644 index 00000000000..b34296ad545 --- /dev/null +++ b/docs/framework/vue/reference/mutationOptions.md @@ -0,0 +1,5 @@ +--- +id: mutationOptions +title: mutationOptions +ref: docs/framework/react/reference/mutationOptions.md +--- diff --git a/docs/framework/vue/reference/usePrefetchInfiniteQuery.md b/docs/framework/vue/reference/usePrefetchInfiniteQuery.md new file mode 100644 index 00000000000..c7af728d36d --- /dev/null +++ b/docs/framework/vue/reference/usePrefetchInfiniteQuery.md @@ -0,0 +1,6 @@ +--- +id: usePrefetchInfiniteQuery +title: usePrefetchInfiniteQuery +ref: docs/framework/react/reference/usePrefetchInfiniteQuery.md +replace: { '@tanstack/react-query': '@tanstack/vue-query' } +--- diff --git a/docs/framework/vue/reference/usePrefetchQuery.md b/docs/framework/vue/reference/usePrefetchQuery.md new file mode 100644 index 00000000000..e518a62ef84 --- /dev/null +++ b/docs/framework/vue/reference/usePrefetchQuery.md @@ -0,0 +1,6 @@ +--- +id: usePrefetchQuery +title: usePrefetchQuery +ref: docs/framework/react/reference/usePrefetchQuery.md +replace: { '@tanstack/react-query': '@tanstack/vue-query' } +--- diff --git a/docs/reference/QueryCache.md b/docs/reference/QueryCache.md index 09a9c8e5357..5c7251540eb 100644 --- a/docs/reference/QueryCache.md +++ b/docs/reference/QueryCache.md @@ -22,7 +22,7 @@ const queryCache = new QueryCache({ }, }) -const query = queryCache.find(['posts']) +const query = queryCache.find({ queryKey: ['posts'] }) ``` Its available methods are: @@ -52,12 +52,13 @@ Its available methods are: > Note: This is not typically needed for most applications, but can come in handy when needing more information about a query in rare scenarios (eg. Looking at the query.state.dataUpdatedAt timestamp to decide whether a query is fresh enough to be used as an initial value) ```tsx -const query = queryCache.find(queryKey) +const query = queryCache.find({ queryKey }) ``` **Options** -- `filters?: QueryFilters`: [Query Filters](../framework/react/guides/filters#query-filters) +- `filters: QueryFilters`: [Query Filters](../framework/react/guides/filters#query-filters) + - `queryKey: QueryKey`: [Query Keys](../framework/react/guides/query-keys.md) **Returns** @@ -71,12 +72,11 @@ const query = queryCache.find(queryKey) > Note: This is not typically needed for most applications, but can come in handy when needing more information about a query in rare scenarios ```tsx -const queries = queryCache.findAll(queryKey) +const queries = queryCache.findAll({ queryKey }) ``` **Options** -- `queryKey?: QueryKey`: [Query Keys](../framework/react/guides/query-keys.md) - `filters?: QueryFilters`: [Query Filters](../framework/react/guides/filters.md#query-filters) **Returns** diff --git a/docs/reference/QueryClient.md b/docs/reference/QueryClient.md index 13bde1ffad0..3c9e1b31c27 100644 --- a/docs/reference/QueryClient.md +++ b/docs/reference/QueryClient.md @@ -250,7 +250,7 @@ This distinction is more a "convenience" for ts devs that know which structure w ## `queryClient.setQueryData` -`setQueryData` is a synchronous function that can be used to immediately update a query's cached data. If the query does not exist, it will be created. **If the query is not utilized by a query hook in the default `gcTime` of 5 minutes, the query will be garbage collected**. To update multiple queries at once and match query keys partially, you need to use [`queryClient.setQueriesData`](#queryclientsetqueriesdata) instead. +`setQueryData` is a synchronous function that can be used to immediately update a query's cached data. If the query does not exist, it will be created. **If the query is not utilized by a query hook within the default `gcTime`, the query will be garbage collected. If the default `gcTime` has not been configured, it defaults to 5 minutes.** To update multiple queries at once and match query keys partially, you need to use [`queryClient.setQueriesData`](#queryclientsetqueriesdata) instead. > The difference between using `setQueryData` and `fetchQuery` is that `setQueryData` is sync and assumes that you already synchronously have the data available. If you need to fetch the data asynchronously, it's suggested that you either refetch the query key or use `fetchQuery` to handle the asynchronous fetch. @@ -352,6 +352,11 @@ await queryClient.invalidateQueries( - Per default, a currently running request will be cancelled before a new request is made - When set to `false`, no refetch will be made if there is already a request running. +**Notes** + +- Unlike [`refetchQueries`](#queryclientrefetchqueries), `invalidateQueries` marks matching queries as invalidated and then refetches `active` queries (unless otherwise specified with the `refetchType` option). +- Unlike [`removeQueries`](#queryclientremovequeries), `invalidateQueries` keeps matching queries in the cache. + ## `queryClient.refetchQueries` The `refetchQueries` method can be used to refetch queries based on certain conditions. @@ -395,6 +400,7 @@ This function returns a promise that will resolve when all of the queries are do - Queries that are "disabled" because they only have disabled Observers will never be refetched. - Queries that are "static" because they only have Observers with a Static StaleTime will never be refetched. +- Unlike [`invalidateQueries`](#queryclientinvalidatequeries), `refetchQueries` refetches all matching queries. ## `queryClient.cancelQueries` @@ -434,6 +440,10 @@ queryClient.removeQueries({ queryKey, exact: true }) This method does not return anything +**Notes** + +- Unlike [`invalidateQueries`](#queryclientinvalidatequeries) or [`refetchQueries`](#queryclientrefetchqueries), `removeQueries` removes matching queries from the cache instead of refetching them. + ## `queryClient.resetQueries` The `resetQueries` method can be used to reset queries in the cache to their diff --git a/eslint.config.js b/eslint.config.js index ae923ac2254..20709d68c25 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -53,10 +53,14 @@ export default [ plugins: { vitest }, rules: { ...vitest.configs.recommended.rules, + 'vitest/consistent-test-it': [ + 'error', + { fn: 'it', withinDescribe: 'it' }, + ], 'vitest/no-standalone-expect': [ 'error', { - additionalTestBlockFunctions: ['testIf'], + additionalTestBlockFunctions: ['itIf'], }, ], }, diff --git a/examples/angular/auto-refetching/README.md b/examples/angular/auto-refetching/README.md index 571955a305d..4a70c6af6e7 100644 --- a/examples/angular/auto-refetching/README.md +++ b/examples/angular/auto-refetching/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/auto-refetching/package.json b/examples/angular/auto-refetching/package.json index 0e06fe93233..ffa0ba4bb5a 100644 --- a/examples/angular/auto-refetching/package.json +++ b/examples/angular/auto-refetching/package.json @@ -13,7 +13,7 @@ "@angular/compiler": "^20.0.0", "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/basic-persister/README.md b/examples/angular/basic-persister/README.md index 47d5931979a..6c79fc36193 100644 --- a/examples/angular/basic-persister/README.md +++ b/examples/angular/basic-persister/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/basic-persister/package.json b/examples/angular/basic-persister/package.json index 44784988078..b227eed959f 100644 --- a/examples/angular/basic-persister/package.json +++ b/examples/angular/basic-persister/package.json @@ -13,9 +13,9 @@ "@angular/compiler": "^20.0.0", "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", - "@tanstack/angular-query-persist-client": "^5.62.32", - "@tanstack/query-async-storage-persister": "^5.90.27", + "@tanstack/angular-query-experimental": "^5.101.0", + "@tanstack/angular-query-persist-client": "^5.101.0", + "@tanstack/query-async-storage-persister": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/basic/README.md b/examples/angular/basic/README.md index 15f2ed2f4a2..61eb487a22c 100644 --- a/examples/angular/basic/README.md +++ b/examples/angular/basic/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/basic/package.json b/examples/angular/basic/package.json index 2bdbba2ff44..c87b2b5bf7d 100644 --- a/examples/angular/basic/package.json +++ b/examples/angular/basic/package.json @@ -13,7 +13,7 @@ "@angular/compiler": "^20.0.0", "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/devtools-panel/README.md b/examples/angular/devtools-panel/README.md index 849d0a9d4e0..729ce68dc49 100644 --- a/examples/angular/devtools-panel/README.md +++ b/examples/angular/devtools-panel/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/devtools-panel/package.json b/examples/angular/devtools-panel/package.json index 5970e53c1d2..0376ced8449 100644 --- a/examples/angular/devtools-panel/package.json +++ b/examples/angular/devtools-panel/package.json @@ -14,7 +14,7 @@ "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", "@angular/router": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/infinite-query-with-max-pages/README.md b/examples/angular/infinite-query-with-max-pages/README.md index fce960f6a35..4f308448f5f 100644 --- a/examples/angular/infinite-query-with-max-pages/README.md +++ b/examples/angular/infinite-query-with-max-pages/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/infinite-query-with-max-pages/package.json b/examples/angular/infinite-query-with-max-pages/package.json index 96701d77008..f7bc3b933cc 100644 --- a/examples/angular/infinite-query-with-max-pages/package.json +++ b/examples/angular/infinite-query-with-max-pages/package.json @@ -13,7 +13,7 @@ "@angular/compiler": "^20.0.0", "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/optimistic-updates/README.md b/examples/angular/optimistic-updates/README.md index 583897ad45d..bcffae54d27 100644 --- a/examples/angular/optimistic-updates/README.md +++ b/examples/angular/optimistic-updates/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/optimistic-updates/package.json b/examples/angular/optimistic-updates/package.json index 8bbe39a2005..10024dc6b15 100644 --- a/examples/angular/optimistic-updates/package.json +++ b/examples/angular/optimistic-updates/package.json @@ -14,7 +14,7 @@ "@angular/core": "^20.0.0", "@angular/forms": "^20.0.0", "@angular/platform-browser": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/pagination/README.md b/examples/angular/pagination/README.md index fd58c455860..6e113c75664 100644 --- a/examples/angular/pagination/README.md +++ b/examples/angular/pagination/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/pagination/package.json b/examples/angular/pagination/package.json index 8f583a65d54..8164f6ea323 100644 --- a/examples/angular/pagination/package.json +++ b/examples/angular/pagination/package.json @@ -13,7 +13,7 @@ "@angular/compiler": "^20.0.0", "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/query-options-from-a-service/README.md b/examples/angular/query-options-from-a-service/README.md index f9e940480ac..40bce6275f8 100644 --- a/examples/angular/query-options-from-a-service/README.md +++ b/examples/angular/query-options-from-a-service/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/query-options-from-a-service/package.json b/examples/angular/query-options-from-a-service/package.json index e17c58742c7..a95aaff2726 100644 --- a/examples/angular/query-options-from-a-service/package.json +++ b/examples/angular/query-options-from-a-service/package.json @@ -14,7 +14,7 @@ "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", "@angular/router": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/router/README.md b/examples/angular/router/README.md index 0429048cb9c..ea37ad5d945 100644 --- a/examples/angular/router/README.md +++ b/examples/angular/router/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/router/package.json b/examples/angular/router/package.json index c504dbf6445..68d4e07f5d8 100644 --- a/examples/angular/router/package.json +++ b/examples/angular/router/package.json @@ -14,7 +14,7 @@ "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", "@angular/router": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/rxjs/README.md b/examples/angular/rxjs/README.md index bc63a82b7f2..5603b0de263 100644 --- a/examples/angular/rxjs/README.md +++ b/examples/angular/rxjs/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/rxjs/package.json b/examples/angular/rxjs/package.json index d2b4e449366..4859e692f81 100644 --- a/examples/angular/rxjs/package.json +++ b/examples/angular/rxjs/package.json @@ -14,7 +14,7 @@ "@angular/core": "^20.0.0", "@angular/forms": "^20.0.0", "@angular/platform-browser": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/angular/simple/README.md b/examples/angular/simple/README.md index 35939257ca9..b2acfd071ab 100644 --- a/examples/angular/simple/README.md +++ b/examples/angular/simple/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` or `yarn` or `pnpm i` or `bun i` -- `npm run start` or `yarn start` or `pnpm start` or `bun start` +- `pnpm install` +- `pnpm start` diff --git a/examples/angular/simple/package.json b/examples/angular/simple/package.json index f407d3ca1fe..0e613ca6e45 100644 --- a/examples/angular/simple/package.json +++ b/examples/angular/simple/package.json @@ -13,7 +13,7 @@ "@angular/compiler": "^20.0.0", "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" diff --git a/examples/lit/basic/CHANGELOG.md b/examples/lit/basic/CHANGELOG.md new file mode 100644 index 00000000000..6047b494b38 --- /dev/null +++ b/examples/lit/basic/CHANGELOG.md @@ -0,0 +1,8 @@ +# @tanstack/query-example-lit-basic + +## 0.0.2 + +### Patch Changes + +- Updated dependencies [[`4082894`](https://github.com/TanStack/query/commit/4082894509f31376ebeb8514cc3e167bbbfc7c46)]: + - @tanstack/lit-query@0.2.0 diff --git a/examples/lit/basic/README.md b/examples/lit/basic/README.md new file mode 100644 index 00000000000..6d79ad06c1a --- /dev/null +++ b/examples/lit/basic/README.md @@ -0,0 +1,5 @@ +# Example + +To run this example from the repo root: + +- `pnpm --dir examples/lit/basic run dev` diff --git a/examples/lit/basic/basic-query.html b/examples/lit/basic/basic-query.html new file mode 100644 index 00000000000..19de73348a1 --- /dev/null +++ b/examples/lit/basic/basic-query.html @@ -0,0 +1,12 @@ + + + + + + Lit Query Basic Example + + + + + + diff --git a/examples/lit/basic/config/port.d.ts b/examples/lit/basic/config/port.d.ts new file mode 100644 index 00000000000..12e9b85cc4d --- /dev/null +++ b/examples/lit/basic/config/port.d.ts @@ -0,0 +1 @@ +export const DEMO_PORT: number diff --git a/examples/lit/basic/config/port.js b/examples/lit/basic/config/port.js new file mode 100644 index 00000000000..bff39bff2ee --- /dev/null +++ b/examples/lit/basic/config/port.js @@ -0,0 +1,22 @@ +const DEFAULT_DEMO_PORT = 4173 +const envPort = process.env.DEMO_PORT + +function resolvePort() { + if (!envPort) { + return DEFAULT_DEMO_PORT + } + + const parsedPort = Number.parseInt(envPort, 10) + const isValidPort = + Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 + + if (!isValidPort) { + throw new Error( + `Invalid DEMO_PORT "${envPort}". Expected an integer between 1 and 65535.`, + ) + } + + return parsedPort +} + +export const DEMO_PORT = resolvePort() diff --git a/examples/lit/basic/index.html b/examples/lit/basic/index.html new file mode 100644 index 00000000000..a2962e70475 --- /dev/null +++ b/examples/lit/basic/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Lit Query E2E Demo + + + + + + diff --git a/examples/lit/basic/lifecycle-contract.html b/examples/lit/basic/lifecycle-contract.html new file mode 100644 index 00000000000..5d0f5e5e5a1 --- /dev/null +++ b/examples/lit/basic/lifecycle-contract.html @@ -0,0 +1,12 @@ + + + + + + TanStack Lit Query Lifecycle Contract Fixture + + + + + + diff --git a/examples/lit/basic/mutation.html b/examples/lit/basic/mutation.html new file mode 100644 index 00000000000..1d5de500efb --- /dev/null +++ b/examples/lit/basic/mutation.html @@ -0,0 +1,12 @@ + + + + + + Lit Query Mutation Example + + + + + + diff --git a/examples/lit/basic/package.json b/examples/lit/basic/package.json new file mode 100644 index 00000000000..63d3825dd10 --- /dev/null +++ b/examples/lit/basic/package.json @@ -0,0 +1,19 @@ +{ + "name": "@tanstack/query-example-lit-basic", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/lit-query": "^0.2.7", + "@tanstack/query-core": "^5.101.0", + "lit": "^3.3.1" + }, + "devDependencies": { + "typescript": "5.8.3", + "vite": "^6.4.1" + } +} diff --git a/examples/lit/basic/src/basic-query.ts b/examples/lit/basic/src/basic-query.ts new file mode 100644 index 00000000000..5cc208d0174 --- /dev/null +++ b/examples/lit/basic/src/basic-query.ts @@ -0,0 +1,90 @@ +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createQueryController, +} from '@tanstack/lit-query' +import { fetchTodosFromServer, resetTodoApi } from './todoApi' +import type { TodosResponse } from './todoApi' + +resetTodoApi() + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}) + +class BasicQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } +} + +customElements.define('basic-query-provider', BasicQueryProvider) + +class BasicQueryExample extends LitElement { + private readonly todos = createQueryController(this, { + queryKey: ['todos'], + queryFn: fetchTodosFromServer, + }) + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + render() { + const query = this.todos() + return html` +
+

Basic Query Example

+

+ Status: ${query.status} +

+ + + ${query.isPending + ? html`

Loading...

` + : null} + ${query.isError + ? html`

Error: ${String(query.error)}

` + : null} + +
    + ${(query.data?.items ?? []).map( + (todo) => + html`
  • ${todo.title}
  • `, + )} +
+
+ ` + } +} + +customElements.define('basic-query-example', BasicQueryExample) + +class BasicQueryRoot extends LitElement { + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + render() { + return html` + + + + ` + } +} + +customElements.define('basic-query-root', BasicQueryRoot) diff --git a/examples/lit/basic/src/lifecycle-contract.ts b/examples/lit/basic/src/lifecycle-contract.ts new file mode 100644 index 00000000000..76f0027ba40 --- /dev/null +++ b/examples/lit/basic/src/lifecycle-contract.ts @@ -0,0 +1,223 @@ +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createQueryController, +} from '@tanstack/lit-query' + +type ContractProbeData = { + provider: 'provider-a' | 'provider-b' + payload: string +} + +type ContractTarget = 'orphan' | 'provider-a' | 'provider-b' + +const contractQueryKey = ['lifecycle-contract', 'provider-binding'] as const +let contractConsumerInstanceCount = 0 + +function createContractClient(data: ContractProbeData): QueryClient { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + }, + }, + }) + + client.setQueryData(contractQueryKey, data) + return client +} + +const contractClientA = createContractClient({ + provider: 'provider-a', + payload: 'provider-a cache', +}) + +const contractClientB = createContractClient({ + provider: 'provider-b', + payload: 'provider-b cache', +}) + +class ContractProviderA extends QueryClientProvider { + constructor() { + super() + this.client = contractClientA + } + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } +} + +if (!customElements.get('contract-provider-a')) { + customElements.define('contract-provider-a', ContractProviderA) +} + +class ContractProviderB extends QueryClientProvider { + constructor() { + super() + this.client = contractClientB + } + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } +} + +if (!customElements.get('contract-provider-b')) { + customElements.define('contract-provider-b', ContractProviderB) +} + +class LifecycleContractConsumer extends LitElement { + private readonly instanceId = ++contractConsumerInstanceCount + + private readonly query = createQueryController( + this, + { + queryKey: contractQueryKey, + queryFn: () => { + throw new Error( + 'Lifecycle contract fixture unexpectedly fetched from queryFn.', + ) + }, + retry: false, + staleTime: Number.POSITIVE_INFINITY, + }, + ) + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + private renderQueryState() { + try { + const query = this.query() + + return html` +
query: ${query.status}
+
+ provider: ${query.data?.provider ?? 'none'} +
+
+ payload: ${query.data?.payload ?? 'none'} +
+
+ error: ${query.error ? String(query.error) : 'none'} +
+ ` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + + return html` +
query: missing-client
+
provider: none
+
payload: none
+
error: ${message}
+ ` + } + } + + render() { + return html` +
instance: ${this.instanceId}
+ ${this.renderQueryState()} + ` + } +} + +if (!customElements.get('lifecycle-contract-consumer')) { + customElements.define( + 'lifecycle-contract-consumer', + LifecycleContractConsumer, + ) +} + +class LifecycleContractRoot extends LitElement { + static properties = { + currentTarget: { state: true }, + } + + private currentTarget: ContractTarget = 'orphan' + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + override firstUpdated(): void { + this.moveConsumerTo('orphan') + } + + moveConsumerTo(target: ContractTarget): void { + const consumer = this.ensureConsumer() + const destination = this.getContainer(target) + destination.appendChild(consumer) + this.currentTarget = target + this.requestUpdate() + } + + private ensureConsumer(): LifecycleContractConsumer { + const existing = this.querySelector( + 'lifecycle-contract-consumer', + ) + if (existing) { + return existing + } + + return document.createElement( + 'lifecycle-contract-consumer', + ) as LifecycleContractConsumer + } + + private getContainer(target: ContractTarget): HTMLElement { + const selector = + target === 'orphan' + ? '[data-contract-slot="orphan"]' + : target === 'provider-a' + ? 'contract-provider-a' + : 'contract-provider-b' + + const container = this.querySelector(selector) + if (!(container instanceof HTMLElement)) { + throw new Error( + `Lifecycle contract container not found for target "${target}".`, + ) + } + + return container + } + + render() { + return html` +
+

Lifecycle Contract Fixture

+

+ Exercises the same consumer across missing-provider and provider + switching flows. +

+
+ location: ${this.currentTarget} +
+ +
+

Orphan Zone

+
+
+ +
+

Provider A

+ +
+ +
+

Provider B

+ +
+
+ ` + } +} + +if (!customElements.get('lifecycle-contract-root')) { + customElements.define('lifecycle-contract-root', LifecycleContractRoot) +} diff --git a/examples/lit/basic/src/main.ts b/examples/lit/basic/src/main.ts new file mode 100644 index 00000000000..5f56e3be0f6 --- /dev/null +++ b/examples/lit/basic/src/main.ts @@ -0,0 +1,274 @@ +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createMutationController, + createQueryController, + useIsFetching, + useIsMutating, +} from '@tanstack/lit-query' +import { + addTodoOnServer, + failNextFetchRequest, + failNextMutationRequest, + fetchTodosFromServer, + resetTodoApi, +} from './todoApi' +import type { Todo, TodosResponse } from './todoApi' + +resetTodoApi() + +const demoQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, +}) + +class DemoQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = demoQueryClient + } + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } +} + +customElements.define('demo-query-provider', DemoQueryProvider) + +class TanstackLitQueryDemo extends LitElement { + static properties = { + nextTodoTitle: { state: true }, + cacheSeedCount: { state: true }, + } + + private nextTodoTitle = 'Add mutation assertion' + private cacheSeedCount = 0 + + private readonly todosQuery = createQueryController( + this, + { + queryKey: ['todos'], + queryFn: fetchTodosFromServer, + }, + ) + + private readonly createTodoMutation = createMutationController< + Todo, + Error, + string + >(this, { + mutationKey: ['create-todo'], + mutationFn: addTodoOnServer, + onSuccess: (createdTodo) => { + demoQueryClient.setQueryData(['todos'], (existing) => { + if (!existing) { + return { + items: [createdTodo], + requestCount: 0, + source: 'cache', + } + } + + return { + items: [...existing.items, createdTodo], + requestCount: existing.requestCount, + source: 'cache', + } + }) + }, + }) + + private readonly isFetching = useIsFetching(this, { + queryKey: ['todos'], + }) + + private readonly isMutating = useIsMutating(this, { + mutationKey: ['create-todo'], + }) + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + private onTitleInput(event: Event): void { + const target = event.target as HTMLInputElement + this.nextTodoTitle = target.value + } + + private addTodo(): void { + const title = this.nextTodoTitle.trim() + if (!title) { + return + } + + this.createTodoMutation.mutate(title) + this.nextTodoTitle = '' + } + + private async invalidateTodos(): Promise { + await demoQueryClient.invalidateQueries({ queryKey: ['todos'] }) + } + + private seedCacheOnlyTodo(): void { + this.cacheSeedCount += 1 + + const seedTodo: Todo = { + id: 10_000 + this.cacheSeedCount, + title: `Seeded cache todo ${this.cacheSeedCount}`, + } + + demoQueryClient.setQueryData(['todos'], (existing) => { + if (!existing) { + return { + items: [seedTodo], + requestCount: 0, + source: 'cache', + } + } + + return { + items: [...existing.items, seedTodo], + requestCount: existing.requestCount, + source: 'cache', + } + }) + } + + private forceNextFetchFailure(): void { + failNextFetchRequest() + } + + private forceNextMutationFailure(): void { + failNextMutationRequest() + } + + private async resetDemoState(): Promise { + resetTodoApi() + this.cacheSeedCount = 0 + this.nextTodoTitle = 'Add mutation assertion' + + await demoQueryClient.resetQueries({ queryKey: ['todos'] }) + this.createTodoMutation.reset() + } + + render() { + const query = this.todosQuery() + const mutation = this.createTodoMutation() + const todos = query.data?.items ?? [] + + return html` +
+

TanStack Lit Query E2E Demo

+

Verifies integration between Lit, query-core, and this adapter.

+ +
+
query: ${query.status}
+
mutation: ${mutation.status}
+
fetches: ${this.isFetching()}
+
+ mutations: ${this.isMutating()} +
+
+ server-requests: ${query.data?.requestCount ?? 0} +
+
+ source: ${query.data?.source ?? 'none'} +
+
+ +
+ + + + + + +
+ +
+ + + +
+ + ${query.isError + ? html`
${String(query.error)}
` + : null} + ${mutation.isError + ? html`
+ ${String(mutation.error)} +
` + : null} + +
    + ${todos.map( + (todo) => + html`
  • ${todo.id}: ${todo.title}
  • `, + )} +
+
+ ` + } +} + +customElements.define('tanstack-lit-query-demo', TanstackLitQueryDemo) + +class DemoRoot extends LitElement { + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + render() { + return html` + + + + ` + } +} + +customElements.define('demo-root', DemoRoot) diff --git a/examples/lit/basic/src/mutation.ts b/examples/lit/basic/src/mutation.ts new file mode 100644 index 00000000000..422be660656 --- /dev/null +++ b/examples/lit/basic/src/mutation.ts @@ -0,0 +1,139 @@ +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createMutationController, + createQueryController, +} from '@tanstack/lit-query' +import { addTodoOnServer, fetchTodosFromServer, resetTodoApi } from './todoApi' +import type { Todo, TodosResponse } from './todoApi' + +resetTodoApi() + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}) + +class MutationExampleProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } +} + +customElements.define('mutation-example-provider', MutationExampleProvider) + +class MutationExample extends LitElement { + static properties = { + nextTitle: { state: true }, + } + + private nextTitle = 'Created from mutation example' + + private readonly todos = createQueryController(this, { + queryKey: ['todos'], + queryFn: fetchTodosFromServer, + }) + + private readonly addTodo = createMutationController( + this, + { + mutationKey: ['create-todo'], + mutationFn: addTodoOnServer, + onSuccess: (created) => { + queryClient.setQueryData(['todos'], (existing) => { + if (!existing) { + return { + items: [created], + requestCount: 0, + source: 'cache', + } + } + + return { + items: [...existing.items, created], + requestCount: existing.requestCount, + source: 'cache', + } + }) + }, + }, + ) + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + private onInput(event: Event): void { + const target = event.target as HTMLInputElement + this.nextTitle = target.value + } + + private submit(): void { + const title = this.nextTitle.trim() + if (!title) return + this.addTodo.mutate(title) + this.nextTitle = '' + } + + render() { + const query = this.todos() + const mutation = this.addTodo() + const items = query.data?.items ?? [] + + return html` +
+

Mutation Example

+

+ Query: ${query.status} +

+

+ Mutation: ${mutation.status} +

+ + + + + +
    + ${items.map( + (todo) => + html`
  • ${todo.title}
  • `, + )} +
+
+ ` + } +} + +customElements.define('mutation-example', MutationExample) + +class MutationExampleRoot extends LitElement { + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + render() { + return html` + + + + ` + } +} + +customElements.define('mutation-example-root', MutationExampleRoot) diff --git a/examples/lit/basic/src/todoApi.ts b/examples/lit/basic/src/todoApi.ts new file mode 100644 index 00000000000..6ea4aa4dac1 --- /dev/null +++ b/examples/lit/basic/src/todoApi.ts @@ -0,0 +1,77 @@ +export type Todo = { + id: number + title: string +} + +export type TodosResponse = { + items: Todo[] + requestCount: number + source: 'server' | 'cache' +} + +let todos: Todo[] = [ + { id: 1, title: 'Ship lit-query alpha' }, + { id: 2, title: 'Write integration checks' }, +] + +let requestCount = 0 +let nextTodoId = 3 +let failNextFetch = false +let failNextMutation = false + +const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +export async function fetchTodosFromServer(): Promise { + await delay(90) + if (failNextFetch) { + failNextFetch = false + throw new Error('Forced fetch failure (test)') + } + requestCount += 1 + + return { + items: todos.map((todo) => ({ ...todo })), + requestCount, + source: 'server', + } +} + +export async function addTodoOnServer(title: string): Promise { + await delay(70) + if (failNextMutation) { + failNextMutation = false + throw new Error('Forced mutation failure (test)') + } + + const nextTodo: Todo = { + id: nextTodoId, + title, + } + + nextTodoId += 1 + todos = [...todos, nextTodo] + + return { ...nextTodo } +} + +export function resetTodoApi(): void { + todos = [ + { id: 1, title: 'Ship lit-query alpha' }, + { id: 2, title: 'Write integration checks' }, + ] + requestCount = 0 + nextTodoId = 3 + failNextFetch = false + failNextMutation = false +} + +export function failNextFetchRequest(): void { + failNextFetch = true +} + +export function failNextMutationRequest(): void { + failNextMutation = true +} diff --git a/examples/lit/basic/tsconfig.json b/examples/lit/basic/tsconfig.json new file mode 100644 index 00000000000..d6e16f3ca50 --- /dev/null +++ b/examples/lit/basic/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "useDefineForClassFields": false + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/examples/lit/basic/vite.config.ts b/examples/lit/basic/vite.config.ts new file mode 100644 index 00000000000..1d3028a8fb5 --- /dev/null +++ b/examples/lit/basic/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import { DEMO_PORT } from './config/port.js' + +export default defineConfig({ + server: { + host: '127.0.0.1', + port: DEMO_PORT, + strictPort: true, + }, + preview: { + host: '127.0.0.1', + port: DEMO_PORT, + strictPort: true, + }, +}) diff --git a/examples/lit/pagination/CHANGELOG.md b/examples/lit/pagination/CHANGELOG.md new file mode 100644 index 00000000000..7a70041c82c --- /dev/null +++ b/examples/lit/pagination/CHANGELOG.md @@ -0,0 +1,8 @@ +# @tanstack/query-example-lit-pagination + +## 0.0.2 + +### Patch Changes + +- Updated dependencies [[`4082894`](https://github.com/TanStack/query/commit/4082894509f31376ebeb8514cc3e167bbbfc7c46)]: + - @tanstack/lit-query@0.2.0 diff --git a/examples/lit/pagination/README.md b/examples/lit/pagination/README.md new file mode 100644 index 00000000000..695072f25d0 --- /dev/null +++ b/examples/lit/pagination/README.md @@ -0,0 +1,5 @@ +# Example + +To run this example from the repo root: + +- `pnpm --dir examples/lit/pagination run dev` diff --git a/examples/lit/pagination/config/ports.d.ts b/examples/lit/pagination/config/ports.d.ts new file mode 100644 index 00000000000..740d57d9863 --- /dev/null +++ b/examples/lit/pagination/config/ports.d.ts @@ -0,0 +1,2 @@ +export const DEMO_PORT: number +export const API_PORT: number diff --git a/examples/lit/pagination/config/ports.js b/examples/lit/pagination/config/ports.js new file mode 100644 index 00000000000..c4afc201ce2 --- /dev/null +++ b/examples/lit/pagination/config/ports.js @@ -0,0 +1,26 @@ +const DEFAULT_DEMO_PORT = 4183 +const DEFAULT_API_PORT = 4184 + +function readPortFromEnv(name, fallback) { + const rawValue = process.env[name] + if (!rawValue) { + return fallback + } + + const parsed = Number.parseInt(rawValue, 10) + const valid = Number.isInteger(parsed) && parsed >= 1 && parsed <= 65_535 + + if (!valid) { + throw new Error( + `Invalid ${name}="${rawValue}". Expected integer in [1, 65535].`, + ) + } + + return parsed +} + +export const DEMO_PORT = readPortFromEnv( + 'PAGINATION_DEMO_PORT', + DEFAULT_DEMO_PORT, +) +export const API_PORT = readPortFromEnv('PAGINATION_API_PORT', DEFAULT_API_PORT) diff --git a/examples/lit/pagination/index.html b/examples/lit/pagination/index.html new file mode 100644 index 00000000000..5d3fe8e8121 --- /dev/null +++ b/examples/lit/pagination/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Lit Query Pagination Demo + + + + + + diff --git a/examples/lit/pagination/package.json b/examples/lit/pagination/package.json new file mode 100644 index 00000000000..782ab70ad70 --- /dev/null +++ b/examples/lit/pagination/package.json @@ -0,0 +1,19 @@ +{ + "name": "@tanstack/query-example-lit-pagination", + "private": true, + "type": "module", + "scripts": { + "dev": "node ./scripts/dev.mjs", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/lit-query": "^0.2.7", + "@tanstack/query-core": "^5.101.0", + "lit": "^3.3.1" + }, + "devDependencies": { + "typescript": "5.8.3", + "vite": "^6.4.1" + } +} diff --git a/examples/lit/pagination/scripts/dev.mjs b/examples/lit/pagination/scripts/dev.mjs new file mode 100644 index 00000000000..2a58a8da0fd --- /dev/null +++ b/examples/lit/pagination/scripts/dev.mjs @@ -0,0 +1,80 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { API_PORT } from '../config/ports.js' + +const viteCommand = process.platform === 'win32' ? 'vite.cmd' : 'vite' +const cwd = new URL('..', import.meta.url) + +function forwardOutput(prefix, stream, output) { + stream.on('data', (chunk) => { + output.write(`${prefix}${chunk}`) + }) +} + +function start(name, command, args, extraEnv = {}) { + const child = spawn(command, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + ...extraEnv, + }, + }) + + forwardOutput(`[${name}] `, child.stdout, process.stdout) + forwardOutput(`[${name}] `, child.stderr, process.stderr) + + return child +} + +async function stop(child) { + if (!child || child.exitCode !== null) { + return + } + + child.kill('SIGTERM') + await Promise.race([ + once(child, 'exit'), + new Promise((resolve) => setTimeout(resolve, 2000)), + ]) + + if (child.exitCode === null) { + child.kill('SIGKILL') + await Promise.race([ + once(child, 'exit'), + new Promise((resolve) => setTimeout(resolve, 2000)), + ]) + } +} + +async function run() { + const api = start('api', process.execPath, ['./server/index.mjs']) + const web = start('web', viteCommand, [], { + VITE_PAGINATION_API_PORT: String(API_PORT), + }) + + const shutdown = async () => { + await Promise.all([stop(web), stop(api)]) + } + + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + + const [winner] = await Promise.race([ + once(api, 'exit').then(([code]) => ({ name: 'api', code })), + once(web, 'exit').then(([code]) => ({ name: 'web', code })), + ]) + + await shutdown() + + if (winner.code !== 0 && winner.code !== null) { + process.exitCode = winner.code + } else { + process.exitCode = 1 + } +} + +run().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/examples/lit/pagination/server/index.mjs b/examples/lit/pagination/server/index.mjs new file mode 100644 index 00000000000..b9722992326 --- /dev/null +++ b/examples/lit/pagination/server/index.mjs @@ -0,0 +1,340 @@ +import { createServer } from 'node:http' +import { API_PORT } from '../config/ports.js' + +const PAGE_SIZE = 10 +const TOTAL_PROJECTS = 50 +const JSON_CONTENT_TYPE = 'application/json' + +function createSeedProjects() { + return Array.from({ length: TOTAL_PROJECTS }, (_, index) => ({ + id: index + 1, + name: `Project ${index + 1}`, + owner: `Team ${(index % 5) + 1}`, + isFavorite: false, + })) +} + +let projects = createSeedProjects() +let nextProjectId = projects.length + 1 +let totalRequestCount = 0 +let totalMutationCount = 0 +let failNextMutation = false +const perPageRequestCount = new Map() + +function writeJson(res, status, body) { + const payload = JSON.stringify(body) + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET,POST,PATCH,OPTIONS', + 'access-control-allow-headers': 'content-type', + 'content-length': Buffer.byteLength(payload), + }) + res.end(payload) +} + +function parsePositiveInt(rawValue, fallback) { + if (rawValue == null || rawValue === '') { + return fallback + } + + const parsed = Number.parseInt(rawValue, 10) + if (!Number.isInteger(parsed) || parsed < 1) { + return undefined + } + + return parsed +} + +function parseNonNegativeInt(rawValue, fallback) { + if (rawValue == null || rawValue === '') { + return fallback + } + + const parsed = Number.parseInt(rawValue, 10) + if (!Number.isInteger(parsed) || parsed < 0) { + return undefined + } + + return parsed +} + +function resetState() { + projects = createSeedProjects() + nextProjectId = projects.length + 1 + totalRequestCount = 0 + totalMutationCount = 0 + failNextMutation = false + perPageRequestCount.clear() +} + +async function sleep(ms) { + if (!ms || ms <= 0) { + return + } + + await new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +async function readJsonBody(req) { + const contentType = req.headers['content-type'] + if (!contentType || !contentType.startsWith(JSON_CONTENT_TYPE)) { + return { + ok: false, + status: 415, + error: 'Expected application/json request body', + } + } + + const chunks = [] + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + const raw = Buffer.concat(chunks).toString('utf8') + if (!raw) { + return { + ok: false, + status: 400, + error: 'Request body is required', + } + } + + try { + return { + ok: true, + value: JSON.parse(raw), + } + } catch { + return { + ok: false, + status: 400, + error: 'Request body must be valid JSON', + } + } +} + +function buildProjectsPage(page) { + const totalPages = Math.max(1, Math.ceil(projects.length / PAGE_SIZE)) + const boundedPage = Math.min(page, totalPages) + const startIndex = (boundedPage - 1) * PAGE_SIZE + const endIndex = startIndex + PAGE_SIZE + const items = projects.slice(startIndex, endIndex) + + totalRequestCount += 1 + const pageRequests = (perPageRequestCount.get(boundedPage) ?? 0) + 1 + perPageRequestCount.set(boundedPage, pageRequests) + + return { + page: boundedPage, + pageSize: PAGE_SIZE, + totalPages, + totalProjects: projects.length, + hasMore: boundedPage < totalPages, + projects: items, + requestMeta: { + totalRequestCount, + pageRequestCount: pageRequests, + totalMutationCount, + }, + } +} + +function maybeFailMutation(res) { + if (!failNextMutation) { + return false + } + + failNextMutation = false + writeJson(res, 500, { error: 'Forced mutation failure (test)' }) + return true +} + +function validateProjectName(name) { + if (typeof name !== 'string') { + return 'Project name must be a string' + } + + const trimmed = name.trim() + if (trimmed.length < 3) { + return 'Project name must be at least 3 characters' + } + + if (trimmed.length > 60) { + return 'Project name must be 60 characters or fewer' + } + + return null +} + +function validateOwner(owner) { + if (typeof owner !== 'string') { + return 'Owner must be a string' + } + + const trimmed = owner.trim() + if (!trimmed) { + return 'Owner is required' + } + + if (trimmed.length > 40) { + return 'Owner must be 40 characters or fewer' + } + + return null +} + +const server = createServer(async (req, res) => { + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET,POST,PATCH,OPTIONS', + 'access-control-allow-headers': 'content-type', + 'cache-control': 'no-store', + }) + res.end() + return + } + + if (!req.url) { + writeJson(res, 400, { error: 'Missing URL' }) + return + } + + const requestUrl = new URL(req.url, `http://127.0.0.1:${API_PORT}`) + + if (requestUrl.pathname === '/api/projects' && req.method === 'GET') { + const page = parsePositiveInt(requestUrl.searchParams.get('page'), 1) + if (!page) { + writeJson(res, 400, { error: 'Invalid page parameter' }) + return + } + + const delayMs = parseNonNegativeInt(requestUrl.searchParams.get('delay'), 0) + if (delayMs === undefined) { + writeJson(res, 400, { error: 'Invalid delay parameter' }) + return + } + + await sleep(delayMs) + + if (requestUrl.searchParams.get('error') === 'true') { + writeJson(res, 500, { + error: 'Forced server error (test)', + page, + }) + return + } + + writeJson(res, 200, buildProjectsPage(page)) + return + } + + if (requestUrl.pathname === '/api/projects' && req.method === 'POST') { + if (maybeFailMutation(res)) { + return + } + + const payload = await readJsonBody(req) + if (!payload.ok) { + writeJson(res, payload.status, { error: payload.error }) + return + } + + const nameError = validateProjectName(payload.value?.name) + if (nameError) { + writeJson(res, 422, { error: nameError }) + return + } + + const ownerError = validateOwner(payload.value?.owner) + if (ownerError) { + writeJson(res, 422, { error: ownerError }) + return + } + + const project = { + id: nextProjectId, + name: payload.value.name.trim(), + owner: payload.value.owner.trim(), + isFavorite: false, + } + + nextProjectId += 1 + totalMutationCount += 1 + projects = [project, ...projects] + + writeJson(res, 201, { + project, + mutationCount: totalMutationCount, + }) + return + } + + const patchMatch = requestUrl.pathname.match(/^\/api\/projects\/(\d+)$/) + if (patchMatch && req.method === 'PATCH') { + if (maybeFailMutation(res)) { + return + } + + const projectId = Number.parseInt(patchMatch[1], 10) + const projectIndex = projects.findIndex( + (project) => project.id === projectId, + ) + if (projectIndex === -1) { + writeJson(res, 404, { error: `Project ${projectId} was not found` }) + return + } + + const payload = await readJsonBody(req) + if (!payload.ok) { + writeJson(res, payload.status, { error: payload.error }) + return + } + + if (typeof payload.value?.isFavorite !== 'boolean') { + writeJson(res, 422, { error: 'isFavorite must be a boolean' }) + return + } + + const nextProject = { + ...projects[projectIndex], + isFavorite: payload.value.isFavorite, + } + + totalMutationCount += 1 + projects = projects.map((project, index) => + index === projectIndex ? nextProject : project, + ) + + writeJson(res, 200, { + project: nextProject, + mutationCount: totalMutationCount, + }) + return + } + + if ( + requestUrl.pathname === '/api/testing/fail-next-mutation' && + req.method === 'POST' + ) { + failNextMutation = true + writeJson(res, 200, { ok: true }) + return + } + + if (requestUrl.pathname === '/api/reset' && req.method === 'POST') { + resetState() + writeJson(res, 200, { ok: true }) + return + } + + writeJson(res, 404, { error: 'Not found' }) +}) + +server.listen(API_PORT, '127.0.0.1', () => { + console.log(`[api] listening on http://127.0.0.1:${API_PORT}`) +}) diff --git a/examples/lit/pagination/src/api.ts b/examples/lit/pagination/src/api.ts new file mode 100644 index 00000000000..8f4e1cd2091 --- /dev/null +++ b/examples/lit/pagination/src/api.ts @@ -0,0 +1,166 @@ +export type Project = { + id: number + name: string + owner: string + isFavorite: boolean +} + +export type ProjectsPageResponse = { + page: number + pageSize: number + totalPages: number + totalProjects: number + hasMore: boolean + projects: Project[] + requestMeta: { + totalRequestCount: number + pageRequestCount: number + totalMutationCount: number + } +} + +export type CreateProjectInput = { + name: string + owner: string +} + +export type ToggleProjectFavoriteInput = { + id: number + isFavorite: boolean +} + +export type ProjectMutationResponse = { + project: Project + mutationCount: number +} + +export type ProjectsQueryKey = readonly ['projects', number, number, boolean] + +const DEFAULT_API_PORT = 4184 +const configuredApiPort = Number.parseInt( + import.meta.env.VITE_PAGINATION_API_PORT ?? String(DEFAULT_API_PORT), + 10, +) +const API_PORT = Number.isInteger(configuredApiPort) + ? configuredApiPort + : DEFAULT_API_PORT +const API_BASE_URL = `http://127.0.0.1:${API_PORT}` + +function buildProjectsUrl( + page: number, + delayMs: number, + forceError: boolean, +): URL { + const url = new URL('/api/projects', API_BASE_URL) + url.searchParams.set('page', String(page)) + + if (delayMs > 0) { + url.searchParams.set('delay', String(delayMs)) + } + + if (forceError) { + url.searchParams.set('error', 'true') + } + + return url +} + +async function readJsonOrThrow( + response: Response, + fallbackMessage: string, +): Promise { + if (response.ok) { + return (await response.json()) as T + } + + const payload = (await response.json().catch(() => null)) as { + error?: string + } | null + + throw new Error( + payload && typeof payload === 'object' && 'error' in payload + ? String(payload.error ?? fallbackMessage) + : fallbackMessage, + ) +} + +async function requestJson( + url: URL, + init: RequestInit, + fallbackMessage: string, +): Promise { + const response = await fetch(url, init) + return readJsonOrThrow(response, fallbackMessage) +} + +export function projectsQueryKey( + page: number, + delayMs: number, + forceError: boolean, +): ProjectsQueryKey { + return ['projects', page, delayMs, forceError] as const +} + +export async function fetchProjectsPage( + page: number, + delayMs: number, + forceError: boolean, +): Promise { + const response = await fetch(buildProjectsUrl(page, delayMs, forceError)) + return readJsonOrThrow( + response, + `Failed to fetch projects page ${page}`, + ) +} + +export async function createProjectOnServer( + input: CreateProjectInput, +): Promise { + return requestJson( + new URL('/api/projects', API_BASE_URL), + { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(input), + }, + 'Failed to create project', + ) +} + +export async function toggleProjectFavoriteOnServer( + input: ToggleProjectFavoriteInput, +): Promise { + return requestJson( + new URL(`/api/projects/${input.id}`, API_BASE_URL), + { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ isFavorite: input.isFavorite }), + }, + `Failed to update project ${input.id}`, + ) +} + +export async function armNextProjectMutationFailureOnServer(): Promise { + await requestJson<{ ok: true }>( + new URL('/api/testing/fail-next-mutation', API_BASE_URL), + { + method: 'POST', + }, + 'Failed to arm next mutation failure', + ) +} + +export async function resetProjectsApiState(): Promise { + await requestJson<{ ok: true }>( + new URL('/api/reset', API_BASE_URL), + { + method: 'POST', + }, + 'Failed to reset API state', + ) +} diff --git a/examples/lit/pagination/src/main.ts b/examples/lit/pagination/src/main.ts new file mode 100644 index 00000000000..b51b6f28641 --- /dev/null +++ b/examples/lit/pagination/src/main.ts @@ -0,0 +1,585 @@ +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createMutationController, + createQueryController, + keepPreviousData, +} from '@tanstack/lit-query' +import { + armNextProjectMutationFailureOnServer, + createProjectOnServer, + fetchProjectsPage, + projectsQueryKey, + resetProjectsApiState, + toggleProjectFavoriteOnServer, +} from './api' +import type { + CreateQueryOptions, + MutationResultAccessor, + QueryKey, + QueryResultAccessor, +} from '@tanstack/lit-query' +import type { + CreateProjectInput, + Project, + ProjectsPageResponse, + ToggleProjectFavoriteInput, +} from './api' + +type ProjectsCacheSnapshot = Array<[QueryKey, ProjectsPageResponse | undefined]> +type FavoriteMutationContext = { + snapshots: ProjectsCacheSnapshot +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 5_000, + }, + mutations: { + retry: false, + }, + }, +}) + +class PaginationQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } +} + +customElements.define('pagination-query-provider', PaginationQueryProvider) + +class PaginationDemo extends LitElement { + static properties = { + page: { state: true }, + delayMs: { state: true }, + forceErrorMode: { state: true }, + prefetchStatus: { state: true }, + resetError: { state: true }, + draftName: { state: true }, + draftOwner: { state: true }, + mutationControlStatus: { state: true }, + mutationControlError: { state: true }, + } + + private page = 1 + private delayMs = 250 + private forceErrorMode = false + private prefetchStatus = 'idle' + private resetError: string | undefined + private draftName = 'Platform Rollout' + private draftOwner = 'Team 6' + private mutationControlStatus = 'idle' + private mutationControlError: string | undefined + private lastAutoPrefetchPage = 0 + private readonly projectsQueryOptions: CreateQueryOptions< + ProjectsPageResponse, + Error + > + private readonly projectsQuery: QueryResultAccessor< + ProjectsPageResponse, + Error + > + private readonly createProjectMutation: MutationResultAccessor< + Project, + Error, + CreateProjectInput, + unknown + > + private readonly favoriteMutation: MutationResultAccessor< + Project, + Error, + ToggleProjectFavoriteInput, + FavoriteMutationContext + > + + constructor() { + super() + + this.projectsQueryOptions = { + queryKey: projectsQueryKey(this.page, this.delayMs, this.forceErrorMode), + queryFn: () => + fetchProjectsPage(this.page, this.delayMs, this.forceErrorMode), + placeholderData: keepPreviousData, + } + + this.projectsQuery = createQueryController( + this, + this.projectsQueryOptions, + ) + + this.createProjectMutation = createMutationController< + Project, + Error, + CreateProjectInput + >( + this, + { + mutationKey: ['create-project'], + mutationFn: async (input) => { + const response = await createProjectOnServer(input) + return response.project + }, + onMutate: () => { + this.mutationControlStatus = 'idle' + this.mutationControlError = undefined + }, + onSuccess: async () => { + this.page = 1 + this.lastAutoPrefetchPage = 0 + this.prefetchStatus = 'idle' + this.draftName = '' + this.draftOwner = 'Team 6' + this.syncProjectsQueryOptions() + await queryClient.invalidateQueries({ + queryKey: ['projects'], + refetchType: 'none', + }) + await this.projectsQuery.refetch() + }, + }, + queryClient, + ) + + this.favoriteMutation = createMutationController< + Project, + Error, + ToggleProjectFavoriteInput, + FavoriteMutationContext + >( + this, + { + mutationKey: ['toggle-project-favorite'], + mutationFn: async (input) => { + const response = await toggleProjectFavoriteOnServer(input) + return response.project + }, + onMutate: async (variables) => { + this.mutationControlStatus = 'idle' + this.mutationControlError = undefined + await queryClient.cancelQueries({ queryKey: ['projects'] }) + + const snapshots = queryClient.getQueriesData({ + queryKey: ['projects'], + }) + + for (const [key, existing] of snapshots) { + if (!existing) { + continue + } + + queryClient.setQueryData(key, { + ...existing, + projects: existing.projects.map((project) => + project.id === variables.id + ? { ...project, isFavorite: variables.isFavorite } + : project, + ), + }) + } + + return { snapshots } + }, + onError: (_error, _variables, context) => { + for (const [key, snapshot] of context?.snapshots ?? []) { + queryClient.setQueryData(key, snapshot) + } + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: ['projects'] }) + }, + }, + queryClient, + ) + } + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + override updated(): void { + this.maybePrefetchNextPage() + } + + private syncProjectsQueryOptions(): void { + this.projectsQueryOptions.queryKey = projectsQueryKey( + this.page, + this.delayMs, + this.forceErrorMode, + ) + this.projectsQueryOptions.queryFn = () => + fetchProjectsPage(this.page, this.delayMs, this.forceErrorMode) + } + + private refetchForCurrentState(): void { + this.syncProjectsQueryOptions() + void this.projectsQuery.refetch() + } + + private async maybePrefetchNextPage(): Promise { + const query = this.projectsQuery() + const currentData = query.data + + if (!currentData || query.isPlaceholderData || !currentData.hasMore) { + return + } + + if (this.lastAutoPrefetchPage === currentData.page) { + return + } + + this.lastAutoPrefetchPage = currentData.page + await this.prefetchPage(currentData.page + 1) + } + + private onDelayInput(event: Event): void { + const target = event.target as HTMLInputElement + const nextValue = Number.parseInt(target.value, 10) + + if (!Number.isInteger(nextValue) || nextValue < 0) { + return + } + + this.delayMs = nextValue + this.refetchForCurrentState() + } + + private onErrorModeToggle(event: Event): void { + const target = event.target as HTMLInputElement + this.forceErrorMode = target.checked + this.refetchForCurrentState() + } + + private onDraftNameInput(event: Event): void { + const target = event.target as HTMLInputElement + this.draftName = target.value + } + + private onDraftOwnerInput(event: Event): void { + const target = event.target as HTMLInputElement + this.draftOwner = target.value + } + + private async prefetchPage(targetPage: number): Promise { + this.prefetchStatus = `pending:${targetPage}` + + try { + await queryClient.prefetchQuery({ + queryKey: projectsQueryKey( + targetPage, + this.delayMs, + this.forceErrorMode, + ), + queryFn: () => + fetchProjectsPage(targetPage, this.delayMs, this.forceErrorMode), + }) + this.prefetchStatus = `ready:${targetPage}` + } catch (error) { + this.prefetchStatus = `error:${String(error)}` + } + } + + private async prefetchNext(): Promise { + const query = this.projectsQuery() + const currentData = query.data + + if (!currentData?.hasMore) { + this.prefetchStatus = 'skipped:no-next-page' + return + } + + await this.prefetchPage(currentData.page + 1) + } + + private goToPreviousPage(): void { + if (this.page > 1) { + this.page -= 1 + this.refetchForCurrentState() + } + } + + private goToNextPage(): void { + const currentData = this.projectsQuery().data + if (!currentData?.hasMore) { + return + } + + this.page += 1 + this.refetchForCurrentState() + } + + private async resetDemoState(): Promise { + this.resetError = undefined + + try { + await resetProjectsApiState() + this.page = 1 + this.delayMs = 250 + this.forceErrorMode = false + this.prefetchStatus = 'idle' + this.resetError = undefined + this.draftName = 'Platform Rollout' + this.draftOwner = 'Team 6' + this.mutationControlStatus = 'idle' + this.mutationControlError = undefined + this.lastAutoPrefetchPage = 0 + this.syncProjectsQueryOptions() + this.createProjectMutation.reset() + this.favoriteMutation.reset() + await queryClient.resetQueries({ queryKey: ['projects'] }) + await this.projectsQuery.refetch() + } catch (error) { + this.resetError = String(error) + } + } + + private submitCreateProject(): void { + const name = this.draftName.trim() + const owner = this.draftOwner.trim() + + if (!name || !owner) { + return + } + + this.createProjectMutation.mutate({ name, owner }) + } + + private toggleFavorite(project: Project): void { + this.favoriteMutation.mutate({ + id: project.id, + isFavorite: !project.isFavorite, + }) + } + + private async armNextMutationFailure(): Promise { + this.mutationControlError = undefined + + try { + await armNextProjectMutationFailureOnServer() + this.mutationControlStatus = 'armed' + } catch (error) { + this.mutationControlStatus = 'error' + this.mutationControlError = String(error) + } + } + + render() { + const query = this.projectsQuery() + const projects = query.data?.projects ?? [] + const hasMore = query.data?.hasMore ?? false + const createProject = this.createProjectMutation() + const favoriteProject = this.favoriteMutation() + + return html` +
+

TanStack Lit Query Pagination Demo

+

+ Pagination + mutation demo with optimistic favorite toggles, + invalidation, and deterministic server failures. +

+ +
+
query: ${query.status}
+
+ isFetching: ${query.isFetching ? 'yes' : 'no'} +
+
+ isPlaceholderData: ${query.isPlaceholderData ? 'yes' : 'no'} +
+
page: ${this.page}
+
+ response-page: ${query.data?.page ?? '-'} +
+
has-more: ${hasMore ? 'yes' : 'no'}
+
+ total-projects: ${query.data?.totalProjects ?? 0} +
+
+ total-requests: ${query.data?.requestMeta.totalRequestCount ?? 0} +
+
+ page-requests: ${query.data?.requestMeta.pageRequestCount ?? 0} +
+
+ total-mutations: ${query.data?.requestMeta.totalMutationCount ?? 0} +
+
+ prefetch: ${this.prefetchStatus} +
+
+ + ${query.isError + ? html`

${String(query.error)}

` + : null} + ${this.resetError + ? html`

${this.resetError}

` + : null} + +
+ + + + + + + + + + +
+ mutation-control: ${this.mutationControlStatus} +
+ ${this.mutationControlError + ? html`
+ ${this.mutationControlError} +
` + : null} +
+ +
+
+ create-mutation: ${createProject.status} +
+ ${createProject.isError + ? html`
+ ${String(createProject.error)} +
` + : null} + + + + + + + + +
+ +
+
+ favorite-mutation: ${favoriteProject.status} +
+ ${favoriteProject.isError + ? html`
+ ${String(favoriteProject.error)} +
` + : null} +
+ +
+ + +
+ +
    + ${projects.map( + (project) => html` +
  • + ${project.id}: ${project.name} (${project.owner}) + ${project.isFavorite ? 'favorite' : 'standard'} + +
  • + `, + )} +
+
+ ` + } +} + +customElements.define('pagination-demo', PaginationDemo) + +class PaginationDemoRoot extends LitElement { + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + render() { + return html` + + + + ` + } +} + +customElements.define('pagination-demo-root', PaginationDemoRoot) diff --git a/examples/lit/pagination/src/vite-env.d.ts b/examples/lit/pagination/src/vite-env.d.ts new file mode 100644 index 00000000000..3d865d7345e --- /dev/null +++ b/examples/lit/pagination/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_PAGINATION_API_PORT?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/examples/lit/pagination/tsconfig.json b/examples/lit/pagination/tsconfig.json new file mode 100644 index 00000000000..d6e16f3ca50 --- /dev/null +++ b/examples/lit/pagination/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "useDefineForClassFields": false + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/examples/lit/pagination/vite.config.ts b/examples/lit/pagination/vite.config.ts new file mode 100644 index 00000000000..371006b81cf --- /dev/null +++ b/examples/lit/pagination/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import { DEMO_PORT } from './config/ports.js' + +export default defineConfig({ + server: { + host: '127.0.0.1', + port: DEMO_PORT, + strictPort: true, + }, + preview: { + host: '127.0.0.1', + port: DEMO_PORT, + strictPort: true, + }, +}) diff --git a/examples/lit/ssr/CHANGELOG.md b/examples/lit/ssr/CHANGELOG.md new file mode 100644 index 00000000000..f8d37da304b --- /dev/null +++ b/examples/lit/ssr/CHANGELOG.md @@ -0,0 +1,8 @@ +# @tanstack/query-example-lit-ssr + +## 0.0.2 + +### Patch Changes + +- Updated dependencies [[`4082894`](https://github.com/TanStack/query/commit/4082894509f31376ebeb8514cc3e167bbbfc7c46)]: + - @tanstack/lit-query@0.2.0 diff --git a/examples/lit/ssr/README.md b/examples/lit/ssr/README.md new file mode 100644 index 00000000000..3c538d78f13 --- /dev/null +++ b/examples/lit/ssr/README.md @@ -0,0 +1,5 @@ +# Example + +To run this example from the repo root: + +- `pnpm --dir examples/lit/ssr run dev` diff --git a/examples/lit/ssr/config/ports.d.ts b/examples/lit/ssr/config/ports.d.ts new file mode 100644 index 00000000000..23d3eea7080 --- /dev/null +++ b/examples/lit/ssr/config/ports.d.ts @@ -0,0 +1,5 @@ +export const SSR_HOST: string +export const SSR_PORT: number +export const SSR_CONNECT_HOST: string +export const SSR_BASE_URL: string +export const SSR_PUBLIC_ORIGIN: string diff --git a/examples/lit/ssr/config/ports.js b/examples/lit/ssr/config/ports.js new file mode 100644 index 00000000000..a2872fdd9e4 --- /dev/null +++ b/examples/lit/ssr/config/ports.js @@ -0,0 +1,63 @@ +const DEFAULT_SSR_HOST = '127.0.0.1' +const DEFAULT_SSR_PORT = 4174 + +function normalizeUrlHost(host) { + if (host.includes(':') && !host.startsWith('[') && !host.endsWith(']')) { + return `[${host}]` + } + + return host +} + +function resolvePort(name, fallback) { + const value = process.env[name] + if (!value) { + return fallback + } + + const parsedPort = Number.parseInt(value, 10) + const isValidPort = + Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65_535 + + if (!isValidPort) { + throw new Error( + `Invalid ${name} "${value}". Expected an integer between 1 and 65535.`, + ) + } + + return parsedPort +} + +export const SSR_PORT = resolvePort('SSR_PORT', DEFAULT_SSR_PORT) +export const SSR_HOST = process.env.SSR_HOST ?? DEFAULT_SSR_HOST + +function resolveConnectHost(host) { + if (host === '0.0.0.0') { + return '127.0.0.1' + } + + if (host === '::') { + return '[::1]' + } + + return normalizeUrlHost(host) +} + +function resolvePublicOrigin(host, port) { + const explicitOrigin = process.env.SSR_PUBLIC_ORIGIN + if (explicitOrigin) { + const url = new URL(explicitOrigin) + return url.origin + } + + const explicitPublicHost = process.env.SSR_PUBLIC_HOST + const publicHost = explicitPublicHost + ? normalizeUrlHost(explicitPublicHost) + : resolveConnectHost(host) + + return `http://${publicHost}:${port}` +} + +export const SSR_CONNECT_HOST = resolveConnectHost(SSR_HOST) +export const SSR_BASE_URL = `http://${SSR_CONNECT_HOST}:${SSR_PORT}` +export const SSR_PUBLIC_ORIGIN = resolvePublicOrigin(SSR_HOST, SSR_PORT) diff --git a/examples/lit/ssr/index.html b/examples/lit/ssr/index.html new file mode 100644 index 00000000000..7632b49fba0 --- /dev/null +++ b/examples/lit/ssr/index.html @@ -0,0 +1,15 @@ + + + + + + Lit Query SSR Example + + + __SSR_APP_HTML__ + + + + diff --git a/examples/lit/ssr/package.json b/examples/lit/ssr/package.json new file mode 100644 index 00000000000..846cdd904bd --- /dev/null +++ b/examples/lit/ssr/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/query-example-lit-ssr", + "private": true, + "type": "module", + "scripts": { + "dev": "node ./scripts/dev.mjs", + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "@lit-labs/ssr": "^3.3.0", + "@tanstack/lit-query": "^0.2.7", + "@tanstack/query-core": "^5.101.0", + "lit": "^3.3.1" + }, + "devDependencies": { + "@lit-labs/ssr-client": "^1.1.7", + "tsx": "^4.19.0", + "typescript": "5.8.3", + "vite": "^6.4.1" + } +} diff --git a/examples/lit/ssr/scripts/dev.mjs b/examples/lit/ssr/scripts/dev.mjs new file mode 100644 index 00000000000..0280720b806 --- /dev/null +++ b/examples/lit/ssr/scripts/dev.mjs @@ -0,0 +1,51 @@ +import { spawn, spawnSync } from 'node:child_process' +import { once } from 'node:events' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' +const tsxCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx' +const cwd = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +function runBuild() { + const result = spawnSync(npmCommand, ['run', 'build'], { + cwd, + stdio: 'inherit', + }) + + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +async function run() { + runBuild() + + const server = spawn(tsxCommand, ['./server/index.mjs'], { + cwd, + stdio: 'inherit', + }) + + const stopServer = (signal) => { + if (server.exitCode === null) { + server.kill(signal) + } + } + + process.on('SIGINT', () => stopServer('SIGINT')) + process.on('SIGTERM', () => stopServer('SIGTERM')) + + const outcome = await Promise.race([ + once(server, 'error').then(([error]) => { + throw error + }), + once(server, 'exit').then(([code]) => ({ code })), + ]) + + process.exitCode = outcome.code ?? 0 +} + +run().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/examples/lit/ssr/server/index.mjs b/examples/lit/ssr/server/index.mjs new file mode 100644 index 00000000000..3c80d93829e --- /dev/null +++ b/examples/lit/ssr/server/index.mjs @@ -0,0 +1,308 @@ +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { dirname, extname, resolve, sep } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' +import { render } from '@lit-labs/ssr' +import { collectResult } from '@lit-labs/ssr/lib/render-result.js' +import { html } from 'lit' +import { QueryClient, dehydrate } from '@tanstack/lit-query' +import { + SSR_BASE_URL, + SSR_HOST, + SSR_PORT, + SSR_PUBLIC_ORIGIN, +} from '../config/ports.js' +import { + DATA_QUERY_KEY, + DEFAULT_MESSAGE, + QUERY_STALE_TIME, + createDataQueryOptions, +} from '../src/api.js' +import { + getSsrQueryControllerCreationCount, + resetSsrQueryControllerCreationCount, +} from '../src/app.ts' + +const serverDir = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(serverDir, '../dist') +const templatePath = resolve(distDir, 'index.html') + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml; charset=utf-8', +} + +let requestCount = 0 +let failNextDataRequest = false +let nextDataDelayMs = 0 + +const apiCorsHeaders = { + 'access-control-allow-headers': 'content-type', + 'access-control-allow-methods': 'GET,POST,OPTIONS', + 'access-control-allow-origin': '*', +} + +function createBrowserQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: QUERY_STALE_TIME, + }, + }, + }) +} + +function nextDataResponse() { + requestCount += 1 + return { + message: DEFAULT_MESSAGE, + requestCount, + servedAt: new Date().toISOString(), + } +} + +function resetApiState() { + requestCount = 0 + failNextDataRequest = false + nextDataDelayMs = 0 +} + +function parseNonNegativeDelayMs(value) { + const parsedDelayMs = Number.parseInt(value ?? '', 10) + if (!Number.isInteger(parsedDelayMs) || parsedDelayMs < 0) { + return undefined + } + + return parsedDelayMs +} + +async function consumeNextDataDelay() { + const delayMs = nextDataDelayMs + nextDataDelayMs = 0 + + if (delayMs > 0) { + await sleep(delayMs) + } +} + +function consumeFailNextDataRequest() { + if (!failNextDataRequest) { + return false + } + + failNextDataRequest = false + return true +} + +function writeBuffer(res, statusCode, contentType, payload, extraHeaders = {}) { + res.writeHead(statusCode, { + 'cache-control': 'no-store', + 'content-length': payload.byteLength, + 'content-type': contentType, + ...extraHeaders, + }) + res.end(payload) +} + +function writeHtml(res, statusCode, body, extraHeaders = {}) { + const payload = Buffer.from(body) + writeBuffer( + res, + statusCode, + 'text/html; charset=utf-8', + payload, + extraHeaders, + ) +} + +function writeJson(res, statusCode, body) { + const payload = Buffer.from(JSON.stringify(body)) + writeBuffer( + res, + statusCode, + 'application/json; charset=utf-8', + payload, + apiCorsHeaders, + ) +} + +function serializeJsonForHtml(value) { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => { + switch (character) { + case '<': + return '\\u003c' + case '>': + return '\\u003e' + case '&': + return '\\u0026' + case '\u2028': + return '\\u2028' + case '\u2029': + return '\\u2029' + default: + return character + } + }) +} + +async function readTemplate() { + try { + return await readFile(templatePath, 'utf8') + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + throw new Error( + 'Missing built client assets. Run "pnpm --dir examples/lit/ssr run build" from the repo root first.', + ) + } + + throw error + } +} + +async function serveAsset(pathname, res) { + if (!pathname.startsWith('/assets/')) { + return false + } + + const assetPath = resolve(distDir, `.${pathname}`) + const distRootPrefix = `${distDir}${sep}` + if (!assetPath.startsWith(distRootPrefix)) { + return false + } + + try { + const file = await readFile(assetPath) + const contentType = + contentTypes[extname(assetPath)] ?? 'application/octet-stream' + writeBuffer(res, 200, contentType, file) + return true + } catch { + return false + } +} + +async function renderPage(res) { + const queryClient = createBrowserQueryClient() + resetSsrQueryControllerCreationCount(queryClient) + await queryClient.prefetchQuery(createDataQueryOptions(SSR_PUBLIC_ORIGIN)) + + const prefetchedQueryState = queryClient.getQueryState(DATA_QUERY_KEY) + if (prefetchedQueryState?.status !== 'success') { + throw new Error( + 'SSR prefetch did not complete successfully. Refusing to render loading HTML.', + ) + } + + const appHtml = await collectResult( + render( + html``, + ), + ) + + const dehydratedState = dehydrate(queryClient) + const controllerCreationCount = + getSsrQueryControllerCreationCount(queryClient) + if (controllerCreationCount < 1) { + throw new Error('SSR render did not exercise createQueryController.') + } + + const template = await readTemplate() + const htmlDocument = template + .replace('__SSR_APP_HTML__', appHtml) + .replace('__QUERY_STATE_JSON__', serializeJsonForHtml(dehydratedState)) + + writeHtml(res, 200, htmlDocument, { + 'x-ssr-query-controller-created': String(controllerCreationCount), + }) +} + +const server = createServer(async (req, res) => { + const requestUrl = new URL(req.url ?? '/', SSR_BASE_URL) + const method = req.method ?? 'GET' + + if (method === 'OPTIONS' && requestUrl.pathname.startsWith('/api/')) { + res.writeHead(204, { + 'cache-control': 'no-store', + ...apiCorsHeaders, + }) + res.end() + return + } + + if (method === 'GET' && requestUrl.pathname === '/api/data') { + await consumeNextDataDelay() + + if (consumeFailNextDataRequest()) { + writeJson(res, 500, { error: 'Forced data failure (test)' }) + return + } + + writeJson(res, 200, nextDataResponse()) + return + } + + if (method === 'GET' && requestUrl.pathname === '/api/request-count') { + writeJson(res, 200, { count: requestCount }) + return + } + + if (method === 'POST' && requestUrl.pathname === '/api/reset') { + resetApiState() + writeJson(res, 200, { ok: true }) + return + } + + if (method === 'POST' && requestUrl.pathname === '/api/test/fail-next-data') { + failNextDataRequest = true + writeJson(res, 200, { ok: true }) + return + } + + if ( + method === 'POST' && + requestUrl.pathname === '/api/test/delay-next-data' + ) { + const delayMs = parseNonNegativeDelayMs(requestUrl.searchParams.get('ms')) + if (delayMs === undefined) { + writeJson(res, 400, { error: 'Invalid ms query parameter.' }) + return + } + + nextDataDelayMs = delayMs + writeJson(res, 200, { ok: true, delayMs }) + return + } + + if (method === 'GET' && requestUrl.pathname === '/') { + try { + await renderPage(res) + } catch (error) { + console.error('[ssr] render failed:', error) + writeHtml( + res, + 500, + 'SSR render failed.', + ) + } + return + } + + if (method === 'GET' && (await serveAsset(requestUrl.pathname, res))) { + return + } + + writeJson(res, 404, { error: 'Not found' }) +}) + +server.listen(SSR_PORT, SSR_HOST, () => { + console.log( + `[ssr] listening on ${SSR_BASE_URL} (public origin ${SSR_PUBLIC_ORIGIN})`, + ) +}) diff --git a/examples/lit/ssr/src/api.ts b/examples/lit/ssr/src/api.ts new file mode 100644 index 00000000000..eb9656de655 --- /dev/null +++ b/examples/lit/ssr/src/api.ts @@ -0,0 +1,69 @@ +import type { CreateQueryOptions } from '@tanstack/lit-query' + +export const DATA_QUERY_KEY = ['ssr-example-data'] as const +export const DEFAULT_MESSAGE = 'Hello from SSR!' +export const QUERY_STALE_TIME = 30_000 + +export type DataResponse = { + message: string + requestCount: number + servedAt: string +} + +function resolveApiUrl(pathname: string, apiBaseUrl: string): string { + if (!apiBaseUrl) { + return pathname + } + + return new URL(pathname, apiBaseUrl).toString() +} + +async function readJson(response: Response): Promise { + let payload: TResponse | { error?: string } | null = null + + try { + payload = (await response.json()) as TResponse | { error?: string } + } catch { + if (response.ok) { + throw new Error( + `Failed to parse JSON response with status ${response.status}.`, + ) + } + } + + if (!response.ok) { + const errorDetail = + payload && + typeof payload === 'object' && + 'error' in payload && + typeof payload.error === 'string' + ? `: ${payload.error}` + : '' + + throw new Error( + `Request failed with status ${response.status}${errorDetail}`, + ) + } + + return payload as TResponse +} + +export function createDataQueryOptions(apiBaseUrl = '') { + return { + queryKey: DATA_QUERY_KEY, + queryFn: async ({ signal }) => { + const response = await fetch(resolveApiUrl('/api/data', apiBaseUrl), { + signal, + }) + return readJson(response) + }, + retry: false, + staleTime: QUERY_STALE_TIME, + } satisfies CreateQueryOptions< + DataResponse, + Error, + DataResponse, + DataResponse, + typeof DATA_QUERY_KEY + > +} diff --git a/examples/lit/ssr/src/app.ts b/examples/lit/ssr/src/app.ts new file mode 100644 index 00000000000..903bbcb2c9c --- /dev/null +++ b/examples/lit/ssr/src/app.ts @@ -0,0 +1,167 @@ +import { LitElement, css, html } from 'lit' +import { + createQueryController, + type QueryClient, + type QueryResultAccessor, +} from '@tanstack/lit-query' +import { createDataQueryOptions, type DataResponse } from './api.js' + +const ssrQueryControllerCreationCounts = new WeakMap() + +function incrementSsrQueryControllerCreationCount( + queryClient: QueryClient, +): void { + ssrQueryControllerCreationCounts.set( + queryClient, + (ssrQueryControllerCreationCounts.get(queryClient) ?? 0) + 1, + ) +} + +export function resetSsrQueryControllerCreationCount( + queryClient: QueryClient, +): void { + ssrQueryControllerCreationCounts.set(queryClient, 0) +} + +export function getSsrQueryControllerCreationCount( + queryClient: QueryClient, +): number { + return ssrQueryControllerCreationCounts.get(queryClient) ?? 0 +} + +export class SsrApp extends LitElement { + static properties = { + apiBaseUrl: { attribute: 'api-base-url' }, + queryClient: { attribute: false }, + } + + static styles = css` + :host { + color: #1f2937; + display: block; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + max-width: 32rem; + padding: 1.5rem; + } + + article { + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 1rem; + box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); + padding: 1.25rem; + } + + h1 { + font-size: 1.25rem; + margin: 0 0 1rem; + } + + p { + margin: 0.5rem 0; + } + + button { + background: #111827; + border: none; + border-radius: 999px; + color: #ffffff; + cursor: pointer; + font: inherit; + margin-top: 1rem; + padding: 0.65rem 1rem; + } + + button[disabled] { + cursor: wait; + opacity: 0.65; + } + ` + + apiBaseUrl = '' + queryClient?: QueryClient + + private dataQuery?: QueryResultAccessor + + protected override willUpdate(): void { + if (!this.dataQuery && this.queryClient) { + incrementSsrQueryControllerCreationCount(this.queryClient) + this.dataQuery = createQueryController( + this, + createDataQueryOptions(this.apiBaseUrl), + this.queryClient, + ) + } + } + + private readonly handleRefetch = (): void => { + void this.dataQuery?.refetch() + } + + protected override render() { + if (!this.dataQuery) { + return html` +
+

Lit Query SSR

+

Loading...

+
+ ` + } + + const query = this.dataQuery() + + if (query.isPending) { + return html` +
+

Lit Query SSR

+

Loading...

+
+ ` + } + + if (query.isError) { + return html` +
+

Lit Query SSR

+

Error

+

${query.error?.message}

+
+ ` + } + + return html` +
+

Lit Query SSR

+

${query.isFetching ? 'Refreshing' : 'Ready'}

+

${query.data.message}

+

+ Request count: ${query.data.requestCount} +

+

Served at: ${query.data.servedAt}

+ +
+ ` + } +} + +if (!customElements.get('ssr-app')) { + customElements.define('ssr-app', SsrApp) +} + +declare global { + interface HTMLElementTagNameMap { + 'ssr-app': SsrApp + } +} diff --git a/examples/lit/ssr/src/main.ts b/examples/lit/ssr/src/main.ts new file mode 100644 index 00000000000..6c8b52e9e71 --- /dev/null +++ b/examples/lit/ssr/src/main.ts @@ -0,0 +1,63 @@ +import '@lit-labs/ssr-client/lit-element-hydrate-support.js' + +import { QueryClient, hydrate, type DehydratedState } from '@tanstack/lit-query' +import { QUERY_STALE_TIME } from './api.js' + +type HydratableSsrApp = HTMLElement & { + queryClient?: QueryClient +} + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: QUERY_STALE_TIME, + }, + }, + }) +} + +function readDehydratedState(): DehydratedState { + const stateElement = document.getElementById('__QUERY_STATE__') + if (!stateElement) { + throw new Error('Missing dehydrated state script.') + } + + const stateText = stateElement.textContent?.trim() ?? 'null' + return JSON.parse(stateText) as DehydratedState +} + +async function bootstrap() { + if (document.readyState === 'loading') { + await new Promise((resolve) => { + document.addEventListener('DOMContentLoaded', () => resolve(), { + once: true, + }) + }) + } + + const appElement = document.querySelector( + 'ssr-app', + ) as HydratableSsrApp | null + if (!appElement) { + throw new Error('Expected the SSR app element to exist before hydration.') + } + + const queryClient = createQueryClient() + queryClient.mount() + hydrate(queryClient, readDehydratedState()) + appElement.queryClient = queryClient + + window.addEventListener( + 'pagehide', + () => { + queryClient.unmount() + }, + { once: true }, + ) + + await import('./app.js') +} + +void bootstrap() diff --git a/examples/lit/ssr/tsconfig.json b/examples/lit/ssr/tsconfig.json new file mode 100644 index 00000000000..84271844ba9 --- /dev/null +++ b/examples/lit/ssr/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "useDefineForClassFields": false + }, + "include": ["config/**/*.d.ts", "src/**/*.ts", "vite.config.ts"] +} diff --git a/examples/lit/ssr/vite.config.ts b/examples/lit/ssr/vite.config.ts new file mode 100644 index 00000000000..e313e6f288d --- /dev/null +++ b/examples/lit/ssr/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import { SSR_PORT } from './config/ports.js' + +export default defineConfig({ + build: { + target: 'es2022', + }, + server: { + host: '127.0.0.1', + port: SSR_PORT, + strictPort: true, + }, + preview: { + host: '127.0.0.1', + port: SSR_PORT, + strictPort: true, + }, +}) diff --git a/examples/preact/simple/README.md b/examples/preact/simple/README.md index a9d90bf0398..88b7fc571a5 100644 --- a/examples/preact/simple/README.md +++ b/examples/preact/simple/README.md @@ -8,6 +8,8 @@ ## Getting Started +- `pnpm install` - Installs dependencies + - `pnpm dev` - Starts a dev server at http://localhost:5173/ - `pnpm build` - Builds for production, emitting to `dist/` diff --git a/examples/preact/simple/package.json b/examples/preact/simple/package.json index 7ca211acff9..45aef72c406 100644 --- a/examples/preact/simple/package.json +++ b/examples/preact/simple/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/preact-query": "workspace:^", + "@tanstack/preact-query": "^5.101.0", "preact": "^10.28.0" }, "devDependencies": { diff --git a/examples/react/algolia/package.json b/examples/react/algolia/package.json index 864d21f035a..24f31274e3e 100644 --- a/examples/react/algolia/package.json +++ b/examples/react/algolia/package.json @@ -9,13 +9,13 @@ }, "dependencies": { "@algolia/client-search": "5.2.1", - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { - "@tanstack/eslint-plugin-query": "^5.91.5", + "@tanstack/eslint-plugin-query": "^5.101.0", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", "@vitejs/plugin-react": "^4.3.4", diff --git a/examples/react/algolia/src/index.tsx b/examples/react/algolia/src/index.tsx index 5580576a6b6..7a7be0a6925 100644 --- a/examples/react/algolia/src/index.tsx +++ b/examples/react/algolia/src/index.tsx @@ -2,5 +2,6 @@ import ReactDOM from 'react-dom/client' import App from './App' -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/auto-refetching/package.json b/examples/react/auto-refetching/package.json index 49148cdd368..128303786d7 100644 --- a/examples/react/auto-refetching/package.json +++ b/examples/react/auto-refetching/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/basic-graphql-request/package.json b/examples/react/basic-graphql-request/package.json index b048353b962..bf03398edcc 100644 --- a/examples/react/basic-graphql-request/package.json +++ b/examples/react/basic-graphql-request/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "graphql": "^16.9.0", "graphql-request": "^7.1.2", "react": "^19.0.0", diff --git a/examples/react/basic-graphql-request/src/index.tsx b/examples/react/basic-graphql-request/src/index.tsx index 09995939cd8..af4e81a8d4f 100644 --- a/examples/react/basic-graphql-request/src/index.tsx +++ b/examples/react/basic-graphql-request/src/index.tsx @@ -172,5 +172,6 @@ function Post({ ) } -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/basic/package.json b/examples/react/basic/package.json index 4c72dbaffad..08fe16d9386 100644 --- a/examples/react/basic/package.json +++ b/examples/react/basic/package.json @@ -9,15 +9,15 @@ "test:eslint": "eslint ./src" }, "dependencies": { - "@tanstack/query-async-storage-persister": "^5.90.27", - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-query-persist-client": "^5.90.27", + "@tanstack/query-async-storage-persister": "^5.101.0", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", + "@tanstack/react-query-persist-client": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { - "@tanstack/eslint-plugin-query": "^5.91.5", + "@tanstack/eslint-plugin-query": "^5.101.0", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", "@vitejs/plugin-react": "^4.3.4", diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index 2bd37c9f788..1309e81e964 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/examples/react/chat/src/index.tsx b/examples/react/chat/src/index.tsx index 4e95c7b37c3..5ddbcfe5e19 100644 --- a/examples/react/chat/src/index.tsx +++ b/examples/react/chat/src/index.tsx @@ -96,5 +96,6 @@ function Example() { ) } -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/default-query-function/package.json b/examples/react/default-query-function/package.json index ae7ef9f6c3a..2f38d6ba8b4 100644 --- a/examples/react/default-query-function/package.json +++ b/examples/react/default-query-function/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/examples/react/default-query-function/src/index.tsx b/examples/react/default-query-function/src/index.tsx index 72a45b7f3cf..b3cec50224c 100644 --- a/examples/react/default-query-function/src/index.tsx +++ b/examples/react/default-query-function/src/index.tsx @@ -146,5 +146,6 @@ function Post({ ) } -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/devtools-panel/package.json b/examples/react/devtools-panel/package.json index eea79c11574..63af0858e27 100644 --- a/examples/react/devtools-panel/package.json +++ b/examples/react/devtools-panel/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/examples/react/devtools-panel/src/index.tsx b/examples/react/devtools-panel/src/index.tsx index 7278a000e31..bc2ca5e65dd 100644 --- a/examples/react/devtools-panel/src/index.tsx +++ b/examples/react/devtools-panel/src/index.tsx @@ -54,5 +54,6 @@ function Example() { ) } -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/eslint-legacy/package.json b/examples/react/eslint-legacy/package.json index 6606c2c8c38..d4bfab89688 100644 --- a/examples/react/eslint-legacy/package.json +++ b/examples/react/eslint-legacy/package.json @@ -9,15 +9,15 @@ "test:eslint": "ESLINT_USE_FLAT_CONFIG=false eslint ./src/**/*.tsx" }, "dependencies": { - "@tanstack/query-async-storage-persister": "^5.90.27", - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-query-persist-client": "^5.90.27", + "@tanstack/query-async-storage-persister": "^5.101.0", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", + "@tanstack/react-query-persist-client": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { - "@tanstack/eslint-plugin-query": "^5.91.5", + "@tanstack/eslint-plugin-query": "^5.101.0", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", "@vitejs/plugin-react": "^4.3.4", diff --git a/examples/react/eslint-legacy/src/index.tsx b/examples/react/eslint-legacy/src/index.tsx index 59a186be1bb..9ddea366c33 100644 --- a/examples/react/eslint-legacy/src/index.tsx +++ b/examples/react/eslint-legacy/src/index.tsx @@ -157,5 +157,6 @@ function App() { ) } -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/eslint-plugin-demo/eslint.config.js b/examples/react/eslint-plugin-demo/eslint.config.js new file mode 100644 index 00000000000..85fa95bb76b --- /dev/null +++ b/examples/react/eslint-plugin-demo/eslint.config.js @@ -0,0 +1,21 @@ +import pluginQuery from '@tanstack/eslint-plugin-query' +import tseslint from 'typescript-eslint' + +export default [ + ...tseslint.configs.recommended, + ...pluginQuery.configs['flat/recommended-strict'], + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + rules: { + '@tanstack/query/exhaustive-deps': [ + 'error', + { + allowlist: { + variables: ['api'], + types: ['AnalyticsClient'], + }, + }, + ], + }, + }, +] diff --git a/examples/react/eslint-plugin-demo/package.json b/examples/react/eslint-plugin-demo/package.json new file mode 100644 index 00000000000..d9e9f1241aa --- /dev/null +++ b/examples/react/eslint-plugin-demo/package.json @@ -0,0 +1,27 @@ +{ + "name": "@tanstack/query-example-eslint-plugin-demo", + "private": true, + "type": "module", + "scripts": { + "test:eslint": "eslint ./src" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.0", + "react": "^19.0.0" + }, + "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.101.0", + "eslint": "^9.39.0", + "typescript": "5.8.3", + "typescript-eslint": "^8.48.0" + }, + "nx": { + "targets": { + "test:eslint": { + "dependsOn": [ + "^build" + ] + } + } + } +} diff --git a/examples/react/eslint-plugin-demo/src/allowlist-demo.tsx b/examples/react/eslint-plugin-demo/src/allowlist-demo.tsx new file mode 100644 index 00000000000..8960d3c2223 --- /dev/null +++ b/examples/react/eslint-plugin-demo/src/allowlist-demo.tsx @@ -0,0 +1,55 @@ +import { queryOptions } from '@tanstack/react-query' + +export function todosOptions(userId: string) { + const api = useApiClient() + return queryOptions({ + // āœ… passes: 'api' is in allowlist.variables + queryKey: ['todos', userId], + queryFn: () => api.fetchTodos(userId), + }) +} + +export function todosByApiOptions(userId: string) { + const todoApi = useApiClient() + // āŒ fails: 'api' is in allowlist.variables, but this variable is named 'todoApi' + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- The following dependencies are missing in your queryKey: todoApi + return queryOptions({ + queryKey: ['todos', userId], + queryFn: () => todoApi.fetchTodos(userId), + }) +} + +export function todosWithTrackingOptions( + tracker: AnalyticsClient, + userId: string, +) { + return queryOptions({ + // āœ… passes: AnalyticsClient is in allowlist.types + queryKey: ['todos', userId], + queryFn: async () => { + tracker.track('todos') + return fetch(`/api/todos?userId=${userId}`).then((r) => r.json()) + }, + }) +} + +export function todosWithClientOptions(client: ApiClient, userId: string) { + // āŒ fails: AnalyticsClient is in allowlist.types, but this param is typed as ApiClient + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- The following dependencies are missing in your queryKey: client + return queryOptions({ + queryKey: ['todos', userId], + queryFn: () => client.fetchTodos(userId), + }) +} + +interface ApiClient { + fetchTodos: (userId: string) => Promise> +} + +interface AnalyticsClient { + track: (event: string) => Promise +} + +function useApiClient(): ApiClient { + throw new Error('not implemented') +} diff --git a/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx b/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx new file mode 100644 index 00000000000..d992faa55a8 --- /dev/null +++ b/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx @@ -0,0 +1,49 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { todoOptions, todosOptions } from './queries' + +// āœ… passes: consuming imported queryOptions result directly +export function Todos() { + const query = useQuery(todosOptions) + return query.data +} + +// āœ… passes: spreading imported queryOptions result with additional options +export function Todo({ id }: { id: string }) { + const query = useQuery({ + ...todoOptions(id), + select: (data) => data.title, + }) + return query.data +} + +// āœ… passes: referencing queryKey from imported queryOptions result +export function invalidateTodos() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) +} + +// āŒ fails: inline queryKey + queryFn should use queryOptions() +export function TodosBad() { + const query = useQuery( + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer using queryOptions() or infiniteQueryOptions() to co-locate queryKey and queryFn. + { + queryKey: ['todos'], + queryFn: () => fetch('/api/todos').then((r) => r.json()), + }, + ) + return query.data +} + +// āŒ fails: inline queryKey should reference queryOptions result +export function InvalidateTodosBad() { + const queryClient = useQueryClient() + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer referencing a queryKey from a queryOptions() result instead of typing it manually. + queryClient.invalidateQueries({ queryKey: ['todos'] }) +} + +// āŒ fails: inline queryKey as direct parameter +export function GetTodosDataBad() { + const queryClient = useQueryClient() + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer referencing a queryKey from a queryOptions() result instead of typing it manually. + return queryClient.getQueryData(['todos']) +} diff --git a/examples/react/eslint-plugin-demo/src/queries.tsx b/examples/react/eslint-plugin-demo/src/queries.tsx new file mode 100644 index 00000000000..46a86734eb8 --- /dev/null +++ b/examples/react/eslint-plugin-demo/src/queries.tsx @@ -0,0 +1,23 @@ +import { queryOptions } from '@tanstack/react-query' + +// āœ… passes: queryKey and queryFn are co-located via queryOptions() +export const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), +}) + +// āœ… passes: factory function wrapping queryOptions() +export const todoOptions = (id: string) => + queryOptions({ + queryKey: ['todo', id], + queryFn: () => fetchTodo(id), + }) + +function fetchTodos(): Promise> { + throw new Error('not implemented') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function fetchTodo(id: string): Promise<{ id: string; title: string }> { + throw new Error('not implemented') +} diff --git a/examples/react/eslint-plugin-demo/tsconfig.json b/examples/react/eslint-plugin-demo/tsconfig.json new file mode 100644 index 00000000000..afa57cffd5d --- /dev/null +++ b/examples/react/eslint-plugin-demo/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src", "eslint.config.js"] +} diff --git a/examples/react/infinite-query-with-max-pages/package.json b/examples/react/infinite-query-with-max-pages/package.json index 0cfd095d825..cd12dbd5474 100644 --- a/examples/react/infinite-query-with-max-pages/package.json +++ b/examples/react/infinite-query-with-max-pages/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/load-more-infinite-scroll/package.json b/examples/react/load-more-infinite-scroll/package.json index 3135d0f0d06..23f943f9bad 100644 --- a/examples/react/load-more-infinite-scroll/package.json +++ b/examples/react/load-more-infinite-scroll/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/examples/react/nextjs-app-prefetching/app/get-query-client.ts b/examples/react/nextjs-app-prefetching/app/get-query-client.ts index 9ca1cc407e4..61f9b457ded 100644 --- a/examples/react/nextjs-app-prefetching/app/get-query-client.ts +++ b/examples/react/nextjs-app-prefetching/app/get-query-client.ts @@ -1,7 +1,7 @@ import { QueryClient, defaultShouldDehydrateQuery, - isServer, + environmentManager, } from '@tanstack/react-query' function makeQueryClient() { @@ -23,7 +23,7 @@ function makeQueryClient() { let browserQueryClient: QueryClient | undefined = undefined export function getQueryClient() { - if (isServer) { + if (environmentManager.isServer()) { // Server: always make a new query client return makeQueryClient() } else { diff --git a/examples/react/nextjs-app-prefetching/package.json b/examples/react/nextjs-app-prefetching/package.json index 22b38ff60f0..b48c308d6d1 100644 --- a/examples/react/nextjs-app-prefetching/package.json +++ b/examples/react/nextjs-app-prefetching/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/nextjs-suspense-streaming/package.json b/examples/react/nextjs-suspense-streaming/package.json index f7664fe2b2a..0ca611d4b40 100644 --- a/examples/react/nextjs-suspense-streaming/package.json +++ b/examples/react/nextjs-suspense-streaming/package.json @@ -8,9 +8,9 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-query-next-experimental": "^5.91.0", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", + "@tanstack/react-query-next-experimental": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/nextjs-suspense-streaming/src/app/page.tsx b/examples/react/nextjs-suspense-streaming/src/app/page.tsx index 4c17668cbc3..66023daa723 100644 --- a/examples/react/nextjs-suspense-streaming/src/app/page.tsx +++ b/examples/react/nextjs-suspense-streaming/src/app/page.tsx @@ -1,11 +1,11 @@ 'use client' -import { isServer, useSuspenseQuery } from '@tanstack/react-query' +import { environmentManager, useSuspenseQuery } from '@tanstack/react-query' import { Suspense } from 'react' export const runtime = 'edge' // 'nodejs' (default) | 'edge' function getBaseURL() { - if (!isServer) { + if (!environmentManager.isServer()) { return '' } if (process.env.VERCEL_URL) { @@ -30,7 +30,7 @@ function useWaitQuery(props: { wait: number }) { }, }) - return [query.data as string, query] as const + return [query.data, query] as const } function MyComponent(props: { wait: number }) { diff --git a/examples/react/nextjs-suspense-streaming/src/app/providers.tsx b/examples/react/nextjs-suspense-streaming/src/app/providers.tsx index b4990842e54..85615abb2d9 100644 --- a/examples/react/nextjs-suspense-streaming/src/app/providers.tsx +++ b/examples/react/nextjs-suspense-streaming/src/app/providers.tsx @@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider, - isServer, + environmentManager, } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import * as React from 'react' @@ -22,7 +22,7 @@ function makeQueryClient() { let browserQueryClient: QueryClient | undefined = undefined function getQueryClient() { - if (isServer) { + if (environmentManager.isServer()) { return makeQueryClient() } else { if (!browserQueryClient) browserQueryClient = makeQueryClient() diff --git a/examples/react/nextjs/package.json b/examples/react/nextjs/package.json index d13c47dce10..d5eae449f78 100644 --- a/examples/react/nextjs/package.json +++ b/examples/react/nextjs/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/offline/package.json b/examples/react/offline/package.json index ca31de9c58e..e07fb18b391 100644 --- a/examples/react/offline/package.json +++ b/examples/react/offline/package.json @@ -8,11 +8,11 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/query-async-storage-persister": "^5.90.27", + "@tanstack/query-async-storage-persister": "^5.101.0", "@tanstack/react-location": "^3.7.4", - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-query-persist-client": "^5.90.27", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", + "@tanstack/react-query-persist-client": "^5.101.0", "msw": "^2.6.6", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/examples/react/offline/src/index.tsx b/examples/react/offline/src/index.tsx index d6791b40c19..fd0eb555c85 100644 --- a/examples/react/offline/src/index.tsx +++ b/examples/react/offline/src/index.tsx @@ -5,7 +5,8 @@ import { worker } from './api' worker.start() -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render(
diff --git a/examples/react/optimistic-updates-cache/package.json b/examples/react/optimistic-updates-cache/package.json index 270dc20fd31..6249fa08521 100755 --- a/examples/react/optimistic-updates-cache/package.json +++ b/examples/react/optimistic-updates-cache/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/optimistic-updates-ui/package.json b/examples/react/optimistic-updates-ui/package.json index 4bbb41d0371..c22ee02e1c0 100755 --- a/examples/react/optimistic-updates-ui/package.json +++ b/examples/react/optimistic-updates-ui/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/pagination/package.json b/examples/react/pagination/package.json index 7de927db6ef..02de7bcac16 100644 --- a/examples/react/pagination/package.json +++ b/examples/react/pagination/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/playground/package.json b/examples/react/playground/package.json index 30426ada27f..2d819f980ef 100644 --- a/examples/react/playground/package.json +++ b/examples/react/playground/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/examples/react/playground/src/index.tsx b/examples/react/playground/src/index.tsx index 8da861804cf..053585522de 100644 --- a/examples/react/playground/src/index.tsx +++ b/examples/react/playground/src/index.tsx @@ -457,5 +457,6 @@ function patchTodo(todo?: Todo): Promise { }) } -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/prefetching/package.json b/examples/react/prefetching/package.json index 27116bfe88f..073cfa815f8 100644 --- a/examples/react/prefetching/package.json +++ b/examples/react/prefetching/package.json @@ -8,8 +8,8 @@ "start": "next start" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "next": "^16.0.7", "react": "^19.2.1", "react-dom": "^19.2.1" diff --git a/examples/react/react-native/package.json b/examples/react/react-native/package.json index b64739ff222..735c7ff00b1 100644 --- a/examples/react/react-native/package.json +++ b/examples/react/react-native/package.json @@ -14,8 +14,8 @@ "@react-native-community/netinfo": "^11.4.1", "@react-navigation/native": "^6.1.18", "@react-navigation/stack": "^6.4.1", - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "expo": "^52.0.11", "expo-constants": "^17.0.3", "expo-status-bar": "^2.0.0", diff --git a/examples/react/react-router/package.json b/examples/react/react-router/package.json index e45c60f0ef9..2f071a89287 100644 --- a/examples/react/react-router/package.json +++ b/examples/react/react-router/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "localforage": "^1.10.0", "match-sorter": "^6.3.4", "react": "^19.0.0", diff --git a/examples/react/react-router/src/index.tsx b/examples/react/react-router/src/index.tsx index e4037015ea1..01cabf89f45 100644 --- a/examples/react/react-router/src/index.tsx +++ b/examples/react/react-router/src/index.tsx @@ -67,7 +67,8 @@ const router = createBrowserRouter([ ]) const rootElement = document.getElementById('root') -ReactDOM.createRoot(rootElement!).render( +if (!rootElement) throw new Error('Missing #root element') +ReactDOM.createRoot(rootElement).render( diff --git a/examples/react/rick-morty/package.json b/examples/react/rick-morty/package.json index 7a6d3e02ecb..c9b8bcb9d5e 100644 --- a/examples/react/rick-morty/package.json +++ b/examples/react/rick-morty/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^6.25.1", diff --git a/examples/react/shadow-dom/package.json b/examples/react/shadow-dom/package.json index dbe9938d558..22bd20372e7 100644 --- a/examples/react/shadow-dom/package.json +++ b/examples/react/shadow-dom/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/examples/react/shadow-dom/src/main.tsx b/examples/react/shadow-dom/src/main.tsx index cf1b9e53493..2f7ea2400a1 100644 --- a/examples/react/shadow-dom/src/main.tsx +++ b/examples/react/shadow-dom/src/main.tsx @@ -6,30 +6,29 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { DogList } from './DogList' const appRoot = document.getElementById('root') +if (!appRoot) throw new Error('Missing #root element') -if (appRoot) { - const queryClient = new QueryClient() - const shadowRoot = appRoot.attachShadow({ mode: 'open' }) - const root = ReactDOM.createRoot(shadowRoot) +const queryClient = new QueryClient() +const shadowRoot = appRoot.attachShadow({ mode: 'open' }) +const root = ReactDOM.createRoot(shadowRoot) - root.render( - - -
-

Dog Breeds

- -
- -
-
, - ) -} +root.render( + + +
+

Dog Breeds

+ +
+ +
+
, +) diff --git a/examples/react/simple/package.json b/examples/react/simple/package.json index c8b2d9a90b6..ae93cfe1847 100644 --- a/examples/react/simple/package.json +++ b/examples/react/simple/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/examples/react/star-wars/package.json b/examples/react/star-wars/package.json index 1934221daf2..db76467358c 100644 --- a/examples/react/star-wars/package.json +++ b/examples/react/star-wars/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^6.25.1", diff --git a/examples/react/star-wars/src/Film.tsx b/examples/react/star-wars/src/Film.tsx index da5349cfc44..2dda65cc236 100644 --- a/examples/react/star-wars/src/Film.tsx +++ b/examples/react/star-wars/src/Film.tsx @@ -3,8 +3,11 @@ import { useQuery } from '@tanstack/react-query' import { getFilm, getCharacter } from './api' export default function Film() { - let params = useParams() - const filmId = params.filmId! + const { filmId } = useParams() + + if (!filmId) { + return

Invalid film ID

+ } const { data, status } = useQuery({ queryKey: ['film', filmId], @@ -21,7 +24,7 @@ export default function Film() {

{data.opening_crawl}


Characters

- {data.characters.map((character: any) => { + {data.characters.map((character: string) => { const characterUrlParts = character.split('/').filter(Boolean) const characterId = characterUrlParts[characterUrlParts.length - 1] return diff --git a/examples/react/suspense/package.json b/examples/react/suspense/package.json index 18c21379c5c..d4b105c1c71 100644 --- a/examples/react/suspense/package.json +++ b/examples/react/suspense/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.91.2", - "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "font-awesome": "^4.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/examples/react/suspense/src/index.tsx b/examples/react/suspense/src/index.tsx index 2402fa9393a..34047650471 100755 --- a/examples/react/suspense/src/index.tsx +++ b/examples/react/suspense/src/index.tsx @@ -90,5 +90,6 @@ function Example() { ) } -const rootElement = document.getElementById('root') as HTMLElement +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Missing #root element') ReactDOM.createRoot(rootElement).render() diff --git a/examples/solid/basic-graphql-request/README.md b/examples/solid/basic-graphql-request/README.md index 310f37f62fd..93f18812e1c 100644 --- a/examples/solid/basic-graphql-request/README.md +++ b/examples/solid/basic-graphql-request/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` -- `npm run start` +- `pnpm install` +- `pnpm dev` diff --git a/examples/solid/basic/README.md b/examples/solid/basic/README.md index 310f37f62fd..93f18812e1c 100644 --- a/examples/solid/basic/README.md +++ b/examples/solid/basic/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` -- `npm run start` +- `pnpm install` +- `pnpm dev` diff --git a/examples/solid/default-query-function/README.md b/examples/solid/default-query-function/README.md index 310f37f62fd..93f18812e1c 100644 --- a/examples/solid/default-query-function/README.md +++ b/examples/solid/default-query-function/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` -- `npm run start` +- `pnpm install` +- `pnpm dev` diff --git a/examples/solid/offline/README.md b/examples/solid/offline/README.md index 310f37f62fd..93f18812e1c 100644 --- a/examples/solid/offline/README.md +++ b/examples/solid/offline/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` -- `npm run start` +- `pnpm install` +- `pnpm dev` diff --git a/examples/solid/offline/package.json b/examples/solid/offline/package.json index 89d9599fce2..e215b079a63 100644 --- a/examples/solid/offline/package.json +++ b/examples/solid/offline/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/query-async-storage-persister": "^5.90.27", + "@tanstack/query-async-storage-persister": "^5.101.0", "@tanstack/solid-query": "^6.0.0-beta.4", "@tanstack/solid-query-devtools": "^6.0.0-beta.4", "@tanstack/solid-query-persist-client": "^6.0.0-beta.4", diff --git a/examples/solid/simple/README.md b/examples/solid/simple/README.md index 310f37f62fd..93f18812e1c 100644 --- a/examples/solid/simple/README.md +++ b/examples/solid/simple/README.md @@ -2,5 +2,5 @@ To run this example: -- `npm install` -- `npm run start` +- `pnpm install` +- `pnpm dev` diff --git a/examples/solid/simple/package.json b/examples/solid/simple/package.json index 1d666409677..ce7af50b380 100644 --- a/examples/solid/simple/package.json +++ b/examples/solid/simple/package.json @@ -14,7 +14,7 @@ "solid-js": "2.0.0-beta.7" }, "devDependencies": { - "@tanstack/eslint-plugin-query": "^5.91.5", + "@tanstack/eslint-plugin-query": "^5.101.0", "typescript": "5.8.3", "vite": "^6.4.1", "vite-plugin-solid": "3.0.0-next.5" diff --git a/examples/svelte/auto-refetching/package.json b/examples/svelte/auto-refetching/package.json index 7f0407b418e..cb49311f46c 100644 --- a/examples/svelte/auto-refetching/package.json +++ b/examples/svelte/auto-refetching/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/svelte-query": "^6.1.3", - "@tanstack/svelte-query-devtools": "^6.0.4" + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/svelte-query-devtools": "^6.1.34" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.1.0", - "@sveltejs/kit": "^2.42.2", + "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "svelte": "^5.39.3", "svelte-check": "^4.3.1", diff --git a/examples/svelte/basic/package.json b/examples/svelte/basic/package.json index a9c496d3425..f6d34bd8518 100644 --- a/examples/svelte/basic/package.json +++ b/examples/svelte/basic/package.json @@ -8,14 +8,14 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/query-async-storage-persister": "^5.90.27", - "@tanstack/svelte-query": "^6.1.3", - "@tanstack/svelte-query-devtools": "^6.0.4", - "@tanstack/svelte-query-persist-client": "^6.0.25" + "@tanstack/query-async-storage-persister": "^5.101.0", + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/svelte-query-devtools": "^6.1.34", + "@tanstack/svelte-query-persist-client": "^6.1.34" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.1.0", - "@sveltejs/kit": "^2.42.2", + "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "svelte": "^5.39.3", "svelte-check": "^4.3.1", diff --git a/examples/svelte/load-more-infinite-scroll/package.json b/examples/svelte/load-more-infinite-scroll/package.json index aee012642ac..15074a7de2f 100644 --- a/examples/svelte/load-more-infinite-scroll/package.json +++ b/examples/svelte/load-more-infinite-scroll/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/svelte-query": "^6.1.3", - "@tanstack/svelte-query-devtools": "^6.0.4" + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/svelte-query-devtools": "^6.1.34" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.1.0", - "@sveltejs/kit": "^2.42.2", + "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "svelte": "^5.39.3", "svelte-check": "^4.3.1", diff --git a/examples/svelte/optimistic-updates/package.json b/examples/svelte/optimistic-updates/package.json index 6f41007ab71..6da280bc9c8 100644 --- a/examples/svelte/optimistic-updates/package.json +++ b/examples/svelte/optimistic-updates/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/svelte-query": "^6.1.3", - "@tanstack/svelte-query-devtools": "^6.0.4" + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/svelte-query-devtools": "^6.1.34" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.1.0", - "@sveltejs/kit": "^2.42.2", + "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "svelte": "^5.39.3", "svelte-check": "^4.3.1", diff --git a/examples/svelte/playground/package.json b/examples/svelte/playground/package.json index e56d2fd61fe..448b42fb2c0 100644 --- a/examples/svelte/playground/package.json +++ b/examples/svelte/playground/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/svelte-query": "^6.1.3", - "@tanstack/svelte-query-devtools": "^6.0.4" + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/svelte-query-devtools": "^6.1.34" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.1.0", - "@sveltejs/kit": "^2.42.2", + "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "svelte": "^5.39.3", "svelte-check": "^4.3.1", diff --git a/examples/svelte/simple/package.json b/examples/svelte/simple/package.json index 91dded8ef3c..8a4ced340f7 100644 --- a/examples/svelte/simple/package.json +++ b/examples/svelte/simple/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/svelte-query": "^6.1.3", - "@tanstack/svelte-query-devtools": "^6.0.4" + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/svelte-query-devtools": "^6.1.34" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.1.1", diff --git a/examples/svelte/ssr/package.json b/examples/svelte/ssr/package.json index 1f216052f7c..b7d41464b17 100644 --- a/examples/svelte/ssr/package.json +++ b/examples/svelte/ssr/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/svelte-query": "^6.1.3", - "@tanstack/svelte-query-devtools": "^6.0.4" + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/svelte-query-devtools": "^6.1.34" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.1.0", - "@sveltejs/kit": "^2.42.2", + "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "svelte": "^5.39.3", "svelte-check": "^4.3.1", diff --git a/examples/svelte/star-wars/package.json b/examples/svelte/star-wars/package.json index 0d74f5e3027..6f654eef9f6 100644 --- a/examples/svelte/star-wars/package.json +++ b/examples/svelte/star-wars/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/svelte-query": "^6.1.3", - "@tanstack/svelte-query-devtools": "^6.0.4" + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/svelte-query-devtools": "^6.1.34" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.1.0", - "@sveltejs/kit": "^2.42.2", + "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tailwindcss/vite": "^4.1.13", "svelte": "^5.39.3", diff --git a/examples/vue/basic/package.json b/examples/vue/basic/package.json index d8060e40869..81b7be813a1 100644 --- a/examples/vue/basic/package.json +++ b/examples/vue/basic/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-query": "^5.92.12", - "@tanstack/vue-query-devtools": "^6.1.5", + "@tanstack/vue-query": "^5.101.0", + "@tanstack/vue-query-devtools": "^6.1.34", "vue": "^3.4.27" }, "devDependencies": { diff --git a/examples/vue/dependent-queries/package.json b/examples/vue/dependent-queries/package.json index 0fed3267146..fdfb895062d 100644 --- a/examples/vue/dependent-queries/package.json +++ b/examples/vue/dependent-queries/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-query": "^5.92.12", + "@tanstack/vue-query": "^5.101.0", "vue": "^3.4.27" }, "devDependencies": { diff --git a/examples/vue/persister/README.md b/examples/vue/persister/README.md index 7573cb91da8..7bfc1189b22 100644 --- a/examples/vue/persister/README.md +++ b/examples/vue/persister/README.md @@ -1,4 +1,4 @@ -# Basic example +# Persister example To run this example: diff --git a/examples/vue/persister/package.json b/examples/vue/persister/package.json index 4c149ab0900..65c42729c3d 100644 --- a/examples/vue/persister/package.json +++ b/examples/vue/persister/package.json @@ -8,10 +8,10 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/query-core": "^5.91.2", - "@tanstack/query-persist-client-core": "^5.92.4", - "@tanstack/query-sync-storage-persister": "^5.90.27", - "@tanstack/vue-query": "^5.92.12", + "@tanstack/query-core": "^5.101.0", + "@tanstack/query-persist-client-core": "^5.101.0", + "@tanstack/query-sync-storage-persister": "^5.101.0", + "@tanstack/vue-query": "^5.101.0", "idb-keyval": "^6.2.1", "vue": "^3.4.27" }, diff --git a/examples/vue/simple/README.md b/examples/vue/simple/README.md index a9aad379b0f..01f10919199 100644 --- a/examples/vue/simple/README.md +++ b/examples/vue/simple/README.md @@ -1,4 +1,4 @@ -# Basic example +# Simple example To run this example: diff --git a/examples/vue/simple/package.json b/examples/vue/simple/package.json index 1b4c9d89dbc..b41562f8b53 100644 --- a/examples/vue/simple/package.json +++ b/examples/vue/simple/package.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-query": "^5.92.12", - "@tanstack/vue-query-devtools": "^6.1.5", + "@tanstack/vue-query": "^5.101.0", + "@tanstack/vue-query-devtools": "^6.1.34", "vue": "^3.4.27" }, "devDependencies": { diff --git a/integrations/angular-cli-20/package.json b/integrations/angular-cli-20/package.json index 6d9778769e2..10e1d3d64a7 100644 --- a/integrations/angular-cli-20/package.json +++ b/integrations/angular-cli-20/package.json @@ -14,7 +14,7 @@ "@angular/forms": "^20.0.0", "@angular/platform-browser": "^20.0.0", "@angular/router": "^20.0.0", - "@tanstack/angular-query-experimental": "^5.90.28", + "@tanstack/angular-query-experimental": "^5.101.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/integrations/lit-vite/index.html b/integrations/lit-vite/index.html new file mode 100644 index 00000000000..b3974a125e0 --- /dev/null +++ b/integrations/lit-vite/index.html @@ -0,0 +1,13 @@ + + + + + Vite + Lit + + + + + + + + diff --git a/integrations/lit-vite/package.json b/integrations/lit-vite/package.json new file mode 100644 index 00000000000..f5e040bc08a --- /dev/null +++ b/integrations/lit-vite/package.json @@ -0,0 +1,17 @@ +{ + "name": "lit-vite", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "@tanstack/lit-query": "workspace:*", + "@tanstack/query-core": "workspace:*", + "lit": "^3.3.1", + "vite": "^6.4.1" + }, + "devDependencies": { + "typescript": "5.8.3" + } +} diff --git a/integrations/lit-vite/src/main.ts b/integrations/lit-vite/src/main.ts new file mode 100644 index 00000000000..5da031300e8 --- /dev/null +++ b/integrations/lit-vite/src/main.ts @@ -0,0 +1,56 @@ +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createQueryController, +} from '@tanstack/lit-query' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +class LitQueryProvider extends QueryClientProvider { + constructor() { + super() + this.client = queryClient + } + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } +} + +class LitQueryApp extends LitElement { + private readonly query = createQueryController(this, { + queryKey: ['test'], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + return 'Success' + }, + }) + + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this + } + + render() { + const query = this.query() + + if (query.isPending) { + return html`
Loading...
` + } + + if (query.isError) { + return html`
An error has occurred!
` + } + + return html`
${query.data}
` + } +} + +customElements.define('lit-query-provider', LitQueryProvider) +customElements.define('lit-query-app', LitQueryApp) diff --git a/integrations/lit-vite/tsconfig.json b/integrations/lit-vite/tsconfig.json new file mode 100644 index 00000000000..f223644e2f2 --- /dev/null +++ b/integrations/lit-vite/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "useDefineForClassFields": false + }, + "include": ["src/**/*.ts"] +} diff --git a/integrations/lit-vite/vite.config.ts b/integrations/lit-vite/vite.config.ts new file mode 100644 index 00000000000..4f1b25a0c3d --- /dev/null +++ b/integrations/lit-vite/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite' + +export default defineConfig({}) diff --git a/integrations/react-next-15/app/providers.tsx b/integrations/react-next-15/app/providers.tsx index aa52fc1d35c..3ca21b143ef 100644 --- a/integrations/react-next-15/app/providers.tsx +++ b/integrations/react-next-15/app/providers.tsx @@ -2,7 +2,7 @@ 'use client' // Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top -import { QueryClientProvider, isServer } from '@tanstack/react-query' +import { QueryClientProvider, environmentManager } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import type { QueryClient } from '@tanstack/react-query' import { makeQueryClient } from '@/app/make-query-client' @@ -10,7 +10,7 @@ import { makeQueryClient } from '@/app/make-query-client' let browserQueryClient: QueryClient | undefined = undefined function getQueryClient() { - if (isServer) { + if (environmentManager.isServer()) { // Server: always make a new query client return makeQueryClient() } else { diff --git a/integrations/react-next-16/app/providers.tsx b/integrations/react-next-16/app/providers.tsx index aa52fc1d35c..3ca21b143ef 100644 --- a/integrations/react-next-16/app/providers.tsx +++ b/integrations/react-next-16/app/providers.tsx @@ -2,7 +2,7 @@ 'use client' // Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top -import { QueryClientProvider, isServer } from '@tanstack/react-query' +import { QueryClientProvider, environmentManager } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import type { QueryClient } from '@tanstack/react-query' import { makeQueryClient } from '@/app/make-query-client' @@ -10,7 +10,7 @@ import { makeQueryClient } from '@/app/make-query-client' let browserQueryClient: QueryClient | undefined = undefined function getQueryClient() { - if (isServer) { + if (environmentManager.isServer()) { // Server: always make a new query client return makeQueryClient() } else { diff --git a/knip.json b/knip.json index 14032108008..a2293598ff6 100644 --- a/knip.json +++ b/knip.json @@ -1,11 +1,6 @@ { - "$schema": "https://unpkg.com/knip@5/schema.json", - "ignore": [ - ".pnpmfile.cjs", - "scripts/*.{j,t}s", - "**/root.*.config.*", - "**/ts-fixture/file.ts" - ], + "$schema": "https://unpkg.com/knip@6/schema.json", + "ignore": ["scripts/*.{j,t}s", "**/root.*.config.*", "**/ts-fixture/file.ts"], "ignoreDependencies": [ "@types/react", "@types/react-dom", @@ -25,16 +20,18 @@ "ignore": ["**/__testfixtures__/**"] }, "packages/solid-query": { - "ignoreDependencies": ["@solidjs/web", "esbuild"] + "ignoreDependencies": ["esbuild"] }, "packages/solid-query-devtools": { "ignoreDependencies": ["esbuild"] }, "packages/solid-query-persist-client": { - "ignoreDependencies": ["@solidjs/web", "esbuild"] + "ignoreDependencies": ["esbuild"] + }, + "packages/lit-query": { + "ignore": ["src/tests/**"] }, "packages/vue-query": { - "ignore": ["**/__mocks__/**"], "ignoreDependencies": ["vue2", "vue2.7"] } } diff --git a/labeler-config.yml b/labeler-config.yml deleted file mode 100644 index fa4596076b9..00000000000 --- a/labeler-config.yml +++ /dev/null @@ -1,78 +0,0 @@ -'package: angular-query-devtools-experimental': - - changed-files: - - any-glob-to-any-file: 'packages/angular-query-devtools-experimental/**/*' -'package: angular-query-experimental': - - changed-files: - - any-glob-to-any-file: 'packages/angular-query-experimental/**/*' -'package: angular-query-persist-client': - - changed-files: - - any-glob-to-any-file: 'packages/angular-query-persist-client/**/*' -'package: eslint-plugin-query': - - changed-files: - - any-glob-to-any-file: 'packages/eslint-plugin-query/**/*' -'package: preact-query': - - changed-files: - - any-glob-to-any-file: 'packages/preact-query/**/*' -'package: query-async-storage-persister': - - changed-files: - - any-glob-to-any-file: 'packages/query-async-storage-persister/**/*' -'package: query-broadcast-client-experimental': - - changed-files: - - any-glob-to-any-file: 'packages/query-broadcast-client-experimental/**/*' -'package: query-codemods': - - changed-files: - - any-glob-to-any-file: 'packages/query-codemods/**/*' -'package: query-core': - - changed-files: - - any-glob-to-any-file: 'packages/query-core/**/*' -'package: query-devtools': - - changed-files: - - any-glob-to-any-file: 'packages/query-devtools/**/*' -'package: query-persist-client-core': - - changed-files: - - any-glob-to-any-file: 'packages/query-persist-client-core/**/*' -'package: query-sync-storage-persister': - - changed-files: - - any-glob-to-any-file: 'packages/query-sync-storage-persister/**/*' -'package: query-test-utils': - - changed-files: - - any-glob-to-any-file: 'packages/query-test-utils/**/*' -'package: react-query': - - changed-files: - - any-glob-to-any-file: 'packages/react-query/**/*' -'package: react-query-devtools': - - changed-files: - - any-glob-to-any-file: 'packages/react-query-devtools/**/*' -'package: react-query-next-experimental': - - changed-files: - - any-glob-to-any-file: 'packages/react-query-next-experimental/**/*' -'package: react-query-persist-client': - - changed-files: - - any-glob-to-any-file: 'packages/react-query-persist-client/**/*' -'package: solid-query': - - changed-files: - - any-glob-to-any-file: 'packages/solid-query/**/*' -'package: solid-query-devtools': - - changed-files: - - any-glob-to-any-file: 'packages/solid-query-devtools/**/*' -'package: solid-query-persist-client': - - changed-files: - - any-glob-to-any-file: 'packages/solid-query-persist-client/**/*' -'package: svelte-query': - - changed-files: - - any-glob-to-any-file: 'packages/svelte-query/**/*' -'package: svelte-query-devtools': - - changed-files: - - any-glob-to-any-file: 'packages/svelte-query-devtools/**/*' -'package: svelte-query-persist-client': - - changed-files: - - any-glob-to-any-file: 'packages/svelte-query-persist-client/**/*' -'package: vue-query': - - changed-files: - - any-glob-to-any-file: 'packages/vue-query/**/*' -'package: vue-query-devtools': - - changed-files: - - any-glob-to-any-file: 'packages/vue-query-devtools/**/*' -'documentation': - - changed-files: - - any-glob-to-any-file: 'docs/**/*' diff --git a/nx.json b/nx.json index 7fabda994ec..143d6f40de3 100644 --- a/nx.json +++ b/nx.json @@ -62,6 +62,7 @@ }, "test:types": { "cache": true, + "dependsOn": ["^compile"], "inputs": ["default", "^production"] }, "build": { diff --git a/package.json b/package.json index a11e592f3b4..eaa0cb85530 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "type": "git", "url": "git+https://github.com/TanStack/query.git" }, - "packageManager": "pnpm@10.24.0", + "packageManager": "pnpm@11.1.0", + "engines": { + "pnpm": ">=11.0.0" + }, "type": "module", "scripts": { "clean": "pnpm --filter \"./packages/**\" run clean", @@ -19,7 +22,7 @@ "test:lib:dev": "pnpm run test:lib && nx watch --all -- pnpm run test:lib", "test:build": "nx affected --target=test:build --exclude=examples/**", "test:types": "nx affected --target=test:types --exclude=examples/**", - "test:knip": "knip", + "test:knip": "knip --treat-config-hints-as-errors", "test:docs": "node scripts/verify-links.ts", "build": "nx affected --target=build --exclude=examples/** --exclude=integrations/**", "build:all": "nx run-many --target=build --exclude=examples/** --exclude=integrations/**", @@ -41,11 +44,11 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", + "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.29.8", "@cspell/eslint-plugin": "^9.2.1", "@eslint-react/eslint-plugin": "^2.0.1", "@size-limit/preset-small-lib": "^12.0.0", - "@svitejs/changesets-changelog-github-compact": "^1.2.0", "@tanstack/eslint-config": "0.3.2", "@tanstack/typedoc-config": "0.3.1", "@tanstack/vite-config": "0.4.3", @@ -59,7 +62,7 @@ "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^6.1.1", "jsdom": "^27.0.0", - "knip": "^5.63.1", + "knip": "^6.0.2", "markdown-link-extractor": "^4.0.2", "nx": "22.1.3", "premove": "^4.0.0", @@ -82,26 +85,5 @@ "typescript60": "npm:typescript@6.0.1-rc", "vite": "^6.4.1", "vitest": "^4.0.18" - }, - "pnpm": { - "overrides": { - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@types/node": "^22.15.3", - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/rule-tester": "8.57.0", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/type-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", - "typescript-eslint": "8.57.0", - "vite": "^6.4.1", - "esbuild": "^0.27.2" - } } } diff --git a/packages/angular-query-experimental/CHANGELOG.md b/packages/angular-query-experimental/CHANGELOG.md index 82c7b7a343c..94055e7f773 100644 --- a/packages/angular-query-experimental/CHANGELOG.md +++ b/packages/angular-query-experimental/CHANGELOG.md @@ -1,5 +1,218 @@ # @tanstack/angular-query-experimental +## 5.101.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.101.0 + +## 5.100.14 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.14 + +## 5.100.13 + +### Patch Changes + +- Updated dependencies [[`d423168`](https://github.com/TanStack/query/commit/d423168f6261a5cb3d353e53b27c8150cc271151)]: + - @tanstack/query-core@5.100.13 + +## 5.100.12 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.12 + +## 5.100.11 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.11 + +## 5.100.10 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.10 + +## 5.100.9 + +### Patch Changes + +- Add theme option support to Angular floating devtools. ([#10609](https://github.com/TanStack/query/pull/10609)) + +- Updated dependencies [[`fcee7bd`](https://github.com/TanStack/query/commit/fcee7bdc429385ae8ffa224fa8a7a9ec7b8ee380)]: + - @tanstack/query-core@5.100.9 + +## 5.100.8 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.8 + +## 5.100.7 + +### Patch Changes + +- docs(devtools): align logo, panel, and 'buttonPosition' union descriptions across docs and JSDoc ([#10617](https://github.com/TanStack/query/pull/10617)) + +- Updated dependencies []: + - @tanstack/query-core@5.100.7 + +## 5.100.6 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.6 + +## 5.100.5 + +### Patch Changes + +- Updated dependencies [[`a53ef97`](https://github.com/TanStack/query/commit/a53ef97f87decb8ea2431710f5199431d3c94c8d)]: + - @tanstack/query-core@5.100.5 + +## 5.100.4 + +### Patch Changes + +- fix(devtools): change onClose callback type from () => unknown to () => void ([#10118](https://github.com/TanStack/query/pull/10118)) + +- Updated dependencies []: + - @tanstack/query-core@5.100.4 + +## 5.100.3 + +### Patch Changes + +- Updated dependencies [[`f85d825`](https://github.com/TanStack/query/commit/f85d825e02efbbff02e2081528ed28f5e5382f7a)]: + - @tanstack/query-core@5.100.3 + +## 5.100.2 + +### Patch Changes + +- Updated dependencies [[`ea4497e`](https://github.com/TanStack/query/commit/ea4497e8aa00d8c1c3a36fb1e17563a889d6ab31), [`d6a7bf3`](https://github.com/TanStack/query/commit/d6a7bf3e3e024c1a77d0536813238cc8007a5fa7), [`645d5d1`](https://github.com/TanStack/query/commit/645d5d130f5e8017cb1bf1a37987f7b980aed705)]: + - @tanstack/query-core@5.100.2 + +## 5.100.1 + +### Patch Changes + +- Updated dependencies [[`1bb0d23`](https://github.com/TanStack/query/commit/1bb0d234280fd4ae1725c439088426a20593a8df)]: + - @tanstack/query-core@5.100.1 + +## 5.100.0 + +### Patch Changes + +- Updated dependencies [[`6540a41`](https://github.com/TanStack/query/commit/6540a4126b1c087d86d64525e78f32d9920dcd31)]: + - @tanstack/query-core@5.100.0 + +## 5.99.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.2 + +## 5.99.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.1 + +## 5.99.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.0 + +## 5.98.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.98.0 + +## 5.97.0 + +### Patch Changes + +- Updated dependencies [[`2bfb12c`](https://github.com/TanStack/query/commit/2bfb12cc44f1d8495106136e4ddacb817135f8f9)]: + - @tanstack/query-core@5.97.0 + +## 5.96.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.2 + +## 5.96.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.1 + +## 5.96.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.0 + +## 5.95.2 + +### Patch Changes + +- Updated dependencies [[`cd5a35b`](https://github.com/TanStack/query/commit/cd5a35b328837781aa4f9305bb2bd7877ca934e9)]: + - @tanstack/query-core@5.95.2 + +## 5.95.1 + +### Patch Changes + +- Updated dependencies [[`1f1775c`](https://github.com/TanStack/query/commit/1f1775ca92f2b6c035682947ff3b3424804ff31a)]: + - @tanstack/query-core@5.95.1 + +## 5.95.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]: + - @tanstack/query-core@5.94.5 + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + +- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]: + - @tanstack/query-core@5.94.4 + ## 5.90.28 ### Patch Changes diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index 6f51f402b66..a566a21079e 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/angular-query-experimental", - "version": "5.90.28", + "version": "5.101.0", "description": "Signals for managing, caching and syncing asynchronous and remote data in Angular", "author": "Arnoud de Vries", "license": "MIT", diff --git a/packages/angular-query-experimental/src/__tests__/infinite-query-options.test-d.ts b/packages/angular-query-experimental/src/__tests__/infinite-query-options.test-d.ts index 4a7ce532bc2..d50b8c6b814 100644 --- a/packages/angular-query-experimental/src/__tests__/infinite-query-options.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/infinite-query-options.test-d.ts @@ -1,4 +1,5 @@ -import { assertType, describe, expectTypeOf, it, test } from 'vitest' +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' import { QueryClient, dataTagSymbol } from '@tanstack/query-core' import { infiniteQueryOptions } from '../infinite-query-options' import { injectInfiniteQuery } from '../inject-infinite-query' @@ -11,9 +12,10 @@ import type { describe('infiniteQueryOptions', () => { it('should not allow excess properties', () => { + const key = queryKey() assertType( infiniteQueryOptions({ - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve('data'), getNextPageParam: () => 1, initialPageParam: 1, @@ -23,8 +25,9 @@ describe('infiniteQueryOptions', () => { ) }) it('should infer types for callbacks', () => { + const key = queryKey() infiniteQueryOptions({ - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve('data'), staleTime: 1000, getNextPageParam: () => 1, @@ -35,8 +38,9 @@ describe('infiniteQueryOptions', () => { }) }) it('should work when passed to useInfiniteQuery', () => { + const key = queryKey() const options = infiniteQueryOptions({ - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, @@ -51,8 +55,9 @@ describe('infiniteQueryOptions', () => { }) it('should work when passed to fetchInfiniteQuery', async () => { + const key = queryKey() const options = infiniteQueryOptions({ - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, @@ -63,61 +68,66 @@ describe('infiniteQueryOptions', () => { expectTypeOf(data).toEqualTypeOf>() }) it('should tag the queryKey with the result type of the QueryFn', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const key = queryKey() + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: key, queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf>() }) it('should tag the queryKey even if no promise is returned', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const key = queryKey() + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: key, queryFn: () => 'string', getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf>() }) it('should tag the queryKey with the result type of the QueryFn if select is used', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const key = queryKey() + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: key, queryFn: () => Promise.resolve('string'), select: (data) => data.pages, getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf>() }) it('should return the proper type when passed to getQueryData', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const key = queryKey() + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: key, queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, }) const queryClient = new QueryClient() - const data = queryClient.getQueryData(queryKey) + const data = queryClient.getQueryData(tagged) expectTypeOf(data).toEqualTypeOf< InfiniteData | undefined >() }) it('should properly type when passed to setQueryData', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const key = queryKey() + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: key, queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, }) const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, (prev) => { + const data = queryClient.setQueryData(tagged, (prev) => { expectTypeOf(prev).toEqualTypeOf< InfiniteData | undefined >() @@ -129,10 +139,11 @@ describe('infiniteQueryOptions', () => { >() }) - test('should not be allowed to be passed to non-infinite query functions', () => { + it('should not be allowed to be passed to non-infinite query functions', () => { + const key = queryKey() const queryClient = new QueryClient() const options = infiniteQueryOptions({ - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, @@ -155,10 +166,11 @@ describe('infiniteQueryOptions', () => { ) }) - test('allow optional initialData function', () => { + it('should allow optional initialData function', () => { + const key = queryKey() const initialData: { example: boolean } | undefined = { example: true } const queryOptions = infiniteQueryOptions({ - queryKey: ['example'], + queryKey: key, queryFn: () => initialData, initialData: initialData ? () => ({ pages: [initialData], pageParams: [] }) @@ -166,17 +178,18 @@ describe('infiniteQueryOptions', () => { getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryOptions.initialData).toMatchTypeOf< + expectTypeOf(queryOptions.initialData).toExtend< | InitialDataFunction> | InfiniteData<{ example: boolean }, number> | undefined >() }) - test('allow optional initialData object', () => { + it('should allow optional initialData object', () => { + const key = queryKey() const initialData: { example: boolean } | undefined = { example: true } const queryOptions = infiniteQueryOptions({ - queryKey: ['example'], + queryKey: key, queryFn: () => initialData, initialData: initialData ? { pages: [initialData], pageParams: [] } @@ -184,7 +197,7 @@ describe('infiniteQueryOptions', () => { getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryOptions.initialData).toMatchTypeOf< + expectTypeOf(queryOptions.initialData).toExtend< | InitialDataFunction> | InfiniteData<{ example: boolean }, number> | undefined diff --git a/packages/angular-query-experimental/src/__tests__/infinite-query-options.test.ts b/packages/angular-query-experimental/src/__tests__/infinite-query-options.test.ts index 225a63f3b13..9459d96839f 100644 --- a/packages/angular-query-experimental/src/__tests__/infinite-query-options.test.ts +++ b/packages/angular-query-experimental/src/__tests__/infinite-query-options.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' import { infiniteQueryOptions } from '../infinite-query-options' import type { CreateInfiniteQueryOptions } from '../types' describe('infiniteQueryOptions', () => { it('should return the object received as a parameter without any modification.', () => { + const key = queryKey() const object: CreateInfiniteQueryOptions = { - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve(5), getNextPageParam: () => null, initialPageParam: null, diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts index 7ec133adfb1..59bc53d496b 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing' -import { afterEach, beforeEach, describe, expectTypeOf, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expectTypeOf, it, vi } from 'vitest' import { provideZonelessChangeDetection } from '@angular/core' -import { sleep } from '@tanstack/query-test-utils' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' import type { InfiniteData } from '@tanstack/query-core' @@ -23,10 +23,11 @@ describe('injectInfiniteQuery', () => { vi.useRealTimers() }) - test('should narrow type after isSuccess', () => { + it('should narrow type after isSuccess', () => { + const key = queryKey() const query = TestBed.runInInjectionContext(() => { return injectInfiniteQuery(() => ({ - queryKey: ['infiniteQuery'], + queryKey: key, queryFn: ({ pageParam }) => sleep(0).then(() => 'data on page ' + pageParam), initialPageParam: 0, diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts index 7873d5261ca..f1f38acc7a6 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts @@ -1,9 +1,13 @@ import { TestBed } from '@angular/core/testing' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { Injector, provideZonelessChangeDetection } from '@angular/core' -import { sleep } from '@tanstack/query-test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + Component, + Injector, + provideZonelessChangeDetection, +} from '@angular/core' +import { render } from '@testing-library/angular' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' -import { expectSignals } from './test-utils' describe('injectInfiniteQuery', () => { let queryClient: QueryClient @@ -23,62 +27,102 @@ describe('injectInfiniteQuery', () => { vi.useRealTimers() }) - test('should properly execute infinite query', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectInfiniteQuery(() => ({ - queryKey: ['infiniteQuery'], + it('should properly execute infinite query', async () => { + const key = queryKey() + + @Component({ + template: ` +
status: {{ query.status() }}
+
pages: {{ query.data()?.pages?.join(', ') ?? 'none' }}
+ `, + }) + class Page { + readonly query = injectInfiniteQuery(() => ({ + queryKey: key, queryFn: ({ pageParam }) => sleep(10).then(() => 'data on page ' + pageParam), initialPageParam: 0, getNextPageParam: () => 12, })) - }) + } - expectSignals(query, { - data: undefined, - status: 'pending', - }) + const rendered = await render(Page) + + expect(rendered.getByText('status: pending')).toBeInTheDocument() + expect(rendered.getByText('pages: none')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('status: success')).toBeInTheDocument() + expect(rendered.getByText('pages: data on page 0')).toBeInTheDocument() + + rendered.fixture.componentInstance.query.fetchNextPage() - expectSignals(query, { - data: { - pageParams: [0], - pages: ['data on page 0'], - }, - status: 'success', + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('status: success')).toBeInTheDocument() + expect( + rendered.getByText('pages: data on page 0, data on page 12'), + ).toBeInTheDocument() + }) + + it('should reject and update signal', async () => { + const key = queryKey() + + @Component({ + template: ` +
status: {{ query.status() }}
+
pages: {{ query.data()?.pages?.join(', ') ?? 'none' }}
+
error: {{ query.error()?.message ?? 'none' }}
+
isError: {{ query.isError() }}
+
failureCount: {{ query.failureCount() }}
+ `, }) + class Page { + readonly query = injectInfiniteQuery(() => ({ + retry: false, + queryKey: key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Some error'))), + initialPageParam: 0, + getNextPageParam: () => 12, + })) + } + + const rendered = await render(Page) - void query.fetchNextPage() + expect(rendered.getByText('status: pending')).toBeInTheDocument() + expect(rendered.getByText('pages: none')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() - expectSignals(query, { - data: { - pageParams: [0, 12], - pages: ['data on page 0', 'data on page 12'], - }, - status: 'success', - }) + expect(rendered.getByText('status: error')).toBeInTheDocument() + expect(rendered.getByText('pages: none')).toBeInTheDocument() + expect(rendered.getByText('error: Some error')).toBeInTheDocument() + expect(rendered.getByText('isError: true')).toBeInTheDocument() + expect(rendered.getByText('failureCount: 1')).toBeInTheDocument() }) describe('injection context', () => { - test('throws NG0203 with descriptive error outside injection context', () => { + it('should throw NG0203 with descriptive error outside injection context', () => { + const key = queryKey() expect(() => { injectInfiniteQuery(() => ({ - queryKey: ['injectionContextError'], + queryKey: key, queryFn: ({ pageParam }) => sleep(0).then(() => 'data on page ' + pageParam), initialPageParam: 0, getNextPageParam: () => 12, })) - }).toThrowError(/NG0203(.*?)injectInfiniteQuery/) + }).toThrow(/NG0203(.*?)injectInfiniteQuery/) }) - test('can be used outside injection context when passing an injector', () => { + it('should be usable outside injection context when passing an injector', () => { + const key = queryKey() const query = injectInfiniteQuery( () => ({ - queryKey: ['manualInjector'], + queryKey: key, queryFn: ({ pageParam }) => sleep(0).then(() => 'data on page ' + pageParam), initialPageParam: 0, diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts index 329ef6d9e31..db1f7aeea11 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts @@ -1,7 +1,12 @@ import { TestBed } from '@angular/core/testing' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { Injector, provideZonelessChangeDetection } from '@angular/core' -import { sleep } from '@tanstack/query-test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + Component, + Injector, + provideZonelessChangeDetection, +} from '@angular/core' +import { render } from '@testing-library/angular' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, injectIsFetching, @@ -28,30 +33,71 @@ describe('injectIsFetching', () => { vi.useRealTimers() }) - test('Returns number of fetching queries', async () => { - const isFetching = TestBed.runInInjectionContext(() => { - injectQuery(() => ({ - queryKey: ['isFetching1'], + it('should return the number of fetching queries', async () => { + const key = queryKey() + + @Component({ + template: `
fetching: {{ isFetching() }}
`, + }) + class Page { + readonly query = injectQuery(() => ({ + queryKey: key, queryFn: () => sleep(100).then(() => 'Some data'), })) - return injectIsFetching() + readonly isFetching = injectIsFetching() + } + + const rendered = await render(Page) + + expect(rendered.getByText('fetching: 0')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + rendered.fixture.detectChanges() + expect(rendered.getByText('fetching: 1')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(101) + rendered.fixture.detectChanges() + expect(rendered.getByText('fetching: 0')).toBeInTheDocument() + }) + + it('should be able to filter by queryKey', async () => { + const key1 = queryKey() + const key2 = queryKey() + + @Component({ + template: `
fetching: {{ isFetching() }}
`, }) + class Page { + readonly query1 = injectQuery(() => ({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 'test1'), + })) + readonly query2 = injectQuery(() => ({ + queryKey: key2, + queryFn: () => sleep(100).then(() => 'test2'), + })) + readonly isFetching = injectIsFetching({ queryKey: key1 }) + } + + const rendered = await render(Page) + + await vi.advanceTimersByTimeAsync(0) + rendered.fixture.detectChanges() + expect(rendered.getByText('fetching: 1')).toBeInTheDocument() - expect(isFetching()).toStrictEqual(0) - await vi.advanceTimersByTimeAsync(1) - expect(isFetching()).toStrictEqual(1) - await vi.advanceTimersByTimeAsync(100) - expect(isFetching()).toStrictEqual(0) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('fetching: 0')).toBeInTheDocument() }) describe('injection context', () => { - test('throws NG0203 with descriptive error outside injection context', () => { + it('should throw NG0203 with descriptive error outside injection context', () => { expect(() => { injectIsFetching() - }).toThrowError(/NG0203(.*?)injectIsFetching/) + }).toThrow(/NG0203(.*?)injectIsFetching/) }) - test('can be used outside injection context when passing an injector', () => { + it('should be usable outside injection context when passing an injector', () => { expect( injectIsFetching(undefined, { injector: TestBed.inject(Injector), diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts index 5a4694cb854..66fe678d03a 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts @@ -1,7 +1,12 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TestBed } from '@angular/core/testing' -import { Injector, provideZonelessChangeDetection } from '@angular/core' -import { sleep } from '@tanstack/query-test-utils' +import { + Component, + Injector, + provideZonelessChangeDetection, +} from '@angular/core' +import { render } from '@testing-library/angular' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, injectIsMutating, @@ -28,36 +33,76 @@ describe('injectIsMutating', () => { vi.useRealTimers() }) - test('should properly return isMutating state', async () => { - const [mutation, isMutating] = TestBed.runInInjectionContext(() => [ - injectMutation(() => ({ - mutationKey: ['isMutating1'], + it('should properly return isMutating state', async () => { + const key = queryKey() + + @Component({ + template: `
mutating: {{ isMutating() }}
`, + }) + class Page { + readonly mutation = injectMutation(() => ({ + mutationKey: key, mutationFn: (params: { par1: string }) => sleep(10).then(() => params), - })), - injectIsMutating(), - ]) + })) + readonly isMutating = injectIsMutating() + } + + const rendered = await render(Page) + + expect(rendered.getByText('mutating: 0')).toBeInTheDocument() + + rendered.fixture.componentInstance.mutation.mutate({ par1: 'par1' }) + + await vi.advanceTimersByTimeAsync(0) + rendered.fixture.detectChanges() + expect(rendered.getByText('mutating: 1')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('mutating: 0')).toBeInTheDocument() + }) - expect(isMutating()).toBe(0) + it('should be able to filter by mutationKey', async () => { + const key1 = queryKey() + const key2 = queryKey() - mutation.mutate({ - par1: 'par1', + @Component({ + template: `
mutating: {{ isMutating() }}
`, }) + class Page { + readonly mutation1 = injectMutation(() => ({ + mutationKey: key1, + mutationFn: () => sleep(10).then(() => 'data1'), + })) + readonly mutation2 = injectMutation(() => ({ + mutationKey: key2, + mutationFn: () => sleep(100).then(() => 'data2'), + })) + readonly isMutating = injectIsMutating({ mutationKey: key1 }) + } + + const rendered = await render(Page) + + rendered.fixture.componentInstance.mutation1.mutate() + rendered.fixture.componentInstance.mutation2.mutate() - expect(isMutating()).toBe(0) await vi.advanceTimersByTimeAsync(0) - expect(isMutating()).toBe(1) + rendered.fixture.detectChanges() + expect(rendered.getByText('mutating: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) - expect(isMutating()).toBe(0) + rendered.fixture.detectChanges() + expect(rendered.getByText('mutating: 0')).toBeInTheDocument() }) describe('injection context', () => { - test('throws NG0203 with descriptive error outside injection context', () => { + it('should throw NG0203 with descriptive error outside injection context', () => { expect(() => { injectIsMutating() - }).toThrowError(/NG0203(.*?)injectIsMutating/) + }).toThrow(/NG0203(.*?)injectIsMutating/) }) - test('can be used outside injection context when passing an injector', () => { + it('should be usable outside injection context when passing an injector', () => { expect( injectIsMutating(undefined, { injector: TestBed.inject(Injector), diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-restoring.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-restoring.test.ts index 9c06b0863ba..72ca01f9d69 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-restoring.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-restoring.test.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing' -import { describe, expect, test } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { Injector, provideZonelessChangeDetection, signal } from '@angular/core' import { QueryClient, @@ -11,16 +11,17 @@ import { describe('injectIsRestoring', () => { let queryClient: QueryClient - test('returns false by default when provideIsRestoring is not used', () => { + beforeEach(() => { queryClient = new QueryClient() - TestBed.configureTestingModule({ providers: [ provideZonelessChangeDetection(), provideTanStackQuery(queryClient), ], }) + }) + it('should return false by default when provideIsRestoring is not used', () => { const isRestoring = TestBed.runInInjectionContext(() => { return injectIsRestoring() }) @@ -28,16 +29,11 @@ describe('injectIsRestoring', () => { expect(isRestoring()).toBe(false) }) - test('returns provided signal value when provideIsRestoring is used', () => { - queryClient = new QueryClient() + it('should return the provided signal value when provideIsRestoring is used', () => { const restoringSignal = signal(true) TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - provideIsRestoring(restoringSignal.asReadonly()), - ], + providers: [provideIsRestoring(restoringSignal.asReadonly())], }) const isRestoring = TestBed.runInInjectionContext(() => { @@ -47,16 +43,27 @@ describe('injectIsRestoring', () => { expect(isRestoring()).toBe(true) }) - test('can be used outside injection context when passing an injector', () => { - queryClient = new QueryClient() + it('should reactively reflect changes to the provided signal', () => { + const restoringSignal = signal(true) TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], + providers: [provideIsRestoring(restoringSignal.asReadonly())], }) + const isRestoring = TestBed.runInInjectionContext(() => { + return injectIsRestoring() + }) + + expect(isRestoring()).toBe(true) + + restoringSignal.set(false) + expect(isRestoring()).toBe(false) + + restoringSignal.set(true) + expect(isRestoring()).toBe(true) + }) + + it('should be usable outside injection context when passing an injector', () => { const isRestoring = injectIsRestoring({ injector: TestBed.inject(Injector), }) @@ -64,9 +71,23 @@ describe('injectIsRestoring', () => { expect(isRestoring()).toBe(false) }) - test('throws NG0203 with descriptive error outside injection context', () => { + it('should return the provided signal value when using injector option', () => { + const restoringSignal = signal(true) + + TestBed.configureTestingModule({ + providers: [provideIsRestoring(restoringSignal.asReadonly())], + }) + + const isRestoring = injectIsRestoring({ + injector: TestBed.inject(Injector), + }) + + expect(isRestoring()).toBe(true) + }) + + it('should throw NG0203 with descriptive error outside injection context', () => { expect(() => { injectIsRestoring() - }).toThrowError(/NG0203(.*?)injectIsRestoring/) + }).toThrow(/NG0203(.*?)injectIsRestoring/) }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts index 8b747f66f6c..4eee7699aeb 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts @@ -6,9 +6,9 @@ import { signal, } from '@angular/core' import { TestBed } from '@angular/core/testing' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { By } from '@angular/platform-browser' -import { sleep } from '@tanstack/query-test-utils' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, injectMutation, @@ -36,8 +36,8 @@ describe('injectMutationState', () => { }) describe('injectMutationState', () => { - test('should return variables after calling mutate 1', () => { - const mutationKey = ['mutation'] + it('should return variables after calling mutate 1', () => { + const mutationKey = queryKey() const variables = 'foo123' const mutation = TestBed.runInInjectionContext(() => { @@ -59,9 +59,9 @@ describe('injectMutationState', () => { expect(mutationState()).toEqual([variables]) }) - test('reactive options should update injectMutationState', () => { - const mutationKey1 = ['mutation1'] - const mutationKey2 = ['mutation2'] + it('should update injectMutationState when reactive options change', () => { + const mutationKey1 = queryKey() + const mutationKey2 = queryKey() const variables1 = 'foo123' const variables2 = 'bar234' @@ -96,9 +96,9 @@ describe('injectMutationState', () => { expect(mutationState()).toEqual([variables2]) }) - test('should return variables after calling mutate 2', () => { + it('should return variables after calling mutate 2', () => { queryClient.clear() - const mutationKey = ['mutation'] + const mutationKey = queryKey() const variables = 'bar234' const mutation = TestBed.runInInjectionContext(() => { @@ -117,7 +117,7 @@ describe('injectMutationState', () => { expect(mutationState()[0]?.variables).toEqual(variables) }) - test('should support required signal inputs', async () => { + it('should support required signal inputs', async () => { queryClient.clear() const fakeName = 'name1' const mutationKey1 = ['fake', fakeName] @@ -179,13 +179,13 @@ describe('injectMutationState', () => { }) describe('injection context', () => { - test('throws NG0203 with descriptive error outside injection context', () => { + it('should throw NG0203 with descriptive error outside injection context', () => { expect(() => { injectMutationState() - }).toThrowError(/NG0203(.*?)injectMutationState/) + }).toThrow(/NG0203(.*?)injectMutationState/) }) - test('can be used outside injection context when passing an injector', () => { + it('should be usable outside injection context when passing an injector', () => { const injector = TestBed.inject(Injector) expect( injectMutationState(undefined, { diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test-d.ts index f331bb02dea..e241ce58c94 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test-d.ts @@ -1,71 +1,75 @@ -import { describe, expectTypeOf, test } from 'vitest' +import { describe, expectTypeOf, it } from 'vitest' import { sleep } from '@tanstack/query-test-utils' import { injectMutation } from '..' import type { Signal } from '@angular/core' -describe('Discriminated union return type', () => { - test('data should be possibly undefined by default', () => { - const mutation = injectMutation(() => ({ - mutationFn: () => sleep(0).then(() => 'string'), - })) +describe('injectMutation', () => { + describe('Discriminated union return type', () => { + it('data should be possibly undefined by default', () => { + const mutation = injectMutation(() => ({ + mutationFn: () => sleep(0).then(() => 'string'), + })) - expectTypeOf(mutation.data).toEqualTypeOf>() - }) + expectTypeOf(mutation.data).toEqualTypeOf>() + }) - test('data should be defined when mutation is success', () => { - const mutation = injectMutation(() => ({ - mutationFn: () => sleep(0).then(() => 'string'), - })) + it('data should be defined when mutation is success', () => { + const mutation = injectMutation(() => ({ + mutationFn: () => sleep(0).then(() => 'string'), + })) - if (mutation.isSuccess()) { - expectTypeOf(mutation.data).toEqualTypeOf>() - } - }) + if (mutation.isSuccess()) { + expectTypeOf(mutation.data).toEqualTypeOf>() + } + }) - test('error should be null when mutation is success', () => { - const mutation = injectMutation(() => ({ - mutationFn: () => sleep(0).then(() => 'string'), - })) + it('error should be null when mutation is success', () => { + const mutation = injectMutation(() => ({ + mutationFn: () => sleep(0).then(() => 'string'), + })) - if (mutation.isSuccess()) { - expectTypeOf(mutation.error).toEqualTypeOf>() - } - }) + if (mutation.isSuccess()) { + expectTypeOf(mutation.error).toEqualTypeOf>() + } + }) - test('data should be undefined when mutation is pending', () => { - const mutation = injectMutation(() => ({ - mutationFn: () => sleep(0).then(() => 'string'), - })) + it('data should be undefined when mutation is pending', () => { + const mutation = injectMutation(() => ({ + mutationFn: () => sleep(0).then(() => 'string'), + })) - if (mutation.isPending()) { - expectTypeOf(mutation.data).toEqualTypeOf>() - } - }) + if (mutation.isPending()) { + expectTypeOf(mutation.data).toEqualTypeOf>() + } + }) - test('error should be defined when mutation is error', () => { - const mutation = injectMutation(() => ({ - mutationFn: () => sleep(0).then(() => 'string'), - })) + it('error should be defined when mutation is error', () => { + const mutation = injectMutation(() => ({ + mutationFn: () => sleep(0).then(() => 'string'), + })) - if (mutation.isError()) { - expectTypeOf(mutation.error).toEqualTypeOf>() - } - }) + if (mutation.isError()) { + expectTypeOf(mutation.error).toEqualTypeOf>() + } + }) - test('should narrow variables', () => { - const mutation = injectMutation(() => ({ - mutationFn: (_variables: string) => sleep(0).then(() => 'string'), - })) + it('should narrow variables', () => { + const mutation = injectMutation(() => ({ + mutationFn: (_variables: string) => sleep(0).then(() => 'string'), + })) - if (mutation.isIdle()) { - expectTypeOf(mutation.variables).toEqualTypeOf>() - } - if (mutation.isPending()) { - expectTypeOf(mutation.variables).toEqualTypeOf>() - } - if (mutation.isSuccess()) { - expectTypeOf(mutation.variables).toEqualTypeOf>() - } - expectTypeOf(mutation.variables).toEqualTypeOf>() + if (mutation.isIdle()) { + expectTypeOf(mutation.variables).toEqualTypeOf>() + } + if (mutation.isPending()) { + expectTypeOf(mutation.variables).toEqualTypeOf>() + } + if (mutation.isSuccess()) { + expectTypeOf(mutation.variables).toEqualTypeOf>() + } + expectTypeOf(mutation.variables).toEqualTypeOf< + Signal + >() + }) }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts index 7af34970928..f0c53602b1d 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -7,9 +7,10 @@ import { signal, } from '@angular/core' import { TestBed } from '@angular/core/testing' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { By } from '@angular/platform-browser' -import { sleep } from '@tanstack/query-test-utils' +import { render } from '@testing-library/angular' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, injectMutation, provideTanStackQuery } from '..' import { expectSignals, setFixtureSignalInputs } from './test-utils' @@ -31,7 +32,7 @@ describe('injectMutation', () => { vi.useRealTimers() }) - test('should be in idle state initially', () => { + it('should be in idle state initially', () => { const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ mutationFn: (params) => sleep(0).then(() => params), @@ -46,79 +47,113 @@ describe('injectMutation', () => { }) }) - test('should change state after invoking mutate', async () => { + it('should change state after invoking mutate', async () => { const result = 'Mock data' - const mutation = TestBed.runInInjectionContext(() => { - return injectMutation(() => ({ + @Component({ + template: ` +
isIdle: {{ mutation.isIdle() }}
+
isPending: {{ mutation.isPending() }}
+
isError: {{ mutation.isError() }}
+
isSuccess: {{ mutation.isSuccess() }}
+
data: {{ mutation.data() ?? 'none' }}
+
error: {{ mutation.error()?.message ?? 'none' }}
+ `, + }) + class Page { + readonly mutation = injectMutation(() => ({ mutationFn: (params: string) => sleep(10).then(() => params), })) - }) + } - TestBed.tick() + const rendered = await render(Page) - mutation.mutate(result) + rendered.fixture.componentInstance.mutation.mutate(result) await vi.advanceTimersByTimeAsync(0) - - expectSignals(mutation, { - isIdle: false, - isPending: true, - isError: false, - isSuccess: false, - data: undefined, - error: null, - }) + rendered.fixture.detectChanges() + + expect(rendered.getByText('isIdle: false')).toBeInTheDocument() + expect(rendered.getByText('isPending: true')).toBeInTheDocument() + expect(rendered.getByText('isError: false')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: false')).toBeInTheDocument() + expect(rendered.getByText('data: none')).toBeInTheDocument() + expect(rendered.getByText('error: none')).toBeInTheDocument() }) - test('should return error when request fails', async () => { - const mutation = TestBed.runInInjectionContext(() => { - return injectMutation(() => ({ + it('should return error when request fails', async () => { + @Component({ + template: ` +
isIdle: {{ mutation.isIdle() }}
+
isPending: {{ mutation.isPending() }}
+
isError: {{ mutation.isError() }}
+
isSuccess: {{ mutation.isSuccess() }}
+
data: {{ mutation.data() ?? 'none' }}
+
error: {{ mutation.error()?.message ?? 'none' }}
+ `, + }) + class Page { + readonly mutation = injectMutation(() => ({ mutationFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) - }) + } - mutation.mutate() + const rendered = await render(Page) - await vi.advanceTimersByTimeAsync(11) + rendered.fixture.componentInstance.mutation.mutate() - expectSignals(mutation, { - isIdle: false, - isPending: false, - isError: true, - isSuccess: false, - data: undefined, - error: Error('Some error'), - }) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('isIdle: false')).toBeInTheDocument() + expect(rendered.getByText('isPending: false')).toBeInTheDocument() + expect(rendered.getByText('isError: true')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: false')).toBeInTheDocument() + expect(rendered.getByText('data: none')).toBeInTheDocument() + expect(rendered.getByText('error: Some error')).toBeInTheDocument() }) - test('should return data when request succeeds', async () => { + it('should return data when request succeeds', async () => { const result = 'Mock data' - const mutation = TestBed.runInInjectionContext(() => { - return injectMutation(() => ({ + + @Component({ + template: ` +
isIdle: {{ mutation.isIdle() }}
+
isPending: {{ mutation.isPending() }}
+
isError: {{ mutation.isError() }}
+
isSuccess: {{ mutation.isSuccess() }}
+
data: {{ mutation.data() ?? 'none' }}
+
error: {{ mutation.error()?.message ?? 'none' }}
+ `, + }) + class Page { + readonly mutation = injectMutation(() => ({ mutationFn: (params: string) => sleep(10).then(() => params), })) - }) + } - mutation.mutate(result) + const rendered = await render(Page) - await vi.advanceTimersByTimeAsync(11) + rendered.fixture.componentInstance.mutation.mutate(result) - expectSignals(mutation, { - isIdle: false, - isPending: false, - isError: false, - isSuccess: true, - data: result, - error: null, - }) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('isIdle: false')).toBeInTheDocument() + expect(rendered.getByText('isPending: false')).toBeInTheDocument() + expect(rendered.getByText('isError: false')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: true')).toBeInTheDocument() + expect(rendered.getByText(`data: ${result}`)).toBeInTheDocument() + expect(rendered.getByText('error: none')).toBeInTheDocument() }) - test('reactive options should update mutation', () => { + it('should update mutation when reactive options change', () => { const mutationCache = queryClient.getMutationCache() // Signal will be updated before the mutation is called // this test confirms that the mutation uses the updated value - const mutationKey = signal(['1']) + const key1 = queryKey() + const key2 = queryKey() + const mutationKey = signal(key1) const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ mutationKey: mutationKey(), @@ -126,41 +161,54 @@ describe('injectMutation', () => { })) }) - mutationKey.set(['2']) + mutationKey.set(key2) mutation.mutate('xyz') - const mutations = mutationCache.find({ mutationKey: ['2'] }) + const mutations = mutationCache.find({ mutationKey: key2 }) - expect(mutations?.options.mutationKey).toEqual(['2']) + expect(mutations?.options.mutationKey).toEqual(key2) }) - test('should reset state after invoking mutation.reset', async () => { - const mutation = TestBed.runInInjectionContext(() => { - return injectMutation(() => ({ + it('should reset state after invoking mutation.reset', async () => { + @Component({ + template: ` +
isIdle: {{ mutation.isIdle() }}
+
isPending: {{ mutation.isPending() }}
+
isError: {{ mutation.isError() }}
+
isSuccess: {{ mutation.isSuccess() }}
+
data: {{ mutation.data() ?? 'none' }}
+
error: {{ mutation.error()?.message ?? 'none' }}
+ `, + }) + class Page { + readonly mutation = injectMutation(() => ({ mutationFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) - }) + } + + const rendered = await render(Page) - mutation.mutate() + rendered.fixture.componentInstance.mutation.mutate() await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() - expect(mutation.isError()).toBe(true) + expect(rendered.getByText('isError: true')).toBeInTheDocument() + expect(rendered.getByText('error: Some error')).toBeInTheDocument() - mutation.reset() + rendered.fixture.componentInstance.mutation.reset() await vi.advanceTimersByTimeAsync(0) - - expectSignals(mutation, { - isIdle: true, - isPending: false, - isError: false, - isSuccess: false, - data: undefined, - error: null, - }) + rendered.fixture.detectChanges() + + expect(rendered.getByText('isIdle: true')).toBeInTheDocument() + expect(rendered.getByText('isPending: false')).toBeInTheDocument() + expect(rendered.getByText('isError: false')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: false')).toBeInTheDocument() + expect(rendered.getByText('data: none')).toBeInTheDocument() + expect(rendered.getByText('error: none')).toBeInTheDocument() }) describe('side effects', () => { @@ -168,7 +216,7 @@ describe('injectMutation', () => { vi.clearAllMocks() }) - test('should call onMutate when passed as an option', async () => { + it('should call onMutate when passed as an option', async () => { const onMutate = vi.fn() const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ @@ -184,7 +232,7 @@ describe('injectMutation', () => { expect(onMutate).toHaveBeenCalledTimes(1) }) - test('should call onError when passed as an option', async () => { + it('should call onError when passed as an option', async () => { const onError = vi.fn() const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ @@ -201,7 +249,7 @@ describe('injectMutation', () => { expect(onError).toHaveBeenCalledTimes(1) }) - test('should call onSuccess when passed as an option', async () => { + it('should call onSuccess when passed as an option', async () => { const onSuccess = vi.fn() const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ @@ -217,7 +265,7 @@ describe('injectMutation', () => { expect(onSuccess).toHaveBeenCalledTimes(1) }) - test('should call onSettled when passed as an option', async () => { + it('should call onSettled when passed as an option', async () => { const onSettled = vi.fn() const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ @@ -233,7 +281,7 @@ describe('injectMutation', () => { expect(onSettled).toHaveBeenCalledTimes(1) }) - test('should call onError when passed as an argument of mutate function', async () => { + it('should call onError when passed as an argument of mutate function', async () => { const onError = vi.fn() const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ @@ -249,7 +297,7 @@ describe('injectMutation', () => { expect(onError).toHaveBeenCalledTimes(1) }) - test('should call onSuccess when passed as an argument of mutate function', async () => { + it('should call onSuccess when passed as an argument of mutate function', async () => { const onSuccess = vi.fn() const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ @@ -264,7 +312,7 @@ describe('injectMutation', () => { expect(onSuccess).toHaveBeenCalledTimes(1) }) - test('should call onSettled when passed as an argument of mutate function', async () => { + it('should call onSettled when passed as an argument of mutate function', async () => { const onSettled = vi.fn() const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ @@ -279,7 +327,7 @@ describe('injectMutation', () => { expect(onSettled).toHaveBeenCalledTimes(1) }) - test('should fire both onSettled functions', async () => { + it('should fire both onSettled functions', async () => { const onSettled = vi.fn() const onSettledOnFunction = vi.fn() const mutation = TestBed.runInInjectionContext(() => { @@ -298,7 +346,7 @@ describe('injectMutation', () => { }) }) - test('should support required signal inputs', async () => { + it('should support required signal inputs', async () => { const mutationCache = queryClient.getMutationCache() @Component({ @@ -338,7 +386,7 @@ describe('injectMutation', () => { expect(mutation!.options.mutationKey).toStrictEqual(['fake', 'value']) }) - test('should update options on required signal input change', async () => { + it('should update options on required signal input change', async () => { const mutationCache = queryClient.getMutationCache() @Component({ @@ -390,12 +438,13 @@ describe('injectMutation', () => { }) describe('throwOnError', () => { - test('should evaluate throwOnError when mutation is expected to throw', async () => { + it('should evaluate throwOnError when mutation is expected to throw', async () => { + const key = queryKey() const err = new Error('Expected mock error. All is well!') const boundaryFn = vi.fn() const { mutate } = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ - mutationKey: ['fake'], + mutationKey: key, mutationFn: () => { return Promise.reject(err) }, @@ -413,10 +462,11 @@ describe('injectMutation', () => { expect(boundaryFn).toHaveBeenCalledWith(err) }) - test('should throw when throwOnError is true and mutate is used', async () => { + it('should throw when throwOnError is true and mutate is used', async () => { + const key = queryKey() const { mutate } = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ - mutationKey: ['fake'], + mutationKey: key, mutationFn: () => { return Promise.reject( new Error('Expected mock error. All is well!'), @@ -436,11 +486,12 @@ describe('injectMutation', () => { }) }) - test('should throw when throwOnError is true', async () => { + it('should throw when throwOnError is true', async () => { + const key = queryKey() const err = new Error('Expected mock error. All is well!') const { mutateAsync } = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ - mutationKey: ['fake'], + mutationKey: key, mutationFn: () => { return Promise.reject(err) }, @@ -448,14 +499,15 @@ describe('injectMutation', () => { })) }) - await expect(() => mutateAsync()).rejects.toThrowError(err) + await expect(() => mutateAsync()).rejects.toThrow(err) }) - test('should throw when throwOnError function returns true', async () => { + it('should throw when throwOnError function returns true', async () => { + const key = queryKey() const err = new Error('Expected mock error. All is well!') const { mutateAsync } = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ - mutationKey: ['fake'], + mutationKey: key, mutationFn: () => { return Promise.reject(err) }, @@ -463,24 +515,41 @@ describe('injectMutation', () => { })) }) - await expect(() => mutateAsync()).rejects.toThrowError(err) + await expect(() => mutateAsync()).rejects.toThrow(err) + }) + + it('should resolve mutateAsync with the value returned from mutationFn', async () => { + const key = queryKey() + const { mutateAsync } = TestBed.runInInjectionContext(() => { + return injectMutation(() => ({ + mutationKey: key, + mutationFn: (params: string) => sleep(10).then(() => params), + })) + }) + + const promise = mutateAsync('Mock data') + await vi.advanceTimersByTimeAsync(11) + + await expect(promise).resolves.toBe('Mock data') }) describe('injection context', () => { - test('throws NG0203 with descriptive error outside injection context', () => { + it('should throw NG0203 with descriptive error outside injection context', () => { + const key = queryKey() expect(() => { injectMutation(() => ({ - mutationKey: ['injectionContextError'], + mutationKey: key, mutationFn: () => Promise.resolve(), })) - }).toThrowError(/NG0203(.*?)injectMutation/) + }).toThrow(/NG0203(.*?)injectMutation/) }) - test('can be used outside injection context when passing an injector', () => { + it('should be usable outside injection context when passing an injector', () => { + const key = queryKey() expect(() => { injectMutation( () => ({ - mutationKey: ['injectionContextError'], + mutationKey: key, mutationFn: () => Promise.resolve(), }), { @@ -490,14 +559,15 @@ describe('injectMutation', () => { }).not.toThrow() }) - test('should complete mutation before whenStable() resolves', async () => { + it('should complete mutation before whenStable() resolves', async () => { const app = TestBed.inject(ApplicationRef) let mutationStarted = false let mutationCompleted = false + const key = queryKey() const mutation = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationKey: ['pendingTasksTest'], + mutationKey: key, mutationFn: async (data: string) => { mutationStarted = true await sleep(50) @@ -526,15 +596,7 @@ describe('injectMutation', () => { expect(mutation.data()).toBe('processed: test') }) - test('should handle synchronous mutation with retry', async () => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) - + it('should handle synchronous mutation with retry', async () => { const app = TestBed.inject(ApplicationRef) let attemptCount = 0 @@ -576,21 +638,15 @@ describe('injectMutation', () => { expect(attemptCount).toBe(3) // Initial + 2 retries }) - test('should handle multiple synchronous mutations on same key', async () => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) - + it('should handle multiple synchronous mutations on same key', async () => { const app = TestBed.inject(ApplicationRef) let callCount = 0 + const key = queryKey() + const mutation1 = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationKey: ['sync-mutation-key'], + mutationKey: key, mutationFn: async (data: string) => { callCount++ return `mutation1: ${data}` @@ -600,7 +656,7 @@ describe('injectMutation', () => { const mutation2 = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationKey: ['sync-mutation-key'], + mutationKey: key, mutationFn: async (data: string) => { callCount++ return `mutation2: ${data}` @@ -628,17 +684,9 @@ describe('injectMutation', () => { expect(callCount).toBe(2) }) - test('should handle synchronous mutation with optimistic updates', async () => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) - + it('should handle synchronous mutation with optimistic updates', async () => { const app = TestBed.inject(ApplicationRef) - const testQueryKey = ['sync-optimistic'] + const testQueryKey = queryKey() let onMutateCalled = false let onSuccessCalled = false @@ -680,20 +728,13 @@ describe('injectMutation', () => { expect(queryClient.getQueryData(testQueryKey)).toBe('final: test') }) - test('should handle synchronous mutation cancellation', async () => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) - + it('should handle synchronous mutation cancellation', async () => { const app = TestBed.inject(ApplicationRef) + const key = queryKey() const mutation = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationKey: ['cancel-sync'], + mutationKey: key, mutationFn: async (data: string) => `processed: ${data}`, // Synchronous resolution })), ) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts index 62547fd9e09..b133575746c 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts @@ -1,177 +1,196 @@ import { describe, expectTypeOf, it } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' import { skipToken } from '..' import { injectQueries } from '../inject-queries' import { queryOptions } from '../query-options' import type { CreateQueryOptions, CreateQueryResult, OmitKeyof } from '..' import type { Signal } from '@angular/core' -describe('InjectQueries config object overload', () => { - it('TData should always be defined when initialData is provided as an object', () => { - const query1 = { - queryKey: ['key1'], - queryFn: () => { - return { - wow: true, - } - }, - initialData: { - wow: false, - }, - } - - const query2 = { - queryKey: ['key2'], - queryFn: () => 'Query Data', - initialData: 'initial data', - } - - const query3 = { - queryKey: ['key2'], - queryFn: () => 'Query Data', - } - - const queryResults = injectQueries(() => ({ - queries: [query1, query2, query3], - })) - - const query1Data = queryResults()[0].data() - const query2Data = queryResults()[1].data() - const query3Data = queryResults()[2].data() - - expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>() - expectTypeOf(query2Data).toEqualTypeOf() - expectTypeOf(query3Data).toEqualTypeOf() - }) +describe('injectQueries', () => { + describe('config object overload', () => { + it('TData should always be defined when initialData is provided as an object', () => { + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + + const query1 = { + queryKey: key1, + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: false, + }, + } - it('TData should be defined when passed through queryOptions', () => { - const options = queryOptions({ - queryKey: ['key'], - queryFn: () => { - return { - wow: true, - } - }, - initialData: { - wow: true, - }, - }) - const queryResults = injectQueries(() => ({ queries: [options] })) + const query2 = { + queryKey: key2, + queryFn: () => 'Query Data', + initialData: 'initial data', + } + + const query3 = { + queryKey: key3, + queryFn: () => 'Query Data', + } - const data = queryResults()[0].data() + const queryResults = injectQueries(() => ({ + queries: [query1, query2, query3], + })) - expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() - }) + const query1Data = queryResults()[0].data() + const query2Data = queryResults()[1].data() + const query3Data = queryResults()[2].data() - it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into injectQuery', () => { - const query1 = queryOptions({ - queryKey: ['key'], - queryFn: () => Promise.resolve(1), - select: (data) => data > 1, + expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>() + expectTypeOf(query2Data).toEqualTypeOf() + expectTypeOf(query3Data).toEqualTypeOf() }) - const query2 = { - queryKey: ['key'], - queryFn: () => Promise.resolve(1), - select: (data: number) => data > 1, - } + it('TData should be defined when passed through queryOptions', () => { + const key = queryKey() + const options = queryOptions({ + queryKey: key, + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: true, + }, + }) + const queryResults = injectQueries(() => ({ queries: [options] })) - const queryResults = injectQueries(() => ({ queries: [query1, query2] })) - const query1Data = queryResults()[0].data() - const query2Data = queryResults()[1].data() + const data = queryResults()[0].data() - expectTypeOf(query1Data).toEqualTypeOf() - expectTypeOf(query2Data).toEqualTypeOf() - }) + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) - it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { - const queryResults = injectQueries(() => ({ - queries: [ - { - queryKey: ['key'], - queryFn: () => { - return { - wow: true, - } - }, - initialData: () => undefined as { wow: boolean } | undefined, - }, - ], - })) + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into injectQuery', () => { + const key1 = queryKey() + const key2 = queryKey() - const data = queryResults()[0].data() + const query1 = queryOptions({ + queryKey: key1, + queryFn: () => Promise.resolve(1), + select: (data) => data > 1, + }) - expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() - }) + const query2 = { + queryKey: key2, + queryFn: () => Promise.resolve(1), + select: (data: number) => data > 1, + } + + const queryResults = injectQueries(() => ({ queries: [query1, query2] })) + const query1Data = queryResults()[0].data() + const query2Data = queryResults()[1].data() + + expectTypeOf(query1Data).toEqualTypeOf() + expectTypeOf(query2Data).toEqualTypeOf() + }) - describe('custom injectable', () => { - it('should allow custom hooks using UseQueryOptions', () => { - type Data = string - - const injectCustomQueries = ( - options?: OmitKeyof, 'queryKey' | 'queryFn'>, - ) => { - return injectQueries(() => ({ - queries: [ - { - ...options, - queryKey: ['todos-key'], - queryFn: () => Promise.resolve('data'), + it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { + const key = queryKey() + const queryResults = injectQueries(() => ({ + queries: [ + { + queryKey: key, + queryFn: () => { + return { + wow: true, + } }, - ], - })) - } + initialData: () => undefined as { wow: boolean } | undefined, + }, + ], + })) - const queryResults = injectCustomQueries() const data = queryResults()[0].data() - expectTypeOf(data).toEqualTypeOf() + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() }) - }) - it('TData should have correct type when conditional skipToken is passed', () => { - const queryResults = injectQueries(() => ({ - queries: [ - { - queryKey: ['withSkipToken'], - queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), - }, - ], - })) + describe('custom injectable', () => { + it('should allow custom hooks using UseQueryOptions', () => { + type Data = string + const key = queryKey() + + const injectCustomQueries = ( + options?: OmitKeyof, 'queryKey' | 'queryFn'>, + ) => { + return injectQueries(() => ({ + queries: [ + { + ...options, + queryKey: key, + queryFn: () => Promise.resolve('data'), + }, + ], + })) + } - const firstResult = queryResults()[0] + const queryResults = injectCustomQueries() + const data = queryResults()[0].data() - expectTypeOf(firstResult).toEqualTypeOf>() - expectTypeOf(firstResult.data()).toEqualTypeOf() - }) + expectTypeOf(data).toEqualTypeOf() + }) + }) - it('should return correct data for dynamic queries with mixed result types', () => { - const Queries1 = { - get: () => - queryOptions({ - queryKey: ['key1'], - queryFn: () => Promise.resolve(1), - }), - } - const Queries2 = { - get: () => - queryOptions({ - queryKey: ['key2'], - queryFn: () => Promise.resolve(true), - }), - } - - const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() })) - const result = injectQueries(() => ({ - queries: [...queries1List, { ...Queries2.get() }], - })) - - expectTypeOf(result).branded.toEqualTypeOf< - Signal< - [ - ...Array>, - CreateQueryResult, - ] - > - >() + it('TData should have correct type when conditional skipToken is passed', () => { + const key = queryKey() + const queryResults = injectQueries(() => ({ + queries: [ + { + queryKey: key, + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }, + ], + })) + + const firstResult = queryResults()[0] + + expectTypeOf(firstResult).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(firstResult.data()).toEqualTypeOf() + }) + + it('should return correct data for dynamic queries with mixed result types', () => { + const key1 = queryKey() + const key2 = queryKey() + + const Queries1 = { + get: () => + queryOptions({ + queryKey: key1, + queryFn: () => Promise.resolve(1), + }), + } + const Queries2 = { + get: () => + queryOptions({ + queryKey: key2, + queryFn: () => Promise.resolve(true), + }), + } + + const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() })) + const result = injectQueries(() => ({ + queries: [...queries1List, { ...Queries2.get() }], + })) + + expectTypeOf(result).branded.toEqualTypeOf< + Signal< + [ + ...Array>, + CreateQueryResult, + ] + > + >() + }) }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index d85e1985b3e..523bda69f06 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -3,31 +3,32 @@ import { Component, effect, provideZonelessChangeDetection, + signal, } from '@angular/core' import { TestBed } from '@angular/core/testing' import { render } from '@testing-library/angular' import { queryKey, sleep } from '@tanstack/query-test-utils' -import { QueryClient, provideTanStackQuery } from '..' +import { QueryClient, provideIsRestoring, provideTanStackQuery } from '..' import { injectQueries } from '../inject-queries' -let queryClient: QueryClient +describe('injectQueries', () => { + let queryClient: QueryClient -beforeEach(() => { - vi.useFakeTimers() - queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient() + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ], + }) }) -}) -afterEach(() => { - vi.useRealTimers() -}) + afterEach(() => { + vi.useRealTimers() + }) -describe('injectQueries', () => { it('should return the correct states', async () => { const key1 = queryKey() const key2 = queryKey() @@ -44,7 +45,7 @@ describe('injectQueries', () => { `, }) class Page { - result = injectQueries(() => ({ + readonly result = injectQueries(() => ({ queries: [ { queryKey: key1, @@ -57,18 +58,15 @@ describe('injectQueries', () => { ], })) - _ = effect(() => { - const snapshot = this.result().map((q) => ({ data: q.data() })) - results.push(snapshot) - }) + constructor() { + effect(() => { + const snapshot = this.result().map((q) => ({ data: q.data() })) + results.push(snapshot) + }) + } } - const rendered = await render(Page, { - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + const rendered = await render(Page) await vi.advanceTimersByTimeAsync(101) rendered.fixture.detectChanges() @@ -80,4 +78,163 @@ describe('injectQueries', () => { expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }]) expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) }) + + it('should return the combined result when combine is provided', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: Array> = [] + + @Component({ + template: ` +
data: {{ combined().data.join(',') }}
+
isPending: {{ combined().isPending }}
+ `, + }) + class Page { + readonly combined = injectQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + }, + { + queryKey: key2, + queryFn: () => sleep(10).then(() => 2), + }, + ], + combine: (queries) => ({ + data: queries.map((q) => q.data), + isPending: queries.some((q) => q.isPending), + }), + })) + + constructor() { + effect(() => { + results.push({ ...this.combined() }) + }) + } + } + + const rendered = await render(Page) + + expect(rendered.getByText('data: ,')).toBeInTheDocument() + expect(rendered.getByText('isPending: true')).toBeInTheDocument() + expect(results[0]).toMatchObject({ + data: [undefined, undefined], + isPending: true, + }) + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + expect(rendered.getByText('isPending: false')).toBeInTheDocument() + expect(results[results.length - 1]).toMatchObject({ + data: [1, 2], + isPending: false, + }) + expect(results.length).toBeGreaterThanOrEqual(2) + }) + + it('should reflect error state when one of the queries rejects', async () => { + const key1 = queryKey() + const key2 = queryKey() + + @Component({ + template: ` +
+ status1: {{ result()[0].status() }}, error1: + {{ result()[0].error()?.message ?? 'none' }} +
+
+ status2: {{ result()[1].status() }}, data2: + {{ result()[1].data() ?? 'none' }} +
+ `, + }) + class Page { + readonly result = injectQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Some error'))), + retry: false, + }, + { + queryKey: key2, + queryFn: () => sleep(10).then(() => 2), + }, + ], + })) + } + + const rendered = await render(Page) + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect( + rendered.getByText('status1: error, error1: Some error'), + ).toBeInTheDocument() + expect(rendered.getByText('status2: success, data2: 2')).toBeInTheDocument() + }) + + describe('isRestoring', () => { + it('should not fetch for the duration of the restoring period when isRestoring is true', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = vi.fn().mockImplementation(() => sleep(10).then(() => 1)) + const queryFn2 = vi.fn().mockImplementation(() => sleep(10).then(() => 2)) + + TestBed.configureTestingModule({ + providers: [provideIsRestoring(signal(true).asReadonly())], + }) + + @Component({ + template: ` +
+ status1: {{ queries()[0].status() }}, fetchStatus1: + {{ queries()[0].fetchStatus() }} +
+
+ status2: {{ queries()[1].status() }}, fetchStatus2: + {{ queries()[1].fetchStatus() }} +
+ `, + }) + class Page { + readonly queries = injectQueries(() => ({ + queries: [ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ], + })) + } + + const rendered = await render(Page) + + await vi.advanceTimersByTimeAsync(0) + rendered.fixture.detectChanges() + expect( + rendered.getByText('status1: pending, fetchStatus1: idle'), + ).toBeInTheDocument() + expect( + rendered.getByText('status2: pending, fetchStatus2: idle'), + ).toBeInTheDocument() + expect(queryFn1).toHaveBeenCalledTimes(0) + expect(queryFn2).toHaveBeenCalledTimes(0) + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect( + rendered.getByText('status1: pending, fetchStatus1: idle'), + ).toBeInTheDocument() + expect( + rendered.getByText('status2: pending, fetchStatus2: idle'), + ).toBeInTheDocument() + expect(queryFn1).toHaveBeenCalledTimes(0) + expect(queryFn2).toHaveBeenCalledTimes(0) + }) + }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts index 541ad65f148..c0864c0c99e 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts @@ -1,179 +1,194 @@ -import { describe, expectTypeOf, it, test } from 'vitest' -import { sleep } from '@tanstack/query-test-utils' +import { describe, expectTypeOf, it } from 'vitest' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { injectQuery, queryOptions } from '..' import type { Signal } from '@angular/core' -describe('initialData', () => { - describe('Config object overload', () => { - it('TData should always be defined when initialData is provided as an object', () => { - const { data } = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => ({ wow: true }), - initialData: { wow: true }, - })) +describe('injectQuery', () => { + describe('initialData', () => { + describe('Config object overload', () => { + it('TData should always be defined when initialData is provided as an object', () => { + const key = queryKey() + const { data } = injectQuery(() => ({ + queryKey: key, + queryFn: () => ({ wow: true }), + initialData: { wow: true }, + })) + + expectTypeOf(data).toEqualTypeOf>() + }) - expectTypeOf(data).toEqualTypeOf>() - }) + it('TData should be defined when passed through queryOptions', () => { + const key = queryKey() + const options = () => + queryOptions({ + queryKey: key, + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: true, + }, + }) + const { data } = injectQuery(options) + + expectTypeOf(data).toEqualTypeOf>() + }) + + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { + const key = queryKey() + const options = queryOptions({ + queryKey: key, + queryFn: () => Promise.resolve(1), + }) + + const query = injectQuery(() => ({ + ...options, + select: (data) => data > 1, + })) - it('TData should be defined when passed through queryOptions', () => { - const options = () => - queryOptions({ - queryKey: ['key'], + expectTypeOf(query.data).toEqualTypeOf>() + }) + + it('TData should always be defined when initialData is provided as a function which ALWAYS returns the data', () => { + const key = queryKey() + const { data } = injectQuery(() => ({ + queryKey: key, queryFn: () => { return { wow: true, } }, - initialData: { + initialData: () => ({ wow: true, + }), + })) + + expectTypeOf(data).toEqualTypeOf>() + }) + + it('TData should have undefined in the union when initialData is NOT provided', () => { + const key = queryKey() + const { data } = injectQuery(() => ({ + queryKey: key, + queryFn: () => { + return { + wow: true, + } }, - }) - const { data } = injectQuery(options) + })) - expectTypeOf(data).toEqualTypeOf>() - }) + expectTypeOf(data).toEqualTypeOf>() + }) + + it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { + const key = queryKey() + const { data } = injectQuery(() => ({ + queryKey: key, + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => undefined as { wow: boolean } | undefined, + })) - it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { - const options = queryOptions({ - queryKey: ['key'], - queryFn: () => Promise.resolve(1), + expectTypeOf(data).toEqualTypeOf>() }) - const query = injectQuery(() => ({ - ...options, - select: (data) => data > 1, - })) + it('TData should be narrowed after an isSuccess check when initialData is provided as a function which can return undefined', () => { + const key = queryKey() + const query = injectQuery(() => ({ + queryKey: key, + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => undefined as { wow: boolean } | undefined, + })) - expectTypeOf(query.data).toEqualTypeOf>() + if (query.isSuccess()) { + expectTypeOf(query.data).toEqualTypeOf>() + } + }) }) - it('TData should always be defined when initialData is provided as a function which ALWAYS returns the data', () => { - const { data } = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => { - return { - wow: true, - } - }, - initialData: () => ({ - wow: true, - }), - })) - - expectTypeOf(data).toEqualTypeOf>() + describe('structuralSharing', () => { + it('should be able to use structuralSharing with unknown types', () => { + const key = queryKey() + // https://github.com/TanStack/query/issues/6525#issuecomment-1938411343 + injectQuery(() => ({ + queryKey: key, + queryFn: () => 5, + structuralSharing: (oldData, newData) => { + expectTypeOf(oldData).toBeUnknown() + expectTypeOf(newData).toBeUnknown() + return newData + }, + })) + }) }) + }) - it('TData should have undefined in the union when initialData is NOT provided', () => { - const { data } = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => { - return { - wow: true, - } - }, + describe('Discriminated union return type', () => { + it('data should be possibly undefined by default', () => { + const key = queryKey() + const query = injectQuery(() => ({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), })) - expectTypeOf(data).toEqualTypeOf>() + expectTypeOf(query.data).toEqualTypeOf>() }) - it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { - const { data } = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => { - return { - wow: true, - } - }, - initialData: () => undefined as { wow: boolean } | undefined, + it('data should be defined when query is success', () => { + const key = queryKey() + const query = injectQuery(() => ({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), })) - expectTypeOf(data).toEqualTypeOf>() + if (query.isSuccess()) { + expectTypeOf(query.data).toEqualTypeOf>() + } }) - it('TData should be narrowed after an isSuccess check when initialData is provided as a function which can return undefined', () => { + it('error should be null when query is success', () => { + const key = queryKey() const query = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => { - return { - wow: true, - } - }, - initialData: () => undefined as { wow: boolean } | undefined, + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), })) if (query.isSuccess()) { - expectTypeOf(query.data).toEqualTypeOf>() + expectTypeOf(query.error).toEqualTypeOf>() } }) - }) - describe('structuralSharing', () => { - it('should be able to use structuralSharing with unknown types', () => { - // https://github.com/TanStack/query/issues/6525#issuecomment-1938411343 - injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => 5, - structuralSharing: (oldData, newData) => { - expectTypeOf(oldData).toBeUnknown() - expectTypeOf(newData).toBeUnknown() - return newData - }, + it('data should be undefined when query is pending', () => { + const key = queryKey() + const query = injectQuery(() => ({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), })) - }) - }) -}) -describe('Discriminated union return type', () => { - test('data should be possibly undefined by default', () => { - const query = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => sleep(0).then(() => 'Some data'), - })) - - expectTypeOf(query.data).toEqualTypeOf>() - }) - - test('data should be defined when query is success', () => { - const query = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => sleep(0).then(() => 'Some data'), - })) - - if (query.isSuccess()) { - expectTypeOf(query.data).toEqualTypeOf>() - } - }) - - test('error should be null when query is success', () => { - const query = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => sleep(0).then(() => 'Some data'), - })) - - if (query.isSuccess()) { - expectTypeOf(query.error).toEqualTypeOf>() - } - }) - - test('data should be undefined when query is pending', () => { - const query = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => sleep(0).then(() => 'Some data'), - })) - - if (query.isPending()) { - expectTypeOf(query.data).toEqualTypeOf>() - } - }) + if (query.isPending()) { + expectTypeOf(query.data).toEqualTypeOf>() + } + }) - test('error should be defined when query is error', () => { - const query = injectQuery(() => ({ - queryKey: ['key'], - queryFn: () => sleep(0).then(() => 'Some data'), - })) + it('error should be defined when query is error', () => { + const key = queryKey() + const query = injectQuery(() => ({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + })) - if (query.isError()) { - expectTypeOf(query.error).toEqualTypeOf>() - } + if (query.isError()) { + expectTypeOf(query.error).toEqualTypeOf>() + } + }) }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index 82702e15c24..fb7fd80a7b7 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -20,9 +20,10 @@ import { describe, expect, expectTypeOf, - test, + it, vi, } from 'vitest' +import { render } from '@testing-library/angular' import { queryKey, sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' import { @@ -38,6 +39,7 @@ import type { CreateQueryOptions, OmitKeyof, QueryFunction } from '..' describe('injectQuery', () => { let queryCache: QueryCache let queryClient: QueryClient + beforeEach(() => { vi.useFakeTimers() queryCache = new QueryCache() @@ -54,7 +56,7 @@ describe('injectQuery', () => { vi.useRealTimers() }) - test('should return the correct types', () => { + it('should return the correct types', () => { const key = queryKey() // unspecified query function should default to unknown const noQueryFn = TestBed.runInInjectionContext(() => @@ -266,10 +268,11 @@ describe('injectQuery', () => { >() }) - test('should return pending status initially', () => { + it('should return pending status initially', () => { + const key = queryKey() const query = TestBed.runInInjectionContext(() => { return injectQuery(() => ({ - queryKey: ['key1'], + queryKey: key, queryFn: () => sleep(10).then(() => 'Some data'), })) }) @@ -281,46 +284,138 @@ describe('injectQuery', () => { expect(query.isFetched()).toBe(false) }) - test('should resolve to success and update signal: injectQuery()', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: ['key2'], + it('should resolve to success and update signal: injectQuery()', async () => { + const key = queryKey() + + @Component({ + template: ` +
status: {{ query.status() }}
+
data: {{ query.data() ?? 'none' }}
+
isPending: {{ query.isPending() }}
+
isFetching: {{ query.isFetching() }}
+
isFetched: {{ query.isFetched() }}
+
isSuccess: {{ query.isSuccess() }}
+ `, + }) + class Page { + readonly query = injectQuery(() => ({ + queryKey: key, queryFn: () => sleep(10).then(() => 'result2'), })) - }) + } + + const rendered = await render(Page) await vi.advanceTimersByTimeAsync(11) - expect(query.status()).toBe('success') - expect(query.data()).toBe('result2') - expect(query.isPending()).toBe(false) - expect(query.isFetching()).toBe(false) - expect(query.isFetched()).toBe(true) - expect(query.isSuccess()).toBe(true) + rendered.fixture.detectChanges() + + expect(rendered.getByText('status: success')).toBeInTheDocument() + expect(rendered.getByText('data: result2')).toBeInTheDocument() + expect(rendered.getByText('isPending: false')).toBeInTheDocument() + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + expect(rendered.getByText('isFetched: true')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: true')).toBeInTheDocument() }) - test('should reject and update signal', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + it('should reject and update signal', async () => { + const key = queryKey() + + @Component({ + template: ` +
status: {{ query.status() }}
+
data: {{ query.data() ?? 'none' }}
+
error: {{ query.error()?.message ?? 'none' }}
+
isPending: {{ query.isPending() }}
+
isFetching: {{ query.isFetching() }}
+
isError: {{ query.isError() }}
+
failureCount: {{ query.failureCount() }}
+
failureReason: {{ query.failureReason()?.message ?? 'none' }}
+ `, + }) + class Page { + readonly query = injectQuery(() => ({ retry: false, - queryKey: ['key3'], + queryKey: key, queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) + } + + const rendered = await render(Page) + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('status: error')).toBeInTheDocument() + expect(rendered.getByText('data: none')).toBeInTheDocument() + expect(rendered.getByText('error: Some error')).toBeInTheDocument() + expect(rendered.getByText('isPending: false')).toBeInTheDocument() + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + expect(rendered.getByText('isError: true')).toBeInTheDocument() + expect(rendered.getByText('failureCount: 1')).toBeInTheDocument() + expect(rendered.getByText('failureReason: Some error')).toBeInTheDocument() + }) + + it('should be able to select a part of the data with select', async () => { + const key = queryKey() + + @Component({ + template: `
data: {{ query.data() ?? 'none' }}
`, + }) + class Page { + readonly query = injectQuery<{ name: string }, Error, string>(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => ({ name: 'test' })), + select: (data) => data.name, + })) + } + + const rendered = await render(Page) + + expect(rendered.getByText('data: none')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('data: test')).toBeInTheDocument() + }) + + it('should show placeholderData until queryFn resolves and then expose real data', async () => { + const key = queryKey() + + @Component({ + template: ` +
data: {{ query.data() }}
+
isPlaceholderData: {{ query.isPlaceholderData() }}
+
isSuccess: {{ query.isSuccess() }}
+ `, }) + class Page { + readonly query = injectQuery(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'real-data'), + placeholderData: 'placeholder', + })) + } + + const rendered = await render(Page) + + expect(rendered.getByText('data: placeholder')).toBeInTheDocument() + expect(rendered.getByText('isPlaceholderData: true')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: true')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(11) - expect(query.status()).toBe('error') - expect(query.data()).toBe(undefined) - expect(query.error()).toMatchObject({ message: 'Some error' }) - expect(query.isPending()).toBe(false) - expect(query.isFetching()).toBe(false) - expect(query.isError()).toBe(true) - expect(query.failureCount()).toBe(1) - expect(query.failureReason()).toMatchObject({ message: 'Some error' }) + rendered.fixture.detectChanges() + + expect(rendered.getByText('data: real-data')).toBeInTheDocument() + expect(rendered.getByText('isPlaceholderData: false')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: true')).toBeInTheDocument() }) - test('should update query on options contained signal change', async () => { - const key = signal(['key6', 'key7']) + it('should update query on options contained signal change', async () => { + const key1 = queryKey() + const key2 = queryKey() + const key = signal(key1) const spy = vi.fn(() => sleep(10).then(() => 'Some data')) const query = TestBed.runInInjectionContext(() => { @@ -336,26 +431,27 @@ describe('injectQuery', () => { await vi.advanceTimersByTimeAsync(11) expect(query.status()).toBe('success') - key.set(['key8']) + key.set(key2) TestBed.tick() expect(spy).toHaveBeenCalledTimes(2) // should call queryFn with context containing the new queryKey - expect(spy).toBeCalledWith({ + expect(spy).toHaveBeenCalledWith({ client: queryClient, meta: undefined, - queryKey: ['key8'], + queryKey: key2, signal: expect.anything(), }) }) - test('should only run query once enabled signal is set to true', async () => { + it('should only run query once enabled signal is set to true', async () => { + const key = queryKey() const spy = vi.fn(() => sleep(10).then(() => 'Some data')) const enabled = signal(false) const query = TestBed.runInInjectionContext(() => { return injectQuery(() => ({ - queryKey: ['key9'], + queryKey: key, queryFn: spy, enabled: enabled(), })) @@ -371,10 +467,13 @@ describe('injectQuery', () => { expect(query.status()).toBe('success') }) - test('should properly execute dependent queries', async () => { + it('should properly execute dependent queries', async () => { + const key1 = queryKey() + const key2 = queryKey() + const query1 = TestBed.runInInjectionContext(() => { return injectQuery(() => ({ - queryKey: ['dependent1'], + queryKey: key1, queryFn: () => sleep(10).then(() => 'Some data'), })) }) @@ -386,7 +485,7 @@ describe('injectQuery', () => { const query2 = TestBed.runInInjectionContext(() => { return injectQuery( computed(() => ({ - queryKey: ['dependent2'], + queryKey: key2, queryFn: dependentQueryFn, enabled: !!query1.data(), })), @@ -408,17 +507,18 @@ describe('injectQuery', () => { expect(query2.status()).toStrictEqual('success') expect(dependentQueryFn).toHaveBeenCalledTimes(1) expect(dependentQueryFn).toHaveBeenCalledWith( - expect.objectContaining({ queryKey: ['dependent2'] }), + expect.objectContaining({ queryKey: key2 }), ) }) - test('should use the current value for the queryKey when refetch is called', async () => { + it('should use the current value for the queryKey when refetch is called', async () => { + const key = queryKey() const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) const keySignal = signal('key11') const query = TestBed.runInInjectionContext(() => { return injectQuery(() => ({ - queryKey: ['key10', keySignal()], + queryKey: [...key, keySignal()], queryFn: fetchFn, enabled: false, })) @@ -430,7 +530,7 @@ describe('injectQuery', () => { expect(fetchFn).toHaveBeenCalledTimes(1) expect(fetchFn).toHaveBeenCalledWith( expect.objectContaining({ - queryKey: ['key10', 'key11'], + queryKey: [...key, 'key11'], }), ) }) @@ -443,7 +543,7 @@ describe('injectQuery', () => { expect(fetchFn).toHaveBeenCalledTimes(2) expect(fetchFn).toHaveBeenCalledWith( expect.objectContaining({ - queryKey: ['key10', 'key12'], + queryKey: [...key, 'key12'], }), ) }) @@ -452,11 +552,12 @@ describe('injectQuery', () => { }) describe('throwOnError', () => { - test('should evaluate throwOnError when query is expected to throw', async () => { + it('should evaluate throwOnError when query is expected to throw', async () => { + const key = queryKey() const boundaryFn = vi.fn() TestBed.runInInjectionContext(() => { return injectQuery(() => ({ - queryKey: ['key12'], + queryKey: key, queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), retry: false, @@ -474,10 +575,11 @@ describe('injectQuery', () => { ) }) - test('should throw when throwOnError is true', async () => { + it('should throw when throwOnError is true', async () => { + const key = queryKey() TestBed.runInInjectionContext(() => { return injectQuery(() => ({ - queryKey: ['key13'], + queryKey: key, queryFn: () => sleep(0).then(() => Promise.reject(new Error('Some error'))), throwOnError: true, @@ -487,10 +589,11 @@ describe('injectQuery', () => { await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') }) - test('should throw when throwOnError function returns true', async () => { + it('should throw when throwOnError function returns true', async () => { + const key = queryKey() TestBed.runInInjectionContext(() => { return injectQuery(() => ({ - queryKey: ['key14'], + queryKey: key, queryFn: () => sleep(0).then(() => Promise.reject(new Error('Some error'))), throwOnError: () => true, @@ -501,24 +604,7 @@ describe('injectQuery', () => { }) }) - test('should set state to error when queryFn returns reject promise', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - retry: false, - queryKey: ['key15'], - queryFn: () => - sleep(10).then(() => Promise.reject(new Error('Some error'))), - })) - }) - - expect(query.status()).toBe('pending') - - await vi.advanceTimersByTimeAsync(11) - - expect(query.status()).toBe('error') - }) - - test('should render with required signal inputs', async () => { + it('should render with required signal inputs', async () => { @Component({ selector: 'app-fake', template: `{{ query.data() }}`, @@ -546,19 +632,14 @@ describe('injectQuery', () => { }) describe('isRestoring', () => { - test('should not fetch for the duration of the restoring period when isRestoring is true', async () => { + it('should not fetch for the duration of the restoring period when isRestoring is true', async () => { const key = queryKey() const queryFn = vi .fn() .mockImplementation(() => sleep(10).then(() => 'data')) - TestBed.resetTestingModule() TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - provideIsRestoring(signal(true).asReadonly()), - ], + providers: [provideIsRestoring(signal(true).asReadonly())], }) const query = TestBed.runInInjectionContext(() => @@ -583,19 +664,21 @@ describe('injectQuery', () => { }) describe('injection context', () => { - test('throws NG0203 with descriptive error outside injection context', () => { + it('should throw NG0203 with descriptive error outside injection context', () => { + const key = queryKey() expect(() => { injectQuery(() => ({ - queryKey: ['injectionContextError'], + queryKey: key, queryFn: () => sleep(0).then(() => 'Some data'), })) - }).toThrowError(/NG0203(.*?)injectQuery/) + }).toThrow(/NG0203(.*?)injectQuery/) }) - test('can be used outside injection context when passing an injector', () => { + it('should be usable outside injection context when passing an injector', () => { + const key = queryKey() const query = injectQuery( () => ({ - queryKey: ['manualInjector'], + queryKey: key, queryFn: () => sleep(0).then(() => 'Some data'), }), { @@ -606,16 +689,14 @@ describe('injectQuery', () => { expect(query.status()).toBe('pending') }) - test('should complete queries before whenStable() resolves', async () => { + it('should complete queries before whenStable() resolves', async () => { + const key = queryKey() const app = TestBed.inject(ApplicationRef) const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['pendingTasksTest'], - queryFn: async () => { - await sleep(50) - return 'test data' - }, + queryKey: key, + queryFn: () => sleep(50).then(() => 'test data'), })), ) @@ -630,15 +711,9 @@ describe('injectQuery', () => { expect(query.data()).toBe('test data') }) - test('should complete HttpClient-based queries before whenStable() resolves', async () => { - TestBed.resetTestingModule() + it('should complete HttpClient-based queries before whenStable() resolves', async () => { TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - provideHttpClient(), - provideHttpClientTesting(), - ], + providers: [provideHttpClient(), provideHttpClientTesting()], }) const app = TestBed.inject(ApplicationRef) @@ -646,9 +721,10 @@ describe('injectQuery', () => { const httpTestingController = TestBed.inject(HttpTestingController) // Create a query using HttpClient + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['httpClientTest'], + queryKey: key, queryFn: () => lastValueFrom(httpClient.get<{ message: string }>('/api/test')), })), @@ -675,21 +751,14 @@ describe('injectQuery', () => { httpTestingController.verify() }) - test('should handle synchronous queryFn with staleTime', async () => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) - + it('should handle synchronous queryFn with staleTime', async () => { const app = TestBed.inject(ApplicationRef) let callCount = 0 + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['sync-stale'], + queryKey: key, staleTime: 1000, queryFn: () => { callCount++ @@ -718,22 +787,15 @@ describe('injectQuery', () => { expect(callCount).toBe(2) }) - test('should handle enabled/disabled transitions with synchronous queryFn', async () => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) - + it('should handle enabled/disabled transitions with synchronous queryFn', async () => { const app = TestBed.inject(ApplicationRef) const enabledSignal = signal(false) let callCount = 0 + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['sync-enabled'], + queryKey: key, enabled: enabledSignal(), queryFn: () => { callCount++ @@ -759,17 +821,9 @@ describe('injectQuery', () => { expect(callCount).toBe(1) }) - test('should handle query invalidation with synchronous data', async () => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) - + it('should handle query invalidation with synchronous data', async () => { const app = TestBed.inject(ApplicationRef) - const testKey = ['sync-invalidate'] + const testKey = queryKey() let callCount = 0 const query = TestBed.runInInjectionContext(() => diff --git a/packages/angular-query-experimental/src/__tests__/mutation-options.test-d.ts b/packages/angular-query-experimental/src/__tests__/mutation-options.test-d.ts index 968eda7b30d..89691bf81a4 100644 --- a/packages/angular-query-experimental/src/__tests__/mutation-options.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/mutation-options.test-d.ts @@ -1,4 +1,5 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '@tanstack/query-core' import { injectIsMutating, @@ -16,10 +17,11 @@ import type { CreateMutationOptions, CreateMutationResult } from '../types' describe('mutationOptions', () => { it('should not allow excess properties', () => { + const key = queryKey() // @ts-expect-error this is a good error, because onMutates does not exist! mutationOptions({ mutationFn: () => Promise.resolve(5), - mutationKey: ['key'], + mutationKey: key, onMutates: 1000, onSuccess: (data) => { expectTypeOf(data).toEqualTypeOf() @@ -28,9 +30,10 @@ describe('mutationOptions', () => { }) it('should infer types for callbacks', () => { + const key = queryKey() mutationOptions({ mutationFn: () => Promise.resolve(5), - mutationKey: ['key'], + mutationKey: key, onSuccess: (data) => { expectTypeOf(data).toEqualTypeOf() }, @@ -38,11 +41,12 @@ describe('mutationOptions', () => { }) it('should infer types for onError callback', () => { + const key = queryKey() mutationOptions({ mutationFn: () => { throw new Error('fail') }, - mutationKey: ['key'], + mutationKey: key, onError: (error) => { expectTypeOf(error).toEqualTypeOf() }, @@ -50,19 +54,21 @@ describe('mutationOptions', () => { }) it('should infer types for variables', () => { + const key = queryKey() mutationOptions({ mutationFn: (vars) => { expectTypeOf(vars).toEqualTypeOf<{ id: string }>() return Promise.resolve(5) }, - mutationKey: ['with-vars'], + mutationKey: key, }) }) it('should infer result type correctly', () => { + const key = queryKey() mutationOptions({ mutationFn: () => Promise.resolve(5), - mutationKey: ['key'], + mutationKey: key, onMutate: () => { return { name: 'onMutateResult' } }, @@ -73,12 +79,13 @@ describe('mutationOptions', () => { }) it('should infer context type correctly', () => { + const key = queryKey() mutationOptions({ mutationFn: (_variables, context) => { expectTypeOf(context).toEqualTypeOf() return Promise.resolve(5) }, - mutationKey: ['key'], + mutationKey: key, onMutate: (_variables, context) => { expectTypeOf(context).toEqualTypeOf() }, @@ -113,10 +120,11 @@ describe('mutationOptions', () => { }) it('should infer all types when not explicitly provided', () => { + const key = queryKey() expectTypeOf( mutationOptions({ mutationFn: (id: string) => Promise.resolve(id.length), - mutationKey: ['key'], + mutationKey: key, onSuccess: (data) => { expectTypeOf(data).toEqualTypeOf() }, @@ -140,9 +148,10 @@ describe('mutationOptions', () => { }) it('should infer types when used with injectMutation', () => { + const key = queryKey() const mutation = injectMutation(() => mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => Promise.resolve('data'), onSuccess: (data) => { expectTypeOf(data).toEqualTypeOf() @@ -166,9 +175,10 @@ describe('mutationOptions', () => { }) it('should infer types when used with injectIsMutating', () => { + const key = queryKey() const isMutating = injectIsMutating( mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => Promise.resolve(5), }), ) @@ -183,11 +193,12 @@ describe('mutationOptions', () => { }) it('should infer types when used with queryClient.isMutating', () => { + const key = queryKey() const queryClient = new QueryClient() const isMutating = queryClient.isMutating( mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => Promise.resolve(5), }), ) @@ -202,9 +213,10 @@ describe('mutationOptions', () => { }) it('should infer types when used with injectMutationState', () => { + const key = queryKey() const mutationState = injectMutationState(() => ({ filters: mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => Promise.resolve(5), }), })) diff --git a/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts b/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts index e07b371c6a6..538e353852b 100644 --- a/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts +++ b/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { provideZonelessChangeDetection } from '@angular/core' import { TestBed } from '@angular/core/testing' import { QueryClient } from '@tanstack/query-core' -import { sleep } from '@tanstack/query-test-utils' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { injectIsMutating, injectMutation, @@ -31,8 +31,9 @@ describe('mutationOptions', () => { }) it('should return the object received as a parameter without any modification (with mutationKey in mutationOptions)', () => { + const key = queryKey() const object: CreateMutationOptions = { - mutationKey: ['key'], + mutationKey: key, mutationFn: () => sleep(10).then(() => 5), } as const @@ -48,8 +49,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with injectIsMutating (with mutationKey in mutationOptions)', async () => { + const key = queryKey() const mutationOpts = mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => sleep(50).then(() => 'data'), }) @@ -89,8 +91,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with injectIsMutating', async () => { + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => sleep(50).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -117,8 +120,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with injectIsMutating (filter mutationOpts1.mutationKey)', async () => { + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => sleep(50).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -145,8 +149,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => { + const key = queryKey() const mutationOpts = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(500).then(() => 'data'), }) @@ -180,8 +185,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with queryClient.isMutating', async () => { + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(500).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -203,8 +209,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpt1.mutationKey)', async () => { + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(500).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -232,8 +239,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with injectMutationState (with mutationKey in mutationOptions)', async () => { + const key = queryKey() const mutationOpts = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(10).then(() => 'data'), }) @@ -273,8 +281,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with injectMutationState', async () => { + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(10).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -302,8 +311,9 @@ describe('mutationOptions', () => { }) it('should return the number of fetching mutations when used with injectMutationState (filter mutationOpt1.mutationKey)', async () => { + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(10).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts index 92f70aed9f3..c5a1f58d81f 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -9,8 +9,8 @@ import { HttpTestingController, provideHttpClientTesting, } from '@angular/common/http/testing' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { sleep } from '@tanstack/query-test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' import { QueryClient, @@ -47,17 +47,18 @@ describe('PendingTasks Integration', () => { afterEach(() => { onlineManager.setOnline(true) - vi.useRealTimers() queryClient.clear() + vi.useRealTimers() }) describe('Synchronous Resolution', () => { - test('should handle synchronous queryFn with whenStable()', async () => { + it('should handle synchronous queryFn with whenStable()', async () => { const app = TestBed.inject(ApplicationRef) + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['sync'], + queryKey: key, queryFn: () => 'instant-data', // Resolves synchronously })), ) @@ -77,12 +78,13 @@ describe('PendingTasks Integration', () => { expect(query.data()).toBe('instant-data') }) - test('should handle synchronous error with whenStable()', async () => { + it('should handle synchronous error with whenStable()', async () => { const app = TestBed.inject(ApplicationRef) + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['sync-error'], + queryKey: key, queryFn: () => { throw new Error('instant-error') }, // Throws synchronously @@ -99,7 +101,7 @@ describe('PendingTasks Integration', () => { expect(query.error()).toEqual(new Error('instant-error')) }) - test('should handle synchronous mutationFn with whenStable()', async () => { + it('should handle synchronous mutationFn with whenStable()', async () => { const app = TestBed.inject(ApplicationRef) let mutationFnCalled = false @@ -127,7 +129,7 @@ describe('PendingTasks Integration', () => { expect(mutation.data()).toBe('processed: test') }) - test('should handle synchronous mutation error with whenStable()', async () => { + it('should handle synchronous mutation error with whenStable()', async () => { const app = TestBed.inject(ApplicationRef) const mutation = TestBed.runInInjectionContext(() => @@ -154,7 +156,8 @@ describe('PendingTasks Integration', () => { }) describe('Race Conditions', () => { - test('should handle query that completes during initial subscription', async () => { + it('should handle query that completes during initial subscription', async () => { + const key = queryKey() const app = TestBed.inject(ApplicationRef) let resolveQuery: (value: string) => void @@ -164,7 +167,7 @@ describe('PendingTasks Integration', () => { const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['race-condition'], + queryKey: key, queryFn: () => queryPromise, })), ) @@ -181,13 +184,14 @@ describe('PendingTasks Integration', () => { expect(query.data()).toBe('race-data') }) - test('should handle rapid refetches without task leaks', async () => { + it('should handle rapid refetches without task leaks', async () => { const app = TestBed.inject(ApplicationRef) let callCount = 0 + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['rapid-refetch'], + queryKey: key, queryFn: async () => { callCount++ await sleep(10) @@ -209,13 +213,14 @@ describe('PendingTasks Integration', () => { expect(query.data()).toMatch(/^data-\d+$/) }) - test('should keep PendingTasks active while query retry is paused offline', async () => { + it('should keep PendingTasks active while query retry is paused offline', async () => { const app = TestBed.inject(ApplicationRef) let attempt = 0 + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['paused-offline'], + queryKey: key, retry: 1, retryDelay: 50, // Longer delay to ensure we can go offline before retry queryFn: async () => { @@ -283,21 +288,16 @@ describe('PendingTasks Integration', () => { class TestComponent { query = injectQuery(() => ({ queryKey: ['component-query'], - queryFn: async () => { - await sleep(100) - return 'component-data' - }, + queryFn: () => sleep(100).then(() => 'component-data'), })) mutation = injectMutation(() => ({ - mutationFn: async (data: string) => { - await sleep(100) - return `processed: ${data}` - }, + mutationFn: (data: string) => + sleep(100).then(() => `processed: ${data}`), })) } - test('should cleanup pending tasks when component with active query is destroyed', async () => { + it('should cleanup pending tasks when component with active query is destroyed', async () => { const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) @@ -314,7 +314,7 @@ describe('PendingTasks Integration', () => { await expect(stablePromise).resolves.toEqual(undefined) }) - test('should cleanup pending tasks when component with active mutation is destroyed', async () => { + it('should cleanup pending tasks when component with active mutation is destroyed', async () => { const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) @@ -332,32 +332,29 @@ describe('PendingTasks Integration', () => { }) describe('Concurrent Operations', () => { - test('should handle multiple queries running simultaneously', async () => { + it('should handle multiple queries running simultaneously', async () => { + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() const app = TestBed.inject(ApplicationRef) const query1 = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['concurrent-1'], - queryFn: async () => { - await sleep(30) - return 'data-1' - }, + queryKey: key1, + queryFn: () => sleep(30).then(() => 'data-1'), })), ) const query2 = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['concurrent-2'], - queryFn: async () => { - await sleep(50) - return 'data-2' - }, + queryKey: key2, + queryFn: () => sleep(50).then(() => 'data-2'), })), ) const query3 = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['concurrent-3'], + queryKey: key3, queryFn: () => 'instant-data', // Synchronous })), ) @@ -380,24 +377,20 @@ describe('PendingTasks Integration', () => { expect(query3.data()).toBe('instant-data') }) - test('should handle multiple mutations running simultaneously', async () => { + it('should handle multiple mutations running simultaneously', async () => { const app = TestBed.inject(ApplicationRef) const mutation1 = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationFn: async (data: string) => { - await sleep(30) - return `processed-1: ${data}` - }, + mutationFn: (data: string) => + sleep(30).then(() => `processed-1: ${data}`), })), ) const mutation2 = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationFn: async (data: string) => { - await sleep(50) - return `processed-2: ${data}` - }, + mutationFn: (data: string) => + sleep(50).then(() => `processed-2: ${data}`), })), ) @@ -430,25 +423,21 @@ describe('PendingTasks Integration', () => { expect(mutation3.data()).toBe('processed-3: test3') }) - test('should handle mixed queries and mutations', async () => { + it('should handle mixed queries and mutations', async () => { const app = TestBed.inject(ApplicationRef) + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['mixed-query'], - queryFn: async () => { - await sleep(40) - return 'query-data' - }, + queryKey: key, + queryFn: () => sleep(40).then(() => 'query-data'), })), ) const mutation = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationFn: async (data: string) => { - await sleep(60) - return `mutation: ${data}` - }, + mutationFn: (data: string) => + sleep(60).then(() => `mutation: ${data}`), })), ) @@ -469,25 +458,22 @@ describe('PendingTasks Integration', () => { describe('HttpClient Integration', () => { beforeEach(() => { - TestBed.resetTestingModule() TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - provideHttpClient(), - provideHttpClientTesting(), - ], + providers: [provideHttpClient(), provideHttpClientTesting()], }) }) - test('should handle multiple HttpClient requests with lastValueFrom', async () => { + it('should handle multiple HttpClient requests with lastValueFrom', async () => { const app = TestBed.inject(ApplicationRef) const httpClient = TestBed.inject(HttpClient) const httpTestingController = TestBed.inject(HttpTestingController) + const key1 = queryKey() + const key2 = queryKey() + const query1 = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['http-1'], + queryKey: key1, queryFn: () => lastValueFrom(httpClient.get<{ id: number }>('/api/1')), })), @@ -495,7 +481,7 @@ describe('PendingTasks Integration', () => { const query2 = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['http-2'], + queryKey: key2, queryFn: () => lastValueFrom(httpClient.get<{ id: number }>('/api/2')), })), @@ -522,14 +508,15 @@ describe('PendingTasks Integration', () => { httpTestingController.verify() }) - test('should handle HttpClient request cancellation', async () => { + it('should handle HttpClient request cancellation', async () => { const app = TestBed.inject(ApplicationRef) const httpClient = TestBed.inject(HttpClient) const httpTestingController = TestBed.inject(HttpTestingController) + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['http-cancel'], + queryKey: key, queryFn: () => lastValueFrom(httpClient.get<{ data: string }>('/api/cancel')), })), @@ -555,22 +542,20 @@ describe('PendingTasks Integration', () => { }) describe('Edge Cases', () => { - test('should handle query cancellation mid-flight', async () => { + it('should handle query cancellation mid-flight', async () => { + const key = queryKey() const app = TestBed.inject(ApplicationRef) const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['cancel-test'], - queryFn: async () => { - await sleep(100) - return 'data' - }, + queryKey: key, + queryFn: () => sleep(100).then(() => 'data'), })), ) // Cancel the query after a short delay setTimeout(() => { - queryClient.cancelQueries({ queryKey: ['cancel-test'] }) + queryClient.cancelQueries({ queryKey: key }) }, 20) // Advance to the cancellation point @@ -587,13 +572,14 @@ describe('PendingTasks Integration', () => { expect(query.fetchStatus()).toBe('idle') }) - test('should handle query retry and pending task tracking', async () => { + it('should handle query retry and pending task tracking', async () => { const app = TestBed.inject(ApplicationRef) let attemptCount = 0 + const key = queryKey() const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ - queryKey: ['retry-test'], + queryKey: key, retry: 2, retryDelay: 10, queryFn: async () => { @@ -615,18 +601,15 @@ describe('PendingTasks Integration', () => { expect(attemptCount).toBe(3) // Initial + 2 retries }) - test('should handle mutation with optimistic updates', async () => { + it('should handle mutation with optimistic updates', async () => { const app = TestBed.inject(ApplicationRef) - const testQueryKey = ['optimistic-test'] + const testQueryKey = queryKey() queryClient.setQueryData(testQueryKey, 'initial-data') const mutation = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationFn: async (newData: string) => { - await sleep(50) - return newData - }, + mutationFn: (newData: string) => sleep(50).then(() => newData), onMutate: async (newData) => { // Optimistic update const previousData = queryClient.getQueryData(testQueryKey) diff --git a/packages/angular-query-experimental/src/__tests__/provide-query-client.test.ts b/packages/angular-query-experimental/src/__tests__/provide-query-client.test.ts index 2ea84e4b3f9..5253274de34 100644 --- a/packages/angular-query-experimental/src/__tests__/provide-query-client.test.ts +++ b/packages/angular-query-experimental/src/__tests__/provide-query-client.test.ts @@ -1,11 +1,11 @@ import { TestBed } from '@angular/core/testing' -import { describe, expect, test } from 'vitest' +import { describe, expect, it } from 'vitest' import { InjectionToken, provideZonelessChangeDetection } from '@angular/core' import { QueryClient } from '@tanstack/query-core' import { provideQueryClient } from '../providers' describe('provideQueryClient', () => { - test('should provide a QueryClient instance directly', () => { + it('should provide a QueryClient instance directly', () => { const queryClient = new QueryClient() TestBed.configureTestingModule({ @@ -19,7 +19,7 @@ describe('provideQueryClient', () => { expect(providedQueryClient).toBe(queryClient) }) - test('should provide a QueryClient instance using an InjectionToken', () => { + it('should provide a QueryClient instance using an InjectionToken', () => { const queryClient = new QueryClient() const CUSTOM_QUERY_CLIENT = new InjectionToken('', { factory: () => queryClient, diff --git a/packages/angular-query-experimental/src/__tests__/provide-tanstack-query.test.ts b/packages/angular-query-experimental/src/__tests__/provide-tanstack-query.test.ts index 1f6cf582ba5..ff1cf4324fc 100644 --- a/packages/angular-query-experimental/src/__tests__/provide-tanstack-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/provide-tanstack-query.test.ts @@ -1,11 +1,11 @@ import { TestBed } from '@angular/core/testing' -import { describe, expect, test } from 'vitest' +import { describe, expect, it } from 'vitest' import { InjectionToken, provideZonelessChangeDetection } from '@angular/core' import { QueryClient } from '@tanstack/query-core' import { provideTanStackQuery } from '../providers' describe('provideTanStackQuery', () => { - test('should provide a QueryClient instance directly', () => { + it('should provide a QueryClient instance directly', () => { const queryClient = new QueryClient() TestBed.configureTestingModule({ @@ -19,7 +19,7 @@ describe('provideTanStackQuery', () => { expect(providedQueryClient).toBe(queryClient) }) - test('should provide a QueryClient instance using an InjectionToken', () => { + it('should provide a QueryClient instance using an InjectionToken', () => { const queryClient = new QueryClient() const CUSTOM_QUERY_CLIENT = new InjectionToken('', { factory: () => queryClient, diff --git a/packages/angular-query-experimental/src/__tests__/query-options.test-d.ts b/packages/angular-query-experimental/src/__tests__/query-options.test-d.ts index 350ab3dda28..8642cf07d72 100644 --- a/packages/angular-query-experimental/src/__tests__/query-options.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/query-options.test-d.ts @@ -1,15 +1,17 @@ -import { assertType, describe, expectTypeOf, test } from 'vitest' +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' import { QueryClient, dataTagSymbol, injectQuery, queryOptions } from '..' import type { Signal } from '@angular/core' describe('queryOptions', () => { - test('should not allow excess properties', () => { + it('should not allow excess properties', () => { expectTypeOf(queryOptions).parameter(0).not.toHaveProperty('stallTime') }) - test('should infer types for callbacks', () => { + it('should infer types for callbacks', () => { + const key = queryKey() queryOptions({ - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve(5), staleTime: 1000, select: (data) => { @@ -18,10 +20,11 @@ describe('queryOptions', () => { }) }) - test('should allow undefined response in initialData', () => { + it('should allow undefined response in initialData', () => { + const key = queryKey() const options = (id: string | null) => queryOptions({ - queryKey: ['todo', id], + queryKey: [...key, id], queryFn: () => Promise.resolve({ id: '1', @@ -42,9 +45,10 @@ describe('queryOptions', () => { }) }) -test('should work when passed to injectQuery', () => { +it('should work when passed to injectQuery', () => { + const key = queryKey() const options = queryOptions({ - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve(5), }) @@ -52,9 +56,10 @@ test('should work when passed to injectQuery', () => { expectTypeOf(data).toEqualTypeOf>() }) -test('should work when passed to fetchQuery', () => { +it('should work when passed to fetchQuery', () => { + const key = queryKey() const options = queryOptions({ - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve(5), }) @@ -62,60 +67,66 @@ test('should work when passed to fetchQuery', () => { assertType>(data) }) -test('should tag the queryKey with the result type of the QueryFn', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], +it('should tag the queryKey with the result type of the QueryFn', () => { + const key = queryKey() + const { queryKey: tagged } = queryOptions({ + queryKey: key, queryFn: () => Promise.resolve(5), }) - assertType(queryKey[dataTagSymbol]) + assertType(tagged[dataTagSymbol]) }) -test('should tag the queryKey even if no promise is returned', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], +it('should tag the queryKey even if no promise is returned', () => { + const key = queryKey() + const { queryKey: tagged } = queryOptions({ + queryKey: key, queryFn: () => 5, }) - assertType(queryKey[dataTagSymbol]) + assertType(tagged[dataTagSymbol]) }) -test('should tag the queryKey with unknown if there is no queryFn', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], +it('should tag the queryKey with unknown if there is no queryFn', () => { + const key = queryKey() + const { queryKey: tagged } = queryOptions({ + queryKey: key, }) - assertType(queryKey[dataTagSymbol]) + assertType(tagged[dataTagSymbol]) }) -test('should tag the queryKey with the result type of the QueryFn if select is used', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], +it('should tag the queryKey with the result type of the QueryFn if select is used', () => { + const key = queryKey() + const { queryKey: tagged } = queryOptions({ + queryKey: key, queryFn: () => Promise.resolve(5), select: (data) => data.toString(), }) - assertType(queryKey[dataTagSymbol]) + assertType(tagged[dataTagSymbol]) }) -test('should return the proper type when passed to getQueryData', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], +it('should return the proper type when passed to getQueryData', () => { + const key = queryKey() + const { queryKey: tagged } = queryOptions({ + queryKey: key, queryFn: () => Promise.resolve(5), }) const queryClient = new QueryClient() - const data = queryClient.getQueryData(queryKey) + const data = queryClient.getQueryData(tagged) expectTypeOf(data).toEqualTypeOf() }) -test('should properly type updaterFn when passed to setQueryData', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], +it('should properly type updaterFn when passed to setQueryData', () => { + const key = queryKey() + const { queryKey: tagged } = queryOptions({ + queryKey: key, queryFn: () => Promise.resolve(5), }) const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, (prev) => { + const data = queryClient.setQueryData(tagged, (prev) => { expectTypeOf(prev).toEqualTypeOf() return prev }) @@ -123,20 +134,21 @@ test('should properly type updaterFn when passed to setQueryData', () => { expectTypeOf(data).toEqualTypeOf() }) -test('should properly type value when passed to setQueryData', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], +it('should properly type value when passed to setQueryData', () => { + const key = queryKey() + const { queryKey: tagged } = queryOptions({ + queryKey: key, queryFn: () => Promise.resolve(5), }) const queryClient = new QueryClient() // @ts-expect-error value should be a number - queryClient.setQueryData(queryKey, '5') + queryClient.setQueryData(tagged, '5') // @ts-expect-error value should be a number - queryClient.setQueryData(queryKey, () => '5') + queryClient.setQueryData(tagged, () => '5') - const data = queryClient.setQueryData(queryKey, 5) + const data = queryClient.setQueryData(tagged, 5) expectTypeOf(data).toEqualTypeOf() }) diff --git a/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts b/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts index d06aef67230..cebcd7b82bb 100644 --- a/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts +++ b/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts @@ -1,27 +1,27 @@ import { isSignal, signal } from '@angular/core' -import { describe, expect, test } from 'vitest' +import { describe, expect, it } from 'vitest' import { signalProxy } from '../signal-proxy' describe('signalProxy', () => { const inputSignal = signal({ fn: () => 'bar', baz: 'qux' }) const proxy = signalProxy(inputSignal) - test('should have computed fields', () => { + it('should have computed fields', () => { expect(proxy.baz()).toEqual('qux') expect(isSignal(proxy.baz)).toBe(true) }) - test('should pass through functions as-is', () => { + it('should pass through functions as-is', () => { expect(proxy.fn()).toEqual('bar') expect(isSignal(proxy.fn)).toBe(false) }) - test('supports "in" operator', () => { + it('should support "in" operator', () => { expect('baz' in proxy).toBe(true) expect('foo' in proxy).toBe(false) }) - test('supports "Object.keys"', () => { + it('should support "Object.keys"', () => { expect(Object.keys(proxy)).toEqual(['fn', 'baz']) }) }) diff --git a/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts b/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts index 6c6e1281154..6907186a928 100644 --- a/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts +++ b/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient } from '@tanstack/query-core' import { TestBed } from '@angular/core/testing' import { @@ -58,11 +58,11 @@ describe('withDevtools feature', () => { afterEach(() => { vi.restoreAllMocks() - vi.useRealTimers() TestBed.resetTestingModule() + vi.useRealTimers() }) - test.each([ + it.each([ { description: 'should load devtools in development mode', isDevMode: true, @@ -153,6 +153,33 @@ describe('withDevtools feature', () => { }, ) + it("should throw 'No QueryClient found' when 'loadDevtools' is 'true' and no 'QueryClient' is provided", async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + withDevtools(() => ({ + loadDevtools: true, + })).ɵproviders, + ], + }) + + TestBed.inject(ENVIRONMENT_INITIALIZER) + TestBed.tick() + await vi.dynamicImportSettled() + + expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Install @tanstack/query-devtools or reinstall without --omit=optional.', + expect.objectContaining({ message: 'No QueryClient found' }), + ) + + consoleErrorSpy.mockRestore() + }) + it('should not continue loading devtools after injector is destroyed', async () => { TestBed.configureTestingModule({ providers: [ diff --git a/packages/angular-query-experimental/src/devtools-panel/types.ts b/packages/angular-query-experimental/src/devtools-panel/types.ts index b87373ad953..f91974ee403 100644 --- a/packages/angular-query-experimental/src/devtools-panel/types.ts +++ b/packages/angular-query-experimental/src/devtools-panel/types.ts @@ -42,7 +42,7 @@ export interface DevtoolsPanelOptions { /** * Callback function that is called when the devtools panel is closed */ - onClose?: () => unknown + onClose?: () => void /** * Element where to render the devtools panel. When set to undefined or null, the devtools panel will not be created, or destroyed if existing. diff --git a/packages/angular-query-experimental/src/devtools/types.ts b/packages/angular-query-experimental/src/devtools/types.ts index 614c6f95fd1..2dd870b3738 100644 --- a/packages/angular-query-experimental/src/devtools/types.ts +++ b/packages/angular-query-experimental/src/devtools/types.ts @@ -3,6 +3,7 @@ import type { DevtoolsButtonPosition, DevtoolsErrorType, DevtoolsPosition, + Theme, } from '@tanstack/query-devtools' import type { DevtoolsFeature } from '../providers' @@ -50,7 +51,7 @@ export interface DevtoolsOptions { */ buttonPosition?: DevtoolsButtonPosition /** - * The position of the TanStack Query devtools panel. + * The position of the Angular Query devtools panel. * `top` | `bottom` | `left` | `right` * Defaults to `bottom`. */ @@ -75,6 +76,11 @@ export interface DevtoolsOptions { * Set this to true to hide disabled queries from the devtools panel. */ hideDisabledQueries?: boolean + /** + * Set this to 'light', 'dark', or 'system' to change the theme of the devtools panel. + * Defaults to 'system'. + */ + theme?: Theme /** * Whether the developer tools should load. diff --git a/packages/angular-query-experimental/src/devtools/with-devtools.ts b/packages/angular-query-experimental/src/devtools/with-devtools.ts index 22ee80c1ca1..4d4b861c7f2 100644 --- a/packages/angular-query-experimental/src/devtools/with-devtools.ts +++ b/packages/angular-query-experimental/src/devtools/with-devtools.ts @@ -126,6 +126,7 @@ export const withDevtools: WithDevtools = ( errorTypes, buttonPosition, initialIsOpen, + theme, } = devtoolsOptions() if (!shouldLoadTools) { @@ -142,6 +143,7 @@ export const withDevtools: WithDevtools = ( buttonPosition && devtools.setButtonPosition(buttonPosition) typeof initialIsOpen === 'boolean' && devtools.setInitialIsOpen(initialIsOpen) + theme && devtools.setTheme(theme) return } diff --git a/packages/angular-query-experimental/tsconfig.json b/packages/angular-query-experimental/tsconfig.json index 2aecb185457..bcf51c2ffe5 100644 --- a/packages/angular-query-experimental/tsconfig.json +++ b/packages/angular-query-experimental/tsconfig.json @@ -14,6 +14,13 @@ "strictStandalone": true, "strictTemplates": true }, - "include": ["src", "scripts", "test-setup.ts", "*.config.*", "package.json"], + "include": [ + "src", + "scripts", + "test-setup.ts", + "*.config.ts", + "*.config.js", + "package.json" + ], "references": [{ "path": "../query-core" }, { "path": "../query-devtools" }] } diff --git a/packages/angular-query-experimental/tsconfig.prod.json b/packages/angular-query-experimental/tsconfig.prod.json index b470042ddc1..955d36c73e0 100644 --- a/packages/angular-query-experimental/tsconfig.prod.json +++ b/packages/angular-query-experimental/tsconfig.prod.json @@ -5,5 +5,7 @@ "composite": false, "rootDir": "../../", "customConditions": null - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/angular-query-experimental/vite.config.ts b/packages/angular-query-experimental/vite.config.ts index a4d58a77a5a..d5337c94ea6 100644 --- a/packages/angular-query-experimental/vite.config.ts +++ b/packages/angular-query-experimental/vite.config.ts @@ -45,7 +45,7 @@ const config = defineConfig({ environment: 'jsdom', setupFiles: ['test-setup.ts'], coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/angular-query-persist-client/CHANGELOG.md b/packages/angular-query-persist-client/CHANGELOG.md index bd27db1a2f2..e3f3bea04a7 100644 --- a/packages/angular-query-persist-client/CHANGELOG.md +++ b/packages/angular-query-persist-client/CHANGELOG.md @@ -1,5 +1,241 @@ # @tanstack/angular-query-persist-client +## 5.101.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.101.0 + - @tanstack/query-persist-client-core@5.101.0 + +## 5.100.14 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.14 + - @tanstack/query-persist-client-core@5.100.14 + +## 5.100.13 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.13 + - @tanstack/query-persist-client-core@5.100.13 + +## 5.100.12 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.12 + - @tanstack/query-persist-client-core@5.100.12 + +## 5.100.11 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.11 + - @tanstack/query-persist-client-core@5.100.11 + +## 5.100.10 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.10 + - @tanstack/query-persist-client-core@5.100.10 + +## 5.100.9 + +### Patch Changes + +- Updated dependencies [[`bf902df`](https://github.com/TanStack/query/commit/bf902df59dcc3196cbc4a379c43ee55200c75a0a)]: + - @tanstack/angular-query-experimental@5.100.9 + - @tanstack/query-persist-client-core@5.100.9 + +## 5.100.8 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.8 + - @tanstack/query-persist-client-core@5.100.8 + +## 5.100.7 + +### Patch Changes + +- Updated dependencies [[`868577d`](https://github.com/TanStack/query/commit/868577d5daa6de7dc7698f8b41ad5cf225606d05)]: + - @tanstack/angular-query-experimental@5.100.7 + - @tanstack/query-persist-client-core@5.100.7 + +## 5.100.6 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.6 + - @tanstack/query-persist-client-core@5.100.6 + +## 5.100.5 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.5 + - @tanstack/query-persist-client-core@5.100.5 + +## 5.100.4 + +### Patch Changes + +- Updated dependencies [[`3d1a62e`](https://github.com/TanStack/query/commit/3d1a62e63bd864359e369bb21356fa80d043f2ba)]: + - @tanstack/angular-query-experimental@5.100.4 + - @tanstack/query-persist-client-core@5.100.4 + +## 5.100.3 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.3 + - @tanstack/query-persist-client-core@5.100.3 + +## 5.100.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.2 + - @tanstack/query-persist-client-core@5.100.2 + +## 5.100.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.1 + - @tanstack/query-persist-client-core@5.100.1 + +## 5.100.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.100.0 + - @tanstack/query-persist-client-core@5.100.0 + +## 5.99.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.99.2 + - @tanstack/query-persist-client-core@5.99.2 + +## 5.99.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.99.1 + - @tanstack/query-persist-client-core@5.99.1 + +## 5.99.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.99.0 + - @tanstack/query-persist-client-core@5.99.0 + +## 5.98.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.98.0 + - @tanstack/query-persist-client-core@5.98.0 + +## 5.97.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.97.0 + - @tanstack/query-persist-client-core@5.97.0 + +## 5.96.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.96.2 + - @tanstack/query-persist-client-core@5.96.2 + +## 5.96.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.96.1 + - @tanstack/query-persist-client-core@5.96.1 + +## 5.96.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.96.0 + - @tanstack/query-persist-client-core@5.96.0 + +## 5.95.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.95.2 + - @tanstack/query-persist-client-core@5.95.2 + +## 5.95.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.95.1 + - @tanstack/query-persist-client-core@5.95.1 + +## 5.95.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/angular-query-experimental@5.95.0 + - @tanstack/query-persist-client-core@5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]: + - @tanstack/angular-query-experimental@5.94.5 + - @tanstack/query-persist-client-core@5.94.5 + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + +- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]: + - @tanstack/angular-query-experimental@5.94.4 + - @tanstack/query-persist-client-core@5.94.4 + ## 5.62.32 ### Patch Changes diff --git a/packages/angular-query-persist-client/package.json b/packages/angular-query-persist-client/package.json index 0870cfbe454..5b185188179 100644 --- a/packages/angular-query-persist-client/package.json +++ b/packages/angular-query-persist-client/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/angular-query-persist-client", "private": true, - "version": "5.62.32", + "version": "5.101.0", "description": "Angular bindings to work with persisters in TanStack/angular-query", "author": "Omer Gronich", "license": "MIT", diff --git a/packages/angular-query-persist-client/src/__tests__/with-persist-query-client.test.ts b/packages/angular-query-persist-client/src/__tests__/with-persist-query-client.test.ts index 0ff84e82258..1f9bd61419a 100644 --- a/packages/angular-query-persist-client/src/__tests__/with-persist-query-client.test.ts +++ b/packages/angular-query-persist-client/src/__tests__/with-persist-query-client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient, injectQuery, @@ -18,14 +18,6 @@ import type { Persister, } from '@tanstack/query-persist-client-core' -beforeEach(() => { - vi.useFakeTimers() -}) - -afterEach(() => { - vi.useRealTimers() -}) - const createMockPersister = (): Persister => { let storedState: PersistedClient | undefined @@ -62,7 +54,15 @@ const createMockErrorPersister = ( } describe('withPersistQueryClient', () => { - test('restores cache from persister', async () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should restore cache from persister', async () => { const key = queryKey() const states: Array<{ status: string @@ -97,13 +97,16 @@ describe('withPersistQueryClient', () => { queryKey: key, queryFn: () => sleep(10).then(() => 'fetched'), })) - _ = effect(() => { - states.push({ - status: this.state.status(), - fetchStatus: this.state.fetchStatus(), - data: this.state.data(), + + constructor() { + effect(() => { + states.push({ + status: this.state.status(), + fetchStatus: this.state.fetchStatus(), + data: this.state.data(), + }) }) - }) + } } const rendered = await render(Page, { @@ -146,11 +149,11 @@ describe('withPersistQueryClient', () => { }) }) - test.todo( + it.todo( '(Once injectQueries is functional) verify that injectQueries transitions to an idle state', ) - test('should show initialData while restoring', async () => { + it('should show initialData while restoring', async () => { const key = queryKey() const states: Array<{ status: string @@ -189,13 +192,16 @@ describe('withPersistQueryClient', () => { // otherwise initialData would be newer and takes precedence initialDataUpdatedAt: 1, })) - _ = effect(() => { - states.push({ - status: this.state.status(), - fetchStatus: this.state.fetchStatus(), - data: this.state.data(), + + constructor() { + effect(() => { + states.push({ + status: this.state.status(), + fetchStatus: this.state.fetchStatus(), + data: this.state.data(), + }) }) - }) + } } const rendered = await render(Page, { @@ -238,7 +244,7 @@ describe('withPersistQueryClient', () => { }) }) - test('should not refetch after restoring when data is fresh', async () => { + it('should not refetch after restoring when data is fresh', async () => { const key = queryKey() const states: Array<{ status: string @@ -280,13 +286,16 @@ describe('withPersistQueryClient', () => { }, staleTime: Infinity, })) - _ = effect(() => { - states.push({ - status: this.state.status(), - fetchStatus: this.state.fetchStatus(), - data: this.state.data(), + + constructor() { + effect(() => { + states.push({ + status: this.state.status(), + fetchStatus: this.state.fetchStatus(), + data: this.state.data(), + }) }) - }) + } } const rendered = await render(Page, { @@ -321,7 +330,7 @@ describe('withPersistQueryClient', () => { }) }) - test('should call onSuccess after successful restoring', async () => { + it('should call onSuccess after successful restoring', async () => { const key = queryKey() const queryClient = new QueryClient() queryClient.prefetchQuery({ @@ -377,7 +386,7 @@ describe('withPersistQueryClient', () => { expect(rendered.getByText('fetched')).toBeInTheDocument() }) - test('should remove cache after non-successful restoring', async () => { + it('should remove cache after non-successful restoring', async () => { const key = queryKey() const onErrorMock = vi .spyOn(console, 'error') diff --git a/packages/angular-query-persist-client/tsconfig.json b/packages/angular-query-persist-client/tsconfig.json index 63b0ee37e8d..b1366ab7700 100644 --- a/packages/angular-query-persist-client/tsconfig.json +++ b/packages/angular-query-persist-client/tsconfig.json @@ -7,7 +7,13 @@ "useDefineForClassFields": false, "target": "ES2022" }, - "include": ["src", "test-setup.ts", "*.config.*", "package.json"], + "include": [ + "src", + "test-setup.ts", + "*.config.ts", + "*.config.js", + "package.json" + ], "references": [ { "path": "../angular-query-experimental" }, { "path": "../query-persist-client-core" } diff --git a/packages/angular-query-persist-client/tsconfig.prod.json b/packages/angular-query-persist-client/tsconfig.prod.json index 0f4c92da065..2bb29fdf02a 100644 --- a/packages/angular-query-persist-client/tsconfig.prod.json +++ b/packages/angular-query-persist-client/tsconfig.prod.json @@ -4,5 +4,7 @@ "incremental": false, "composite": false, "rootDir": "../../" - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/angular-query-persist-client/vite.config.ts b/packages/angular-query-persist-client/vite.config.ts index cd95d32df2f..3c608b8a481 100644 --- a/packages/angular-query-persist-client/vite.config.ts +++ b/packages/angular-query-persist-client/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['test-setup.ts'], coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/eslint-plugin-query/CHANGELOG.md b/packages/eslint-plugin-query/CHANGELOG.md index bb5fd382c51..3d3ff84aa7e 100644 --- a/packages/eslint-plugin-query/CHANGELOG.md +++ b/packages/eslint-plugin-query/CHANGELOG.md @@ -1,5 +1,101 @@ # @tanstack/eslint-plugin-query +## 5.101.0 + +### Minor Changes + +- [#10775](https://github.com/TanStack/query/pull/10775) [`dc54932`](https://github.com/TanStack/query/commit/dc549328642e3d5b7417c0ad070d9fbf38c195a4) - `no-rest-destructuring` now also flags rest destructuring on custom hooks that return a TanStack Query result. Detection uses the TypeScript type checker and runs only when typed linting is enabled, so untyped projects are unaffected. Closes [#8951](https://github.com/TanStack/query/issues/8951). + +## 5.100.14 + +## 5.100.13 + +## 5.100.12 + +### Patch Changes + +- fix(no-unstable-deps): handle array-destructured useQueries and useSuspenseQueries results ([#10747](https://github.com/TanStack/query/pull/10747)) + +- Fix `no-unstable-deps` false positives for `useSuspenseQueries` results returned from `combine`. ([#10642](https://github.com/TanStack/query/pull/10642)) + +## 5.100.11 + +## 5.100.10 + +## 5.100.9 + +## 5.100.8 + +## 5.100.7 + +## 5.100.6 + +## 5.100.5 + +## 5.100.4 + +## 5.100.3 + +## 5.100.2 + +## 5.100.1 + +## 5.100.0 + +## 5.99.2 + +## 5.99.1 + +### Patch Changes + +- fix(eslint-plugin-query): fix `no-void-query-fn` false positive on enum returns for typescript 6. ([#10460](https://github.com/TanStack/query/pull/10460)) + +## 5.99.0 + +## 5.98.0 + +## 5.97.0 + +### Minor Changes + +- feat(eslint): support eslint v10 and typescript v6 ([#10182](https://github.com/TanStack/query/pull/10182)) + +## 5.96.2 + +### Patch Changes + +- fix(eslint-plugin): normalize whitespace in allowList variable matching for multiline expressions ([#10337](https://github.com/TanStack/query/pull/10337)) + +## 5.96.1 + +## 5.96.0 + +### Minor Changes + +- Add `prefer-query-options` rule and `recommendedStrict` config ([#10359](https://github.com/TanStack/query/pull/10359)) + +## 5.95.2 + +## 5.95.1 + +## 5.95.0 + +### Minor Changes + +- BREAKING (eslint-plugin): The `exhaustive-deps` rule now reports member expression dependencies more granularly for call expressions (e.g. `a.b.foo()` suggests `a.b`), which may cause existing code that previously passed the rule to now report missing dependencies. To accommodate stable variables and types, the rule now accepts an `allowlist` option with `variables` and `types` arrays to exclude specific dependencies from enforcement. ([#10295](https://github.com/TanStack/query/pull/10295)) + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + ## 5.91.5 ### Patch Changes diff --git a/packages/eslint-plugin-query/package.json b/packages/eslint-plugin-query/package.json index 8e8513515f4..5efae7fc13c 100644 --- a/packages/eslint-plugin-query/package.json +++ b/packages/eslint-plugin-query/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/eslint-plugin-query", - "version": "5.91.5", + "version": "5.101.0", "description": "ESLint plugin for TanStack Query", "author": "Eliya Cohen", "license": "MIT", @@ -58,7 +58,7 @@ "!src/__tests__" ], "dependencies": { - "@typescript-eslint/utils": "^8.48.0" + "@typescript-eslint/utils": "^8.58.1" }, "devDependencies": { "@typescript-eslint/parser": "^8.48.0", @@ -68,8 +68,8 @@ "npm-run-all2": "^5.0.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "^5.4.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "^5.4.0 || ^6.0.0" }, "peerDependenciesMeta": { "typescript": { diff --git a/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts index d16ee89d54d..7e67ae85404 100644 --- a/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts @@ -34,6 +34,20 @@ ruleTester.run('exhaustive-deps', rule, { name: 'should not pass api.entity.get', code: 'useQuery({ queryKey: ["entity", id], queryFn: () => api.entity.get(id) });', }, + { + name: 'should pass api when its member is being invoked', + code: ` + import useApi from './useApi' + + const useFoo = () => { + const api = useApi(); + return useQuery({ + queryKey: ['foo', api], + queryFn: () => api.fetchFoo(), + }) + } + `, + }, { name: 'should pass props.src', code: ` @@ -89,6 +103,17 @@ ruleTester.run('exhaustive-deps', rule, { } `, }, + { + name: 'should ignore type parameters used only in queryFn return type', + code: normalizeIndent` + function useThing() { + return useQuery({ + queryKey: ['thing'], + queryFn: (): Promise => Promise.reject(new Error('nope')), + }) + } + `, + }, { name: 'should add "...args" to deps', code: ` @@ -428,7 +453,7 @@ ruleTester.run('exhaustive-deps', rule, { { name: 'should pass when queryKey uses a direct conditional expression', code: normalizeIndent` - function Component(cond, a, b) { + function Component({ cond, a, b }) { useQuery({ queryKey: ['thing', cond ? a : b], queryFn: () => (cond ? a : b), @@ -439,7 +464,7 @@ ruleTester.run('exhaustive-deps', rule, { { name: 'should pass when queryKey uses a direct binary expression', code: normalizeIndent` - function Component(a, b) { + function Component({ a, b }) { useQuery({ queryKey: ['thing', a + b], queryFn: () => a + b, @@ -476,7 +501,7 @@ ruleTester.run('exhaustive-deps', rule, { function Component({ value }) { useQuery({ - queryKey: ['foo'], + queryKey: ['foo', value], queryFn: () => { return value instanceof SomeClass; } @@ -682,37 +707,244 @@ ruleTester.run('exhaustive-deps', rule, { } `, }, + { + name: 'should pass when multiple sibling member method calls covered by root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a], + queryFn: () => { + a.b.foo() + a.c.bar() + return 1 + } + }) + } + `, + }, + { + name: 'should pass when multiple sibling member method calls explicitly listed', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b, a.c], + queryFn: () => { + a.b.foo() + a.c.bar() + return 1 + } + }) + } + `, + }, + { + name: 'should pass when single member method call covered by root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a], + queryFn: () => { + a.b.foo() + return 1 + } + }) + } + `, + }, + { + name: 'should pass when single member method call uses member path', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b], + queryFn: () => { + a.b.foo() + return 1 + } + }) + } + `, + }, + { + name: 'should pass when optional chaining method call is covered by root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a], + queryFn: () => a?.foo() + }) + } + `, + }, + { + name: 'should pass when queryKey uses TSAsExpression with array', + code: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing', dep] as const, + queryFn: () => dep + }) + } + `, + }, + { + name: 'should pass when queryKey references identifier pointing to array', + code: normalizeIndent` + function useThing(dep) { + const key = ['thing', dep] + return useQuery({ + queryKey: key, + queryFn: () => dep + }) + } + `, + }, + { + name: 'should pass when queryKey has object with spread properties', + code: normalizeIndent` + function useThing(dep1, dep2) { + return useQuery({ + queryKey: ['thing', { ...dep1, prop: dep2 }], + queryFn: () => dep1.prop + dep2 + }) + } + `, + }, + { + name: 'should pass when queryKey has call expression with member callee', + code: normalizeIndent` + function useThing(api) { + return useQuery({ + queryKey: ['thing', api.createKey()], + queryFn: () => api.fetch() + }) + } + `, + }, + { + name: 'should pass when queryKey has call expression with identifier callee', + code: normalizeIndent` + function useThing(dep) { + const makeKeyPart = (value) => value + + return useQuery({ + queryKey: ['thing', makeKeyPart(dep)], + queryFn: () => makeKeyPart(dep) + }) + } + `, + }, + { + name: 'should pass when queryKey has call expression with nested member callee', + code: normalizeIndent` + function useThing(obj) { + return useQuery({ + queryKey: ['thing', obj.api.createKey()], + queryFn: () => obj.api.fetch() + }) + } + `, + }, + { + name: 'should pass when queryFn uses conditional with skipToken', + code: normalizeIndent` + function useThing(condition, dep) { + return useQuery({ + queryKey: ['thing', dep], + queryFn: condition ? () => dep : skipToken + }) + } + `, + }, + { + name: 'should pass when queryFn uses instanceof expression', + code: normalizeIndent` + function useThing(value) { + return useQuery({ + queryKey: ['thing', value], + queryFn: () => { + return value instanceof Date; + } + }) + } + `, + }, + { + name: 'should pass when queryFn uses conditional with skipToken in consequent', + code: normalizeIndent` + function useThing(condition, dep) { + return useQuery({ + queryKey: ['thing', dep], + queryFn: condition ? skipToken : () => dep + }) + } + `, + }, + { + name: 'should pass when queryFn is ternary with both branches having deps in queryKey', + code: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', a, b], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + }, ], invalid: [ { - name: 'should fail when api from hook is used for calling a function', + name: 'should fail when optional chaining method call is missing root', code: normalizeIndent` - import useApi from './useApi' - - const useFoo = () => { - const api = useApi(); + function useThing(a) { return useQuery({ - queryKey: ['foo'], - queryFn: () => api.fetchFoo(), + queryKey: ['thing'], + queryFn: () => a?.foo() }) } `, errors: [ { messageId: 'missingDeps', - data: { deps: 'api' }, + data: { deps: 'a' }, suggestions: [ { messageId: 'fixTo', - data: { result: "['foo', api]" }, output: normalizeIndent` - import useApi from './useApi' - - const useFoo = () => { - const api = useApi(); + function useThing(a) { + return useQuery({ + queryKey: ['thing', a], + queryFn: () => a?.foo() + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when non-null assertion method call is missing root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing'], + queryFn: () => a!.foo() + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'a' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(a) { return useQuery({ - queryKey: ['foo', api], - queryFn: () => api.fetchFoo(), + queryKey: ['thing', a], + queryFn: () => a!.foo() }) } `, @@ -721,6 +953,49 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, + { + name: 'should fail when alias of props used in queryFn is missing in queryKey', + code: normalizeIndent` + function Component(props) { + const entities = props.entities; + + const q = useQuery({ + queryKey: ['get-stuff'], + queryFn: () => { + return api.fetchStuff({ + ids: entities.map((o) => o.id) + }); + } + }); + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'entities' }, + suggestions: [ + { + messageId: 'fixTo', + data: { result: "['get-stuff', entities]" }, + output: normalizeIndent` + function Component(props) { + const entities = props.entities; + + const q = useQuery({ + queryKey: ['get-stuff', entities], + queryFn: () => { + return api.fetchStuff({ + ids: entities.map((o) => o.id) + }); + } + }); + } + `, + }, + ], + }, + ], + }, { name: 'should fail when deps are missing in query factory', code: normalizeIndent` @@ -1020,49 +1295,6 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, - { - name: 'should fail when alias of props used in queryFn is missing in queryKey', - code: normalizeIndent` - function Component(props) { - const entities = props.entities; - - const q = useQuery({ - queryKey: ['get-stuff'], - queryFn: () => { - return api.fetchStuff({ - ids: entities.map((o) => o.id) - }); - } - }); - } - `, - errors: [ - { - messageId: 'missingDeps', - data: { deps: 'entities' }, - suggestions: [ - { - messageId: 'fixTo', - data: { result: "['get-stuff', entities]" }, - output: normalizeIndent` - function Component(props) { - const entities = props.entities; - - const q = useQuery({ - queryKey: ['get-stuff', entities], - queryFn: () => { - return api.fetchStuff({ - ids: entities.map((o) => o.id) - }); - } - }); - } - `, - }, - ], - }, - ], - }, { name: 'should fail when queryKey is a queryKeyFactory while having missing dep', code: normalizeIndent` @@ -1399,5 +1631,625 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, + { + name: 'should fail when sibling member method calls missing one path', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b], + queryFn: () => { + a.b.foo() + a.c.bar() + return 1 + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'a.c' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b, a.c], + queryFn: () => { + a.b.foo() + a.c.bar() + return 1 + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when single member method call missing path and root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing'], + queryFn: () => { + a.b.foo() + return 1 + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'a.b' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b], + queryFn: () => { + a.b.foo() + return 1 + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when queryKey has TSAsExpression with missing dep', + code: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing'] as const, + queryFn: () => dep + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'dep' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing', dep] as const, + queryFn: () => dep + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when queryKey references identifier with missing dep', + code: normalizeIndent` + function useThing(dep) { + const key = ['thing'] + return useQuery({ + queryKey: key, + queryFn: () => dep + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'dep' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(dep) { + const key = ['thing', dep] + return useQuery({ + queryKey: key, + queryFn: () => dep + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when type allowlist is empty', + options: [{ allowlist: { types: [] } }], + code: normalizeIndent` + interface Api { fetch: () => void } + function useThing(api: Api) { + return useQuery({ + queryKey: ['thing'], + queryFn: () => api.fetch() + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'api' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + interface Api { fetch: () => void } + function useThing(api: Api) { + return useQuery({ + queryKey: ['thing', api], + queryFn: () => api.fetch() + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fix correctly when queryKey has trailing comma', + code: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing',], + queryFn: () => dep + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'dep' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing', dep], + queryFn: () => dep + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fix correctly when queryKey is empty with whitespace', + code: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: [ ], + queryFn: () => dep + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'dep' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: [dep], + queryFn: () => dep + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when dep in alternate branch of ternary queryFn is missing', + code: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', a], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'b' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', a, b], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when dep in consequent branch of ternary queryFn is missing', + code: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', b], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'a' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', b, a], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + }, + ], + }, + ], + }, + ], +}) + +ruleTester.run('exhaustive-deps allowlist.types', rule, { + valid: [ + { + name: 'should ignore missing member path when root type is in allowlist.types', + options: [{ allowlist: { types: ['Svc'] } }], + code: normalizeIndent` + interface Svc { part: { load: (id: string) => void } } + function useThing(svc: Svc, id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + }, + { + name: 'should ignore when TypeScript union type contains allowlisted type', + options: [{ allowlist: { types: ['AllowedType'] } }], + code: normalizeIndent` + function useThing(value: AllowedType | OtherType, id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + console.log(value) + return id + } + }) + } + `, + }, + { + name: 'should ignore when TypeScript intersection type contains allowlisted type', + options: [{ allowlist: { types: ['AllowedType'] } }], + code: normalizeIndent` + function useThing(value: AllowedType & OtherType, id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + console.log(value) + return id + } + }) + } + `, + }, + { + name: 'should ignore when TypeScript array type contains allowlisted type', + options: [{ allowlist: { types: ['AllowedType'] } }], + code: normalizeIndent` + function useThing(value: AllowedType[], id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + console.log(value) + return id + } + }) + } + `, + }, + { + name: 'should ignore when TypeScript tuple type contains allowlisted type', + options: [{ allowlist: { types: ['AllowedType'] } }], + code: normalizeIndent` + function useThing(value: [AllowedType, string], id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + console.log(value) + return id + } + }) + } + `, + }, + ], + invalid: [ + { + name: 'should report missing member path when root type not in allowlist.types', + options: [{ allowlist: { types: ['Other'] } }], + code: normalizeIndent` + interface Svc { part: { load: (id: string) => void } } + function useThing(svc: Svc, id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'svc.part' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + interface Svc { part: { load: (id: string) => void } } + function useThing(svc: Svc, id: string) { + return useQuery({ + queryKey: ['thing', id, svc.part], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should report missing member path when variable has type annotation but type not allowlisted', + options: [{ allowlist: { types: ['AllowedService'] } }], + code: normalizeIndent` + interface MyService { method: () => void } + function useData(service: MyService) { + return useQuery({ + queryKey: ['data'], + queryFn: () => { + service.method() + return 'data' + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'service' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + interface MyService { method: () => void } + function useData(service: MyService) { + return useQuery({ + queryKey: ['data', service], + queryFn: () => { + service.method() + return 'data' + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should not inherit allowlisted type from outer shadowed binding', + options: [{ allowlist: { types: ['AllowedService'] } }], + code: normalizeIndent` + interface AllowedService { load: () => void } + interface OtherService { load: () => void } + + function useThing() { + const svc: AllowedService = { load: () => undefined } + + if (true) { + const svc: OtherService = { load: () => undefined } + + return useQuery({ + queryKey: ['thing'], + queryFn: () => { + svc.load() + return 'data' + } + }) + } + + return null + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'svc' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + interface AllowedService { load: () => void } + interface OtherService { load: () => void } + + function useThing() { + const svc: AllowedService = { load: () => undefined } + + if (true) { + const svc: OtherService = { load: () => undefined } + + return useQuery({ + queryKey: ['thing', svc], + queryFn: () => { + svc.load() + return 'data' + } + }) + } + + return null + } + `, + }, + ], + }, + ], + }, + ], +}) + +ruleTester.run('exhaustive-deps allowlist.variables', rule, { + valid: [ + { + name: 'should ignore missing member path when root is in allowlist.variables', + options: [{ allowlist: { variables: ['svc'] } }], + code: normalizeIndent` + function useThing(svc, id) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + }, + { + name: 'should ignore allowlisted variable when member access spans multiple lines', + options: [{ allowlist: { variables: ['ignored'] } }], + code: normalizeIndent` + function useThing() { + const ignored = { run: () => Promise.resolve() } + return useQuery({ + queryKey: ['thing'], + queryFn: () => ignored + .run() + }) + } + `, + }, + ], + invalid: [ + { + name: 'should only report non-allowlisted roots', + options: [{ allowlist: { variables: ['svc'] } }], + code: normalizeIndent` + function useThing(svc, other) { + return useQuery({ + queryKey: ['thing'], + queryFn: () => { + svc.part.load() + other.x.run() + return 1 + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'other.x' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(svc, other) { + return useQuery({ + queryKey: ['thing', other.x], + queryFn: () => { + svc.part.load() + other.x.run() + return 1 + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when missing member path not in allowlist.variables', + code: normalizeIndent` + function useThing(svc, id) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'svc.part' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(svc, id) { + return useQuery({ + queryKey: ['thing', id, svc.part], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + }, + ], + }, + ], + }, ], }) diff --git a/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts b/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts index fbe65f67709..face6bc7bc6 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts @@ -1,7 +1,13 @@ +import path from 'node:path' import { RuleTester } from '@typescript-eslint/rule-tester' +import { afterAll, describe, it } from 'vitest' import { rule } from '../rules/no-rest-destructuring/no-rest-destructuring.rule' import { normalizeIndent } from './test-utils' +RuleTester.afterAll = afterAll +RuleTester.describe = describe +RuleTester.it = it + const ruleTester = new RuleTester() ruleTester.run('no-rest-destructuring', rule, { @@ -392,3 +398,109 @@ ruleTester.run('no-rest-destructuring', rule, { }, ], }) + +const ruleTesterTypeChecked = new RuleTester({ + languageOptions: { + parser: await import('@typescript-eslint/parser'), + parserOptions: { + project: true, + tsconfigRootDir: path.resolve(__dirname, './ts-fixture'), + }, + }, +}) + +ruleTesterTypeChecked.run('no-rest-destructuring with type information', rule, { + valid: [ + { + name: 'custom hook not returning a query result is destructured with rest', + code: normalizeIndent` + const useThing = () => ({ data: 1, isError: false }) + + function Component() { + const { data, ...rest } = useThing() + return null + } + `, + }, + { + name: 'custom hook returning a query result is destructured without rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => + useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) }) + + function Component() { + const { data, isLoading } = useTodos() + return null + } + `, + }, + ], + invalid: [ + { + name: 'custom hook returning useQuery is destructured with rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => + useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) }) + + function Component() { + const { data, ...rest } = useTodos() + return null + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'custom hook result is spread in object expression', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => + useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) }) + + function Component() { + const todosQuery = useTodos() + return { ...todosQuery } + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'custom hook result is assigned then destructured with rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => + useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) }) + + function Component() { + const todosQuery = useTodos() + const { data, ...rest } = todosQuery + return null + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'custom hook returning an interface query result is destructured with rest', + code: normalizeIndent` + import type { QueryObserverResult } from '@tanstack/react-query' + + const useTodos = (): QueryObserverResult => ({ + data: undefined, + isLoading: false, + isError: false, + }) + + function Component() { + const { data, ...rest } = useTodos() + return null + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + ], +}) diff --git a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts index b111fc0ed73..fca18c41cd2 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts @@ -63,6 +63,43 @@ const baseTestCases = { } `, }, + { + name: `should pass when useSuspenseQueries with combine is passed to ${reactHookAlias} as dependency`, + code: ` + ${reactHookImport} + import { useSuspenseQueries } from "@tanstack/react-query"; + + function Component() { + const queries = useSuspenseQueries({ + queries: [ + { queryKey: ['test'], queryFn: () => 'test' } + ], + combine: (results) => ({ data: results[0]?.data }) + }); + const callback = ${reactHookInvocation}(() => { queries.data }, [queries]); + return; + } + `, + }, + ]) + .concat([ + { + name: `should pass when useQueries is array-destructured and element properties are used with ${reactHookAlias}`, + code: ` + ${reactHookImport} + import { useQueries } from "@tanstack/react-query"; + + function Component() { + const [{ data }] = useQueries({ + queries: [ + { queryKey: ['test'], queryFn: () => 'test' } + ] + }); + const callback = ${reactHookInvocation}(() => { data }, [data]); + return; + } + `, + }, ]) .concat([ { @@ -163,6 +200,172 @@ const baseTestCases = { }, ], }, + { + name: `result of custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useMutation } from "@tanstack/react-query"; + + const useMyMutation = () => useMutation({ mutationFn: (value: string) => value }); + + function Component() { + const mutation = useMyMutation(); + const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useMutation' }, + }, + ], + }, + { + name: `result of custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useQuery } from "@tanstack/react-query"; + + function useMyQuery() { + return useQuery({ queryFn: (value: string) => value }); + } + + function Component() { + const query = useMyQuery(); + const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useQuery' }, + }, + ], + }, + { + name: `result of later custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useMutation } from "@tanstack/react-query"; + + function Component() { + const mutation = useMyMutation(); + const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]); + return; + } + + function useMyMutation() { + return useMutation({ mutationFn: (value: string) => value }); + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useMutation' }, + }, + ], + }, + { + name: `result of later custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useQuery } from "@tanstack/react-query"; + + function Component() { + const query = useMyQuery(); + const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]); + return; + } + + function useMyQuery() { + return useQuery({ queryFn: (value: string) => value }); + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useQuery' }, + }, + ], + }, + ]) + .concat([ + { + name: `array-destructured useQueries element is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useQueries } from "@tanstack/react-query"; + + function Component() { + const [userQuery, postsQuery] = useQueries({ + queries: [ + { queryKey: ['user'], queryFn: () => 'user' }, + { queryKey: ['posts'], queryFn: () => 'posts' } + ] + }); + const callback = ${reactHookInvocation}(() => { userQuery.data }, [userQuery]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useQueries' }, + }, + ], + }, + { + name: `array-destructured useSuspenseQueries element is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useSuspenseQueries } from "@tanstack/react-query"; + + function Component() { + const [query] = useSuspenseQueries({ + queries: [ + { queryKey: ['test'], queryFn: () => 'test' } + ] + }); + const callback = ${reactHookInvocation}(() => { query.data }, [query]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { + reactHook: reactHookAlias, + queryHook: 'useSuspenseQueries', + }, + }, + ], + }, + { + name: `rest element of array-destructured useQueries is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useQueries } from "@tanstack/react-query"; + + function Component() { + const [firstQuery, ...restQueries] = useQueries({ + queries: [ + { queryKey: ['a'], queryFn: () => 'a' }, + { queryKey: ['b'], queryFn: () => 'b' } + ] + }); + const callback = ${reactHookInvocation}(() => {}, [restQueries]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useQueries' }, + }, + ], + }, ]), } diff --git a/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts b/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts index c136a2582c0..700a4f95e68 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts @@ -162,6 +162,189 @@ ruleTester.run('no-void-query-fn', rule, { } `, }, + { + name: 'useInfiniteQuery queryFn returns a value', + code: normalizeIndent` + import { useInfiniteQuery } from '@tanstack/react-query' + + function Component() { + const query = useInfiniteQuery({ + queryKey: ['test'], + queryFn: ({ pageParam }) => ({ data: 'test', page: pageParam }), + initialPageParam: 0, + getNextPageParam: (lastPage) => undefined, + }) + return null + } + `, + }, + { + name: 'useSuspenseQuery queryFn returns a value', + code: normalizeIndent` + import { useSuspenseQuery } from '@tanstack/react-query' + + function Component() { + const query = useSuspenseQuery({ + queryKey: ['test'], + queryFn: () => ({ data: 'test' }), + }) + return null + } + `, + }, + { + name: 'queryOptions queryFn returns a value', + code: normalizeIndent` + import { queryOptions } from '@tanstack/react-query' + + const options = queryOptions({ + queryKey: ['test'], + queryFn: () => ({ data: 'test' }), + }) + `, + }, + { + name: 'fetchQuery queryFn returns a value', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.fetchQuery({ + queryKey: ['test'], + queryFn: () => fetch('/api/test').then((r) => r.json()), + }) + `, + }, + { + name: 'prefetchQuery queryFn returns a value', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.prefetchQuery({ + queryKey: ['test'], + queryFn: () => fetch('/api/test').then((r) => r.json()), + }) + `, + }, + { + name: 'prefetchInfiniteQuery queryFn returns a value', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.prefetchInfiniteQuery({ + queryKey: ['test'], + queryFn: ({ pageParam }: { pageParam: number }) => + fetch(\`/api/test?page=\${pageParam}\`).then((r) => r.json()), + initialPageParam: 0, + }) + `, + }, + { + name: 'ensureQueryData queryFn returns a value', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.ensureQueryData({ + queryKey: ['test'], + queryFn: () => fetch('/api/test').then((r) => r.json()), + }) + `, + }, + { + name: 'ensureInfiniteQueryData queryFn returns a value', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.ensureInfiniteQueryData({ + queryKey: ['test'], + queryFn: ({ pageParam }: { pageParam: number }) => + fetch(\`/api/test?page=\${pageParam}\`).then((r) => r.json()), + initialPageParam: 0, + }) + `, + }, + { + name: 'queryFn returns a numeric enum member', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + enum ExampleEnum { + A, + B, + } + + function Component() { + const query = useQuery({ + queryKey: ['test'], + queryFn: () => ExampleEnum.A, + }) + return null + } + `, + }, + { + name: 'queryFn returns a string enum member', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + enum StringEnum { + Foo = 'foo', + Bar = 'bar', + } + + function Component() { + const query = useQuery({ + queryKey: ['test'], + queryFn: () => StringEnum.Foo, + }) + return null + } + `, + }, + { + name: 'async queryFn returns a numeric enum member', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + enum Status { + Active, + Inactive, + } + + function Component() { + const query = useQuery({ + queryKey: ['test'], + queryFn: async () => { + return Status.Active + }, + }) + return null + } + `, + }, + { + name: 'queryFn returns a const enum member', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const enum Direction { + Up = 'UP', + Down = 'DOWN', + } + + function Component() { + const query = useQuery({ + queryKey: ['test'], + queryFn: () => Direction.Up, + }) + return null + } + `, + }, ], invalid: [ { @@ -321,5 +504,132 @@ ruleTester.run('no-void-query-fn', rule, { `, errors: [{ messageId: 'noVoidReturn' }], }, + { + name: 'useInfiniteQuery queryFn returns void', + code: normalizeIndent` + import { useInfiniteQuery } from '@tanstack/react-query' + + function Component() { + const query = useInfiniteQuery({ + queryKey: ['test'], + queryFn: async ({ pageParam }) => { + await fetch('/api/test?page=' + pageParam) + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => undefined, + }) + return null + } + `, + errors: [{ messageId: 'noVoidReturn' }], + }, + { + name: 'useSuspenseQuery queryFn returns void', + code: normalizeIndent` + import { useSuspenseQuery } from '@tanstack/react-query' + + function Component() { + const query = useSuspenseQuery({ + queryKey: ['test'], + queryFn: () => { + console.log('fetching') + }, + }) + return null + } + `, + errors: [{ messageId: 'noVoidReturn' }], + }, + { + name: 'queryOptions queryFn returns void', + code: normalizeIndent` + import { queryOptions } from '@tanstack/react-query' + + const options = queryOptions({ + queryKey: ['test'], + queryFn: async () => { + await fetch('/api/test') + }, + }) + `, + errors: [{ messageId: 'noVoidReturn' }], + }, + { + name: 'fetchQuery queryFn returns void', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.fetchQuery({ + queryKey: ['test'], + queryFn: async () => { + await fetch('/api/test') + }, + }) + `, + errors: [{ messageId: 'noVoidReturn' }], + }, + { + name: 'prefetchQuery queryFn returns void', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.prefetchQuery({ + queryKey: ['test'], + queryFn: async () => { + await fetch('/api/test') + }, + }) + `, + errors: [{ messageId: 'noVoidReturn' }], + }, + { + name: 'prefetchInfiniteQuery queryFn returns void', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.prefetchInfiniteQuery({ + queryKey: ['test'], + queryFn: async ({ pageParam }: { pageParam: number }) => { + await fetch(\`/api/test?page=\${pageParam}\`) + }, + initialPageParam: 0, + }) + `, + errors: [{ messageId: 'noVoidReturn' }], + }, + { + name: 'ensureQueryData queryFn returns void', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.ensureQueryData({ + queryKey: ['test'], + queryFn: async () => { + await fetch('/api/test') + }, + }) + `, + errors: [{ messageId: 'noVoidReturn' }], + }, + { + name: 'ensureInfiniteQueryData queryFn returns void', + code: normalizeIndent` + import { QueryClient } from '@tanstack/react-query' + + const queryClient = new QueryClient() + queryClient.ensureInfiniteQueryData({ + queryKey: ['test'], + queryFn: async ({ pageParam }: { pageParam: number }) => { + await fetch(\`/api/test?page=\${pageParam}\`) + }, + initialPageParam: 0, + }) + `, + errors: [{ messageId: 'noVoidReturn' }], + }, ], }) diff --git a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts new file mode 100644 index 00000000000..ff5962623ba --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -0,0 +1,1019 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { afterAll, describe, it } from 'vitest' +import { rule } from '../rules/prefer-query-options/prefer-query-options.rule' +import { normalizeIndent } from './test-utils' + +RuleTester.afterAll = afterAll +RuleTester.describe = describe +RuleTester.it = it + +const ruleTester = new RuleTester() + +describe('prefer-query-options', () => { + describe('queryOptions / infiniteQueryOptions builders', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'queryOptions builder is allowed', + code: normalizeIndent` + import { queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + `, + }, + { + name: 'infiniteQueryOptions builder is allowed', + code: normalizeIndent` + import { infiniteQueryOptions } from '@tanstack/react-query' + + const todosOptions = infiniteQueryOptions({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + `, + }, + ], + invalid: [], + }) + }) + + describe('hooks consuming queryOptions result', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'useQuery with queryOptions result is allowed', + code: normalizeIndent` + import { useQuery, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const query = useQuery(todosOptions) + return null + } + `, + }, + { + name: 'useQuery with queryOptions function call result is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component({ id }) { + const query = useQuery(todosOptions(id)) + return null + } + `, + }, + { + name: 'useQuery with imported queryOptions function call is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + import { getFooOptions } from './foo' + + function Component({ id }) { + const query = useQuery(getFooOptions(id)) + return null + } + `, + }, + { + name: 'useQuery spreading queryOptions result is allowed', + code: normalizeIndent` + import { useQuery, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const query = useQuery({ ...todosOptions, select: (data) => data.items }) + return null + } + `, + }, + { + name: 'useQuery spreading queryOptions function call result is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component({ id }) { + const query = useQuery({ ...todosOptions(id), select: (data) => data.items }) + return null + } + `, + }, + { + name: 'useQueries with all entries from queryOptions is allowed', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [todosOptions, usersOptions], + }) + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('queryClient methods referencing queryKey from options', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'queryClient.getQueryData with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(todosOptions.queryKey) + return null + } + `, + }, + { + name: 'queryClient.setQueryData with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryData(todosOptions.queryKey, []) + return null + } + `, + }, + { + name: 'queryClient.invalidateQueries with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) + return null + } + `, + }, + { + name: 'queryClient.invalidateQueries with options.queryKey and extra filter props is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey, exact: true }) + return null + } + `, + }, + { + name: 'queryClient.getQueryData with variable queryKey is allowed', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component({ queryKey }) { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(queryKey) + return null + } + `, + }, + { + name: 'shadowed queryClient parameter is ignored', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + + function run(queryClient) { + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + } + + return null + } + `, + }, + { + name: 'non-queryClient fetchQuery call is ignored', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const analytics = { + fetchQuery(options) { + return options + }, + } + + function Component() { + useQuery(todosOptions) + + analytics.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('non-tanstack imports', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'non-tanstack useQuery is ignored', + code: normalizeIndent` + import { useQuery } from 'other-library' + + function Component() { + const query = useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('inline lone queryKey or queryFn in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery with only inline queryKey (no queryFn)', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQuery with only inline queryFn (no queryKey)', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ queryFn: () => fetchTodos() }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('spread with inline queryKey or queryFn override in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery spreading options but overriding queryKey inline', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ ...options, queryKey: ['override'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQuery spreading options but overriding queryFn inline', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ ...options, queryFn: () => fetchOverride() }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('inline queryKey + queryFn in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'aliased useQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQuery as useTanstackQuery } from '@tanstack/react-query' + + function Component() { + const query = useTanstackQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useInfiniteQuery } from '@tanstack/react-query' + + function Component() { + const query = useInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useSuspenseQuery } from '@tanstack/react-query' + + function Component() { + const query = useSuspenseQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useSuspenseInfiniteQuery } from '@tanstack/react-query' + + function Component() { + const query = useSuspenseInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQueries with inline queryKey + queryFn in queries array', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + ], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQueries with multiple inline entries reports multiple errors', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + { + queryKey: ['users'], + queryFn: () => fetchUsers(), + }, + ], + }) + return null + } + `, + errors: [ + { messageId: 'preferQueryOptions' }, + { messageId: 'preferQueryOptions' }, + ], + }, + { + name: 'useQueries with mapped inline query objects', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component({ ids }) { + const queries = useQueries({ + queries: ids.map((id) => ({ + queryKey: ['todos', id], + queryFn: () => fetchTodo(id), + })), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQueries with inline queryKey + queryFn in queries array', + code: normalizeIndent` + import { useSuspenseQueries } from '@tanstack/react-query' + + function Component() { + const queries = useSuspenseQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + ], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQueries with mapped inline query objects', + code: normalizeIndent` + import { useSuspenseQueries } from '@tanstack/react-query' + + function Component({ ids }) { + const queries = useSuspenseQueries({ + queries: ids.map((id) => ({ + queryKey: ['todos', id], + queryFn: () => fetchTodo(id), + })), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'usePrefetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { usePrefetchQuery } from '@tanstack/react-query' + + function Component() { + usePrefetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'usePrefetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { usePrefetchInfiniteQuery } from '@tanstack/react-query' + + function Component() { + usePrefetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'inline queryKey + queryFn inside a custom hook', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function useTodos() { + return useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('queryClient with alternate variable names', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'client.fetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const client = useQueryClient() + client.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'aliased useQueryClient tracks query client variables', + code: normalizeIndent` + import { useQueryClient as getClient } from '@tanstack/react-query' + + function Component() { + const client = getClient() + client.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'aliased QueryClient tracks query client instances', + code: normalizeIndent` + import { QueryClient as Client } from '@tanstack/react-query' + + const queryClient = new Client() + + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'qc.getQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const qc = useQueryClient() + const data = qc.getQueryData(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'client.invalidateQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const client = useQueryClient() + client.invalidateQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) + + describe('inline queryKey + queryFn in queryClient methods', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.fetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.prefetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.fetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.fetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.prefetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.prefetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.ensureQueryData with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.ensureQueryData({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.ensureInfiniteQueryData with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.ensureInfiniteQueryData({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('inline queryKey as direct parameter', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.getQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryData with inline queryKey as const', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(['todos'] as const) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryData with inline queryKey satisfies', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData((['todos']) satisfies readonly string[]) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryData(['todos'], []) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryState with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const state = queryClient.getQueryState(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueryDefaults with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryDefaults(['todos'], { staleTime: 1000 }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryDefaults with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const defaults = queryClient.getQueryDefaults(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) + + describe('inline queryKey in filter objects', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.invalidateQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.invalidateQueries with inline queryKey as const in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: ['todos'] as const }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.invalidateQueries with inline queryKey satisfies in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ + queryKey: (['todos']) satisfies readonly string[], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.cancelQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.cancelQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.refetchQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.refetchQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.removeQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.removeQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.resetQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.resetQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.isFetching with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const count = queryClient.isFetching({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueriesData with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueriesData({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueriesData with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueriesData({ queryKey: ['todos'] }, []) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'useIsFetching with inline queryKey in filters', + code: normalizeIndent` + import { useIsFetching } from '@tanstack/react-query' + + function Component() { + const count = useIsFetching({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) +}) diff --git a/packages/eslint-plugin-query/src/__tests__/sort-data-by-order.utils.test.ts b/packages/eslint-plugin-query/src/__tests__/sort-data-by-order.utils.test.ts index 577252b7c64..896d4524ce3 100644 --- a/packages/eslint-plugin-query/src/__tests__/sort-data-by-order.utils.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/sort-data-by-order.utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, it } from 'vitest' import { sortDataByOrder } from '../utils/sort-data-by-order' describe('create-route-property-order utils', () => { @@ -68,7 +68,7 @@ describe('create-route-property-order utils', () => { expected: [{ key: 'd' }, { key: 'a' }, { key: 'b' }, { key: 'c' }], }, ] as const - test.each(testCases)( + it.each(testCases)( '$data $orderArray $key $expected', ({ data, orderArray, key, expected }) => { const sortedData = sortDataByOrder(data, orderArray, key) diff --git a/packages/eslint-plugin-query/src/__tests__/stable-query-client.test.ts b/packages/eslint-plugin-query/src/__tests__/stable-query-client.test.ts index 0ceda63523d..fa896ff5790 100644 --- a/packages/eslint-plugin-query/src/__tests__/stable-query-client.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/stable-query-client.test.ts @@ -96,7 +96,7 @@ ruleTester.run('stable-query-client', rule, { `, }, { - name: 'QueryClient is invoked in an async (react server) component', + name: 'QueryClient is not flagged when imported from a non-react-query package in an async component', code: normalizeIndent` import { QueryClient } from "@tanstack/solid-query"; @@ -106,6 +106,17 @@ ruleTester.run('stable-query-client', rule, { } `, }, + { + name: 'QueryClient is not flagged in an async react-query server component', + code: normalizeIndent` + import { QueryClient } from "@tanstack/react-query"; + + async function ServerComponent() { + const queryClient = new QueryClient(); + return; + } + `, + }, ], invalid: [ { @@ -188,5 +199,18 @@ ruleTester.run('stable-query-client', rule, { `, errors: [{ messageId: 'unstable' }], }, + { + name: 'QueryClient with destructuring pattern reports error without autofix', + code: normalizeIndent` + import { QueryClient } from "@tanstack/react-query"; + + function Component() { + const { defaultOptions } = new QueryClient(); + return; + } + `, + output: null, + errors: [{ messageId: 'unstable' }], + }, ], }) diff --git a/packages/eslint-plugin-query/src/__tests__/test-utils.test.ts b/packages/eslint-plugin-query/src/__tests__/test-utils.test.ts index 5997c342a7b..ea0e65b1aee 100644 --- a/packages/eslint-plugin-query/src/__tests__/test-utils.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/test-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, it } from 'vitest' import { expectArrayEqualIgnoreOrder, generateInterleavedCombinations, @@ -32,7 +32,7 @@ describe('test-utils', () => { expected: [['a']], }, ] - test.each(testCases)('$input $expected', ({ input, expected }) => { + it.each(testCases)('$input $expected', ({ input, expected }) => { const permutations = generatePermutations(input) expect(permutations).toEqual(expected) }) @@ -71,7 +71,7 @@ describe('test-utils', () => { minLength: 0, }, ] - test.each(testCases)( + it.each(testCases)( '$input $minLength $expected', ({ input, minLength, expected }) => { const combinations = generatePartialCombinations(input, minLength) @@ -93,12 +93,9 @@ describe('test-utils', () => { ], }, ] - test.each(testCases)( - '$input $expected', - ({ data, additional, expected }) => { - const combinations = generateInterleavedCombinations(data, additional) - expectArrayEqualIgnoreOrder(combinations, expected) - }, - ) + it.each(testCases)('$input $expected', ({ data, additional, expected }) => { + const combinations = generateInterleavedCombinations(data, additional) + expectArrayEqualIgnoreOrder(combinations, expected) + }) }) }) diff --git a/packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts b/packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts new file mode 100644 index 00000000000..07fe05a76f6 --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts @@ -0,0 +1,20 @@ +// Ambient stub so type-checked tests can resolve `@tanstack/react-query` +// without adding it as a devDependency of this plugin. +declare module '@tanstack/react-query' { + export type UseQueryResult = { + data: TData | undefined + isLoading: boolean + isError: boolean + } + // Declared as an interface so its type resolves via `getSymbol()` rather + // than `aliasSymbol`, exercising the non-alias detection path. + export interface QueryObserverResult { + data: TData | undefined + isLoading: boolean + isError: boolean + } + export function useQuery(options: { + queryKey: ReadonlyArray + queryFn: () => Promise + }): UseQueryResult +} diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 452bc0f5a42..085ba7b9f3d 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -8,10 +8,27 @@ export interface Plugin extends Omit { rules: Record> configs: { recommended: ESLint.ConfigData + recommendedStrict: ESLint.ConfigData 'flat/recommended': Array + 'flat/recommended-strict': Array } } +const recommendedRules = { + '@tanstack/query/exhaustive-deps': 'error', + '@tanstack/query/no-rest-destructuring': 'warn', + '@tanstack/query/stable-query-client': 'error', + '@tanstack/query/no-unstable-deps': 'error', + '@tanstack/query/infinite-query-property-order': 'error', + '@tanstack/query/no-void-query-fn': 'error', + '@tanstack/query/mutation-property-order': 'error', +} as const + +const recommendedStrictRules = { + ...recommendedRules, + '@tanstack/query/prefer-query-options': 'error', +} as const + export const plugin = { meta: { name: '@tanstack/eslint-plugin-query', @@ -19,15 +36,11 @@ export const plugin = { configs: { recommended: { plugins: ['@tanstack/query'], - rules: { - '@tanstack/query/exhaustive-deps': 'error', - '@tanstack/query/no-rest-destructuring': 'warn', - '@tanstack/query/stable-query-client': 'error', - '@tanstack/query/no-unstable-deps': 'error', - '@tanstack/query/infinite-query-property-order': 'error', - '@tanstack/query/no-void-query-fn': 'error', - '@tanstack/query/mutation-property-order': 'error', - }, + rules: recommendedRules, + }, + recommendedStrict: { + plugins: ['@tanstack/query'], + rules: recommendedStrictRules, }, 'flat/recommended': [ { @@ -35,15 +48,16 @@ export const plugin = { plugins: { '@tanstack/query': {}, // Assigned after plugin object created }, - rules: { - '@tanstack/query/exhaustive-deps': 'error', - '@tanstack/query/no-rest-destructuring': 'warn', - '@tanstack/query/stable-query-client': 'error', - '@tanstack/query/no-unstable-deps': 'error', - '@tanstack/query/infinite-query-property-order': 'error', - '@tanstack/query/no-void-query-fn': 'error', - '@tanstack/query/mutation-property-order': 'error', + rules: recommendedRules, + }, + ], + 'flat/recommended-strict': [ + { + name: 'tanstack/query/flat/recommended-strict', + plugins: { + '@tanstack/query': {}, // Assigned after plugin object created }, + rules: recommendedStrictRules, }, ], }, @@ -51,5 +65,7 @@ export const plugin = { } satisfies Plugin plugin.configs['flat/recommended'][0]!.plugins['@tanstack/query'] = plugin +plugin.configs['flat/recommended-strict'][0]!.plugins['@tanstack/query'] = + plugin export default plugin diff --git a/packages/eslint-plugin-query/src/rules.ts b/packages/eslint-plugin-query/src/rules.ts index d527768ec1d..cbae59eb9c9 100644 --- a/packages/eslint-plugin-query/src/rules.ts +++ b/packages/eslint-plugin-query/src/rules.ts @@ -5,6 +5,7 @@ import * as noUnstableDeps from './rules/no-unstable-deps/no-unstable-deps.rule' import * as infiniteQueryPropertyOrder from './rules/infinite-query-property-order/infinite-query-property-order.rule' import * as noVoidQueryFn from './rules/no-void-query-fn/no-void-query-fn.rule' import * as mutationPropertyOrder from './rules/mutation-property-order/mutation-property-order.rule' +import * as preferQueryOptions from './rules/prefer-query-options/prefer-query-options.rule' import type { ESLintUtils } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from './types' @@ -24,4 +25,5 @@ export const rules: Record< [infiniteQueryPropertyOrder.name]: infiniteQueryPropertyOrder.rule, [noVoidQueryFn.name]: noVoidQueryFn.rule, [mutationPropertyOrder.name]: mutationPropertyOrder.rule, + [preferQueryOptions.name]: preferQueryOptions.rule, } diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts index 2f20877c67e..a3e7e849254 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts @@ -1,7 +1,6 @@ import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' import { ASTUtils } from '../../utils/ast-utils' import { getDocsUrl } from '../../utils/get-docs-url' -import { uniqueBy } from '../../utils/unique-by' import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports' import { ExhaustiveDepsUtils } from './exhaustive-deps.utils' import type { TSESLint, TSESTree } from '@typescript-eslint/utils' @@ -14,6 +13,13 @@ export const name = 'exhaustive-deps' const createRule = ESLintUtils.RuleCreator(getDocsUrl) +type RuleOption = { + allowlist?: { + variables?: Array + types?: Array + } +} + export const rule = createRule({ name, meta: { @@ -28,27 +34,36 @@ export const rule = createRule({ }, hasSuggestions: true, fixable: 'code', - schema: [], + schema: [ + { + type: 'object', + properties: { + allowlist: { + type: 'object', + properties: { + variables: { type: 'array', items: { type: 'string' } }, + types: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + ], }, defaultOptions: [], create: detectTanstackQueryImports((context) => { return { - Property: (node) => { - if ( - !ASTUtils.isObjectExpression(node.parent) || - !ASTUtils.isIdentifierWithName(node.key, QUERY_KEY) - ) { - return - } - + ObjectExpression: (node: TSESTree.ObjectExpression) => { const scopeManager = context.sourceCode.scopeManager + const queryKey = ASTUtils.findPropertyWithIdentifierKey( - node.parent.properties, + node.properties, QUERY_KEY, ) const queryFn = ASTUtils.findPropertyWithIdentifierKey( - node.parent.properties, + node.properties, QUERY_FN, ) @@ -70,103 +85,134 @@ export const rule = createRule({ context, ) - const externalRefs = ASTUtils.getExternalRefs({ - scopeManager, - sourceCode: context.sourceCode, - node: getQueryFnRelevantNode(queryFn), - }) + const queryFnNodes = ExhaustiveDepsUtils.getQueryFnNodes(queryFn) - const relevantRefs = externalRefs.filter((reference) => - ExhaustiveDepsUtils.isRelevantReference({ - sourceCode: context.sourceCode, - reference, + const externalRefs = queryFnNodes.flatMap((fnNode) => + ASTUtils.getExternalRefs({ scopeManager, - node: getQueryFnRelevantNode(queryFn), - filename: context.filename, + sourceCode: context.sourceCode, + node: fnNode, }), ) + const relevantRefs = externalRefs.filter((reference) => + queryFnNodes.some((fnNode) => + ExhaustiveDepsUtils.isRelevantReference({ + sourceCode: context.sourceCode, + reference, + scopeManager, + node: fnNode, + filename: context.filename, + }), + ), + ) + + const ruleOptions = context.options.at(0) as RuleOption | undefined + const allowlistedVariables = new Set( + ruleOptions?.allowlist?.variables ?? [], + ) + const allowlistedTypes = new Set(ruleOptions?.allowlist?.types ?? []) + + const requiredRefs = relevantRefs.flatMap((ref) => { + if (ref.identifier.type !== AST_NODE_TYPES.Identifier) return [] + + const refPath = ExhaustiveDepsUtils.computeRefPath({ + identifier: ref.identifier, + sourceCode: context.sourceCode, + }) + + if (refPath === null) return [] + + return [ + { + ...refPath, + allowlistedByType: + ExhaustiveDepsUtils.variableIsAllowlistedByType({ + allowlistedTypes, + variable: ref.resolved ?? null, + }), + }, + ] + }) + + if (requiredRefs.length === 0) return + const queryKeyDeps = ExhaustiveDepsUtils.collectQueryKeyDeps({ sourceCode: context.sourceCode, - scopeManager, + scopeManager: scopeManager, + queryKeyNode: queryKeyNode, + }) + + const missingPaths = ExhaustiveDepsUtils.computeFilteredMissingPaths({ + requiredRefs: requiredRefs, + allowlistedVariables: allowlistedVariables, + existingRootIdentifiers: queryKeyDeps.roots, + existingFullPaths: queryKeyDeps.paths, + }) + + if (missingPaths.length === 0) return + + const missingAsText = missingPaths.join(', ') + const suggestions = buildSuggestions({ queryKeyNode, + missingPaths, + missingAsText, + sourceCode: context.sourceCode, }) - const missingRefs = relevantRefs - .map((ref) => ({ - ref: ref, - text: ASTUtils.isAncestorIsCallee(ref.identifier) - ? ref.identifier.name - : ASTUtils.mapKeyNodeToBaseText( - ref.identifier, - context.sourceCode, - ), - })) - .filter(({ ref, text }) => { - return ( - !ref.isTypeReference && - !queryKeyDeps.has(text) && - !queryKeyDeps.has(text.split(/[?.]/)[0] ?? '') - ) - }) - .map(({ ref, text }) => ({ - identifier: ref.identifier, - text: text, - })) - - const uniqueMissingRefs = uniqueBy(missingRefs, (x) => x.text) - - if (uniqueMissingRefs.length > 0) { - const missingAsText = uniqueMissingRefs - .map((ref) => ref.text) - .join(', ') - - const queryKeyValue = context.sourceCode.getText(queryKeyNode) - - const existingWithMissing = - queryKeyValue === '[]' - ? `[${missingAsText}]` - : queryKeyValue.replace(/\]$/, `, ${missingAsText}]`) - - const suggestions: TSESLint.ReportSuggestionArray = [] - - if (queryKeyNode.type === AST_NODE_TYPES.ArrayExpression) { - suggestions.push({ - messageId: 'fixTo', - data: { result: existingWithMissing }, - fix(fixer) { - return fixer.replaceText(queryKeyNode, existingWithMissing) - }, - }) - } - - context.report({ - node: node, - messageId: 'missingDeps', - data: { - deps: uniqueMissingRefs.map((ref) => ref.text).join(', '), - }, - suggest: suggestions, - }) - } + context.report({ + node, + messageId: 'missingDeps', + data: { deps: missingAsText }, + suggest: suggestions, + }) }, } }), }) -function getQueryFnRelevantNode(queryFn: TSESTree.Property) { - if (queryFn.value.type !== AST_NODE_TYPES.ConditionalExpression) { - return queryFn.value +function buildSuggestions(params: { + queryKeyNode: TSESTree.Node + missingPaths: Array + missingAsText: string + sourceCode: Readonly +}): TSESLint.ReportSuggestionArray { + const { queryKeyNode, missingPaths, missingAsText, sourceCode } = params + + if (queryKeyNode.type !== AST_NODE_TYPES.ArrayExpression) { + return [] } - if ( - queryFn.value.consequent.type === AST_NODE_TYPES.Identifier && - queryFn.value.consequent.name === 'skipToken' - ) { - return queryFn.value.alternate + const closingBracket = sourceCode.getLastToken(queryKeyNode) + if (!closingBracket) return [] + + const existingElements = queryKeyNode.elements + .filter((el): el is NonNullable => el !== null) + .map((el) => sourceCode.getText(el)) + + const resultText = `[${[...existingElements, ...missingPaths].join(', ')}]` + + if (queryKeyNode.elements.length === 0) { + return [ + { + messageId: 'fixTo', + data: { result: resultText }, + fix: (fixer) => fixer.replaceText(queryKeyNode, resultText), + }, + ] } - return queryFn.value.consequent + const tokenBefore = sourceCode.getTokenBefore(closingBracket) + const separator = tokenBefore?.value === ',' ? ' ' : ', ' + + return [ + { + messageId: 'fixTo', + data: { result: resultText }, + fix: (fixer) => + fixer.insertTextBefore(closingBracket, `${separator}${missingAsText}`), + }, + ] } function dereferenceVariablesAndTypeAssertions( diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts index 32167e92199..135a5b620d1 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts @@ -14,7 +14,7 @@ export const ExhaustiveDepsUtils = { const component = ASTUtils.getFunctionAncestor(sourceCode, node) const queryFnScope = scopeManager.acquire(node) - if (queryFnScope === null) { + if (queryFnScope === null || reference.isValueReference === false) { return false } @@ -59,24 +59,88 @@ export const ExhaustiveDepsUtils = { !ExhaustiveDepsUtils.isInstanceOfKind(reference.identifier.parent) ) }, - isInstanceOfKind(node: TSESTree.Node) { - return ( - node.type === AST_NODE_TYPES.BinaryExpression && - node.operator === 'instanceof' - ) + + /** + * Given required refs and existing queryKey entries, compute missing dependency paths + * respecting allowlisted variables and types. + */ + computeFilteredMissingPaths(params: { + requiredRefs: Array<{ + path: string + root: string + allowlistedByType: boolean + }> + allowlistedVariables: Set + existingRootIdentifiers: Set + existingFullPaths: Set + }): Array { + const { + requiredRefs, + allowlistedVariables, + existingRootIdentifiers, + existingFullPaths, + } = params + + const missingPaths = new Set() + + for (const { root, path, allowlistedByType } of requiredRefs) { + // If root itself is present in the key, it covers all members + if (existingRootIdentifiers.has(root)) continue + if (allowlistedVariables.has(root)) continue + if (existingFullPaths.has(path)) continue + if (allowlistedByType) continue + + missingPaths.add(path) + } + + // Collapse descendants: if a root is already missing, drop deeper paths + for (const path of missingPaths) { + const root = path.split('.')[0] + if (root !== path && root !== undefined && missingPaths.has(root)) { + missingPaths.delete(path) + } + } + + return Array.from(missingPaths) }, + /** + * Extract existing queryKey deps as root identifiers and full member paths. + */ collectQueryKeyDeps(params: { sourceCode: Readonly scopeManager: TSESLint.Scope.ScopeManager queryKeyNode: TSESTree.Node - }): Set { + }): { roots: Set; paths: Set } { const { sourceCode, scopeManager, queryKeyNode } = params - const deps = new Set() + const roots = new Set() + const paths = new Set() const visitorKeys = sourceCode.visitorKeys - function add(identifier: TSESTree.Identifier) { - deps.add(ASTUtils.mapKeyNodeToBaseText(identifier, sourceCode)) + function addRoot(name: string) { + const cleaned = ExhaustiveDepsUtils.normalizeChain(name) + roots.add(cleaned) + paths.add(cleaned) + } + function addFull(text: string) { + const cleaned = ExhaustiveDepsUtils.normalizeChain(text) + paths.add(cleaned) + } + function addRefPath( + refPath: { + path: string + root: string + coversRootMembers: boolean + } | null, + ) { + if (!refPath) return + + if (refPath.coversRootMembers) { + addRoot(refPath.root) + return + } + + addFull(refPath.path) } function visitChildren(node: TSESTree.Node): void { @@ -106,9 +170,15 @@ export const ExhaustiveDepsUtils = { if (!node) return switch (node.type) { - case AST_NODE_TYPES.Identifier: - add(node) + case AST_NODE_TYPES.Identifier: { + addRefPath( + ExhaustiveDepsUtils.computeRefPath({ + identifier: node, + sourceCode: sourceCode, + }), + ) return + } case AST_NODE_TYPES.ArrowFunctionExpression: case AST_NODE_TYPES.FunctionExpression: for (const reference of ExhaustiveDepsUtils.collectExternalRefsInFunction( @@ -117,9 +187,16 @@ export const ExhaustiveDepsUtils = { scopeManager: scopeManager, }, )) { - if (reference.identifier.type === AST_NODE_TYPES.Identifier) { - add(reference.identifier) + if (reference.identifier.type !== AST_NODE_TYPES.Identifier) { + continue } + + addRefPath( + ExhaustiveDepsUtils.computeRefPath({ + identifier: reference.identifier, + sourceCode: sourceCode, + }), + ) } return case AST_NODE_TYPES.Property: @@ -131,19 +208,20 @@ export const ExhaustiveDepsUtils = { node.parent.callee === node && node.object.type === AST_NODE_TYPES.Identifier ) { - deps.add(node.object.name) + addRoot(node.object.name) } else { visit(node.object) } return case AST_NODE_TYPES.CallExpression: node.arguments.forEach((argument) => visit(argument)) - if ( - node.callee.type === AST_NODE_TYPES.MemberExpression || - node.callee.type === AST_NODE_TYPES.ChainExpression || - node.callee.type === AST_NODE_TYPES.TSNonNullExpression - ) { - visit(node.callee) + switch (node.callee.type) { + case AST_NODE_TYPES.Identifier: + case AST_NODE_TYPES.MemberExpression: + case AST_NODE_TYPES.ChainExpression: + case AST_NODE_TYPES.TSNonNullExpression: + visit(node.callee) + break } return } @@ -153,7 +231,7 @@ export const ExhaustiveDepsUtils = { visit(queryKeyNode) - return deps + return { roots, paths } }, isNode(value: unknown): value is TSESTree.Node { @@ -165,6 +243,97 @@ export const ExhaustiveDepsUtils = { ) }, + /** + * Checks whether the resolved variable is allowlisted by its type annotation + */ + variableIsAllowlistedByType(params: { + allowlistedTypes: Set + variable: TSESLint.Scope.Variable | null + }): boolean { + const { allowlistedTypes, variable } = params + if (allowlistedTypes.size === 0) return false + if (!variable) return false + + for (const id of variable.identifiers) { + if (id.typeAnnotation) { + const typeIdentifiers = new Set() + ExhaustiveDepsUtils.collectTypeIdentifiers( + id.typeAnnotation.typeAnnotation, + typeIdentifiers, + ) + for (const typeIdentifier of typeIdentifiers) { + if (allowlistedTypes.has(typeIdentifier)) return true + } + } + } + + return false + }, + isInstanceOfKind(node: TSESTree.Node) { + return ( + node.type === AST_NODE_TYPES.BinaryExpression && + node.operator === 'instanceof' + ) + }, + + /** + * Normalizes a chain by removing optional chaining operators + * + * Example: `a?.b.c!` -> `a.b.c` + */ + normalizeChain(text: string): string { + return text.replace(/(?:\?(\.)|!)/g, '$1').replace(/\s+/g, '') + }, + + /** + * Computes the reference path for an identifier + * + * Example: `a.b.c!` -> `{ path: 'a.b.c', root: 'a' }` + */ + computeRefPath(params: { + identifier: TSESTree.Identifier + sourceCode: Readonly + }): { path: string; root: string; coversRootMembers: boolean } | null { + const { identifier, sourceCode } = params + + const fullChainNode = ASTUtils.traverseUpOnly(identifier, [ + AST_NODE_TYPES.MemberExpression, + AST_NODE_TYPES.TSNonNullExpression, + AST_NODE_TYPES.Identifier, + ]) + + const fullText = ExhaustiveDepsUtils.normalizeChain( + sourceCode.getText(fullChainNode), + ) + + const parent = fullChainNode.parent + let dependencyPath = fullText + let coversRootMembers = fullText === identifier.name + + if ( + parent && + parent.type === AST_NODE_TYPES.CallExpression && + parent.callee === fullChainNode + ) { + const segments = fullText.split('.') + if (segments.length > 1) { + dependencyPath = segments.slice(0, -1).join('.') + } + + coversRootMembers = false + } + + dependencyPath = + dependencyPath.split('.')[0] === '' ? identifier.name : dependencyPath + const root = dependencyPath.split('.')[0] + + return { + path: dependencyPath, + root: root ?? identifier.name, + coversRootMembers: coversRootMembers && dependencyPath === root, + } + }, + collectExternalRefsInFunction(params: { functionNode: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression scopeManager: TSESLint.Scope.ScopeManager @@ -210,4 +379,61 @@ export const ExhaustiveDepsUtils = { return externalRefs }, + + /** + * Recursively collects type identifiers from a type annotation + */ + collectTypeIdentifiers(typeNode: TSESTree.TypeNode, out: Set): void { + switch (typeNode.type) { + case AST_NODE_TYPES.TSTypeReference: { + if (typeNode.typeName.type === AST_NODE_TYPES.Identifier) { + out.add(typeNode.typeName.name) + } + break + } + case AST_NODE_TYPES.TSUnionType: + case AST_NODE_TYPES.TSIntersectionType: { + typeNode.types.forEach((t) => + ExhaustiveDepsUtils.collectTypeIdentifiers(t, out), + ) + break + } + case AST_NODE_TYPES.TSArrayType: { + ExhaustiveDepsUtils.collectTypeIdentifiers(typeNode.elementType, out) + break + } + case AST_NODE_TYPES.TSTupleType: { + typeNode.elementTypes.forEach((et) => + ExhaustiveDepsUtils.collectTypeIdentifiers(et, out), + ) + break + } + } + }, + + /** + * Gets the function expression nodes from a queryFn property, handling conditional expressions. + * When neither branch is skipToken, returns both branches so all deps are scanned. + */ + getQueryFnNodes(queryFn: TSESTree.Property): Array { + if (queryFn.value.type !== AST_NODE_TYPES.ConditionalExpression) { + return [queryFn.value] + } + + if ( + queryFn.value.consequent.type === AST_NODE_TYPES.Identifier && + queryFn.value.consequent.name === 'skipToken' + ) { + return [queryFn.value.alternate] + } + + if ( + queryFn.value.alternate.type === AST_NODE_TYPES.Identifier && + queryFn.value.alternate.name === 'skipToken' + ) { + return [queryFn.value.consequent] + } + + return [queryFn.value.consequent, queryFn.value.alternate] + }, } diff --git a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts index 42c116a702f..cc465599ff8 100644 --- a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts +++ b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts @@ -38,19 +38,42 @@ export const rule = createRule({ return { CallExpression: (node) => { - if ( - !ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) || - node.parent.type !== AST_NODE_TYPES.VariableDeclarator || - !helpers.isTanstackQueryImport(node.callee) - ) { + if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) { return } const returnValue = node.parent.id + const isDirectHook = + ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) && + helpers.isTanstackQueryImport(node.callee) + + if (!isDirectHook) { + // The type-aware path can only report when the result is rest + // destructured or assigned to an identifier that may later be + // spread. Skip the expensive type lookup for any other binding. + const canReportQueryResult = + returnValue.type === AST_NODE_TYPES.Identifier || + NoRestDestructuringUtils.isObjectRestDestructuring(returnValue) + + if ( + !canReportQueryResult || + !NoRestDestructuringUtils.isQueryResultCall( + node, + context.sourceCode.parserServices, + ) + ) { + return + } + } + + const calleeName = ASTUtils.isIdentifier(node.callee) + ? node.callee.name + : null + if ( - node.callee.name !== 'useQueries' && - node.callee.name !== 'useSuspenseQueries' + calleeName !== 'useQueries' && + calleeName !== 'useSuspenseQueries' ) { if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) { return context.report({ diff --git a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts index 3cff8dcfa97..44133a2ca7e 100644 --- a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts +++ b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts @@ -1,5 +1,37 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils' -import type { TSESTree } from '@typescript-eslint/utils' +import type { + ParserServices, + ParserServicesWithTypeInformation, + TSESTree, +} from '@typescript-eslint/utils' + +type TypeChecker = ReturnType< + ParserServicesWithTypeInformation['program']['getTypeChecker'] +> +type Type = ReturnType + +const QUERY_RESULT_TYPE_NAMES = new Set([ + 'UseBaseQueryResult', + 'UseQueryResult', + 'UseSuspenseQueryResult', + 'DefinedUseQueryResult', + 'UseInfiniteQueryResult', + 'UseSuspenseInfiniteQueryResult', + 'DefinedUseInfiniteQueryResult', + 'QueryObserverResult', + 'InfiniteQueryObserverResult', +]) + +function isQueryResultType(type: Type): boolean { + if (type.aliasSymbol && QUERY_RESULT_TYPE_NAMES.has(type.aliasSymbol.name)) { + return true + } + const symbol = type.getSymbol() + if (symbol && QUERY_RESULT_TYPE_NAMES.has(symbol.name)) { + return true + } + return type.isUnion() && type.types.some(isQueryResultType) +} export const NoRestDestructuringUtils = { isObjectRestDestructuring(node: TSESTree.Node): boolean { @@ -8,4 +40,16 @@ export const NoRestDestructuringUtils = { } return node.properties.some((p) => p.type === AST_NODE_TYPES.RestElement) }, + isQueryResultCall( + node: TSESTree.CallExpression, + parserServices: Partial | null | undefined, + ): boolean { + if (!parserServices?.program || !parserServices.esTreeNodeToTSNodeMap) { + return false + } + const checker = parserServices.program.getTypeChecker() + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node.callee) + const signatures = checker.getTypeAtLocation(tsNode).getCallSignatures() + return signatures.some((sig) => isQueryResultType(sig.getReturnType())) + }, } diff --git a/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts b/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts index 32e68464682..934773faa0f 100644 --- a/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts +++ b/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts @@ -36,7 +36,13 @@ export const rule = createRule({ create: detectTanstackQueryImports((context, _options, helpers) => { const trackedVariables: Record = {} + const trackedCustomHooks: Record = {} const hookAliasMap: Record = {} + const pendingVariableDeclarators: Array = [] + const pendingDependencyChecks: Array<{ + reactHook: string + depsArray: TSESTree.ArrayExpression + }> = [] function getReactHook(node: TSESTree.CallExpression): string | undefined { if (node.callee.type === 'Identifier') { @@ -64,9 +70,27 @@ export const rule = createRule({ ) { if (pattern.type === AST_NODE_TYPES.Identifier) { trackedVariables[pattern.name] = queryHook + } else if (pattern.type === AST_NODE_TYPES.ArrayPattern) { + for (const element of pattern.elements) { + if (element === null) { + continue + } + if (element.type === AST_NODE_TYPES.Identifier) { + trackedVariables[element.name] = queryHook + } else if ( + element.type === AST_NODE_TYPES.RestElement && + element.argument.type === AST_NODE_TYPES.Identifier + ) { + trackedVariables[element.argument.name] = queryHook + } + } } } + function isCustomHookName(hookName: string): boolean { + return /^use[A-Z0-9]/.test(hookName) + } + function hasCombineProperty( callExpression: TSESTree.CallExpression, ): boolean { @@ -84,6 +108,95 @@ export const rule = createRule({ ) } + function getDirectQueryHook( + callExpression: TSESTree.CallExpression, + ): string | undefined { + if ( + callExpression.callee.type !== AST_NODE_TYPES.Identifier || + !allHookNames.includes(callExpression.callee.name) || + !helpers.isTanstackQueryImport(callExpression.callee) + ) { + return undefined + } + + if ( + (callExpression.callee.name === 'useQueries' || + callExpression.callee.name === 'useSuspenseQueries') && + hasCombineProperty(callExpression) + ) { + return undefined + } + + return callExpression.callee.name + } + + function getTrackedQueryHook( + callExpression: TSESTree.CallExpression, + ): string | undefined { + const directQueryHook = getDirectQueryHook(callExpression) + if (directQueryHook !== undefined) { + return directQueryHook + } + + if (callExpression.callee.type === AST_NODE_TYPES.Identifier) { + return trackedCustomHooks[callExpression.callee.name] + } + + return undefined + } + + function getReturnedQueryHook( + body: + | TSESTree.FunctionExpression['body'] + | TSESTree.ArrowFunctionExpression['body'], + ): string | undefined { + if (body.type === AST_NODE_TYPES.CallExpression) { + return getDirectQueryHook(body) + } + + if (body.type !== AST_NODE_TYPES.BlockStatement) { + return undefined + } + + const returnStatements = body.body.filter( + (statement): statement is TSESTree.ReturnStatement => + statement.type === AST_NODE_TYPES.ReturnStatement, + ) + if (returnStatements.length !== 1) { + return undefined + } + + const returnArgument = returnStatements[0]?.argument + if (returnArgument?.type === AST_NODE_TYPES.CallExpression) { + return getDirectQueryHook(returnArgument) + } + + return undefined + } + + function checkDependencyArray( + reactHook: string, + depsArray: TSESTree.ArrayExpression, + ) { + depsArray.elements.forEach((dep) => { + if ( + dep !== null && + dep.type === AST_NODE_TYPES.Identifier && + trackedVariables[dep.name] !== undefined + ) { + const queryHook = trackedVariables[dep.name] + context.report({ + node: dep, + messageId: 'noUnstableDeps', + data: { + queryHook, + reactHook, + }, + }) + } + }) + } + return { ImportDeclaration(node: TSESTree.ImportDeclaration) { if ( @@ -104,23 +217,36 @@ export const rule = createRule({ } }, + FunctionDeclaration(node) { + if (node.id === null || !isCustomHookName(node.id.name)) { + return + } + + const queryHook = getReturnedQueryHook(node.body) + if (queryHook !== undefined) { + trackedCustomHooks[node.id.name] = queryHook + } + }, + VariableDeclarator(node) { if ( + node.id.type === AST_NODE_TYPES.Identifier && + isCustomHookName(node.id.name) && node.init !== null && - node.init.type === AST_NODE_TYPES.CallExpression && - node.init.callee.type === AST_NODE_TYPES.Identifier && - allHookNames.includes(node.init.callee.name) && - helpers.isTanstackQueryImport(node.init.callee) + (node.init.type === AST_NODE_TYPES.ArrowFunctionExpression || + node.init.type === AST_NODE_TYPES.FunctionExpression) ) { - // Special case for useQueries with combine property - it's stable - if ( - node.init.callee.name === 'useQueries' && - hasCombineProperty(node.init) - ) { - // Don't track useQueries with combine as unstable - return + const queryHook = getReturnedQueryHook(node.init.body) + if (queryHook !== undefined) { + trackedCustomHooks[node.id.name] = queryHook } - collectVariableNames(node.id, node.init.callee.name) + } + + if ( + node.init !== null && + node.init.type === AST_NODE_TYPES.CallExpression + ) { + pendingVariableDeclarators.push(node) } }, CallExpression: (node) => { @@ -130,26 +256,28 @@ export const rule = createRule({ node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ArrayExpression ) { - const depsArray = node.arguments[1].elements - depsArray.forEach((dep) => { - if ( - dep !== null && - dep.type === AST_NODE_TYPES.Identifier && - trackedVariables[dep.name] !== undefined - ) { - const queryHook = trackedVariables[dep.name] - context.report({ - node: dep, - messageId: 'noUnstableDeps', - data: { - queryHook, - reactHook, - }, - }) - } + pendingDependencyChecks.push({ + reactHook, + depsArray: node.arguments[1], }) } }, + 'Program:exit'() { + pendingVariableDeclarators.forEach((node) => { + if (node.init?.type !== AST_NODE_TYPES.CallExpression) { + return + } + + const queryHook = getTrackedQueryHook(node.init) + if (queryHook !== undefined) { + collectVariableNames(node.id, queryHook) + } + }) + + pendingDependencyChecks.forEach(({ reactHook, depsArray }) => { + checkDependencyArray(reactHook, depsArray) + }) + }, } }), }) diff --git a/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts b/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts index 5a8708efc0c..cda036aaed5 100644 --- a/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts +++ b/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts @@ -5,11 +5,6 @@ import { getDocsUrl } from '../../utils/get-docs-url' import type { ParserServicesWithTypeInformation } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from '../../types' -const TypeFlags = { - Void: 16384, - Undefined: 32768, -} as const - export const name = 'no-void-query-fn' const createRule = ESLintUtils.RuleCreator(getDocsUrl) @@ -87,5 +82,6 @@ function isIllegalReturn(checker: TypeChecker, type: Type): boolean { return awaited.types.some((t) => isIllegalReturn(checker, t)) } - return awaited.flags & (TypeFlags.Void | TypeFlags.Undefined) ? true : false + const typeString = checker.typeToString(awaited) + return typeString === 'void' || typeString === 'undefined' } diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts new file mode 100644 index 00000000000..b7e65e9907e --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts @@ -0,0 +1,389 @@ +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' +import { ASTUtils } from '../../utils/ast-utils' +import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports' +import { getDocsUrl } from '../../utils/get-docs-url' +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' +import type { ExtraRuleDocs } from '../../types' + +export const name = 'prefer-query-options' + +const queryHooks = [ + 'useQuery', + 'useInfiniteQuery', + 'useSuspenseQuery', + 'useSuspenseInfiniteQuery', + 'usePrefetchQuery', + 'usePrefetchInfiniteQuery', +] + +const queriesHooks = ['useQueries', 'useSuspenseQueries'] + +const filterHooks = ['useIsFetching'] + +const queryClientOptionMethods = [ + 'fetchQuery', + 'prefetchQuery', + 'fetchInfiniteQuery', + 'prefetchInfiniteQuery', + 'ensureQueryData', + 'ensureInfiniteQueryData', +] + +const queryClientQueryKeyMethods = [ + 'getQueryData', + 'setQueryData', + 'getQueryState', + 'setQueryDefaults', + 'getQueryDefaults', +] + +const queryClientFilterMethods = [ + 'invalidateQueries', + 'cancelQueries', + 'refetchQueries', + 'removeQueries', + 'resetQueries', + 'isFetching', + 'getQueriesData', + 'setQueriesData', +] + +const queryOptionsBuilders = ['queryOptions', 'infiniteQueryOptions'] + +const createRule = ESLintUtils.RuleCreator(getDocsUrl) +type Helpers = { + isTanstackQueryImport: (node: TSESTree.Identifier) => boolean +} + +export const rule = createRule({ + name, + meta: { + type: 'problem', + docs: { + description: + 'Prefer using queryOptions() to co-locate queryKey and queryFn', + recommended: 'strict', + }, + messages: { + preferQueryOptions: + 'Prefer using queryOptions() or infiniteQueryOptions() to co-locate queryKey and queryFn.', + preferQueryOptionsQueryKey: + 'Prefer referencing a queryKey from a queryOptions() result instead of typing it manually.', + }, + schema: [], + }, + defaultOptions: [], + + create: detectTanstackQueryImports((context, _, helpers) => { + function reportInlineQueryOptions(node: TSESTree.Node): void { + if (ASTUtils.isObjectExpression(node) && hasInlineQueryOptions(node)) { + context.report({ + node, + messageId: 'preferQueryOptions', + }) + } + } + + function reportInlineFilterQueryKey(node: TSESTree.Node): void { + if (ASTUtils.isObjectExpression(node) && hasInlineFilterQueryKey(node)) { + context.report({ + node, + messageId: 'preferQueryOptionsQueryKey', + }) + } + } + + return { + CallExpression: (node) => { + if (ASTUtils.isIdentifier(node.callee)) { + const importedName = getTanstackImportName( + context, + helpers, + node.callee, + ) + + if (importedName === null) { + return + } + + if (queryOptionsBuilders.includes(importedName)) { + return + } + + const options = node.arguments[0] + + if (options === undefined) { + return + } + + if (queryHooks.includes(importedName)) { + reportInlineQueryOptions(options) + return + } + + if ( + queriesHooks.includes(importedName) && + ASTUtils.isObjectExpression(options) + ) { + const queries = ASTUtils.findPropertyWithIdentifierKey( + options.properties, + 'queries', + )?.value + + if (queries !== undefined) { + getQueryObjects(queries).forEach((query) => { + reportInlineQueryOptions(query) + }) + } + + return + } + + if (filterHooks.includes(importedName)) { + reportInlineFilterQueryKey(options) + } + + return + } + + if ( + node.callee.type !== AST_NODE_TYPES.MemberExpression || + !ASTUtils.isIdentifier(node.callee.property) || + !isTanstackQueryClient(node.callee.object, context, helpers) + ) { + return + } + + const method = node.callee.property.name + const options = node.arguments[0] + + if (options === undefined) { + return + } + + if (queryClientOptionMethods.includes(method)) { + reportInlineQueryOptions(options) + return + } + + if ( + queryClientQueryKeyMethods.includes(method) && + isInlineArrayExpression(options) + ) { + context.report({ + node: options, + messageId: 'preferQueryOptionsQueryKey', + }) + return + } + + if (queryClientFilterMethods.includes(method)) { + reportInlineFilterQueryKey(options) + } + }, + } + }), +}) + +function hasInlineQueryOptions(node: TSESTree.ObjectExpression): boolean { + return ( + ASTUtils.findPropertyWithIdentifierKey(node.properties, 'queryKey') !== + undefined || + ASTUtils.findPropertyWithIdentifierKey(node.properties, 'queryFn') !== + undefined + ) +} + +function hasInlineFilterQueryKey(node: TSESTree.ObjectExpression): boolean { + const queryKey = ASTUtils.findPropertyWithIdentifierKey( + node.properties, + 'queryKey', + )?.value + + return queryKey !== undefined && isInlineArrayExpression(queryKey) +} + +function isInlineArrayExpression(node: TSESTree.Node): boolean { + return unwrapTypeAssertions(node).type === AST_NODE_TYPES.ArrayExpression +} + +function getReturnedObjectExpressions( + node: TSESTree.Node, +): Array { + if (ASTUtils.isObjectExpression(node)) { + return [node] + } + + if ( + node.type === AST_NODE_TYPES.ArrowFunctionExpression || + node.type === AST_NODE_TYPES.FunctionExpression + ) { + return getReturnedObjectExpressions(node.body) + } + + if (node.type === AST_NODE_TYPES.BlockStatement) { + return node.body.flatMap((statement) => { + if ( + statement.type === AST_NODE_TYPES.ReturnStatement && + statement.argument !== null + ) { + return getReturnedObjectExpressions(statement.argument) + } + + return [] + }) + } + + if (node.type === AST_NODE_TYPES.ConditionalExpression) { + return [ + ...getReturnedObjectExpressions(node.consequent), + ...getReturnedObjectExpressions(node.alternate), + ] + } + + if (node.type === AST_NODE_TYPES.LogicalExpression) { + return [ + ...getReturnedObjectExpressions(node.left), + ...getReturnedObjectExpressions(node.right), + ] + } + + if (node.type === AST_NODE_TYPES.SequenceExpression) { + return node.expressions.flatMap((expression) => + getReturnedObjectExpressions(expression), + ) + } + + return [] +} + +function getQueryObjects( + node: TSESTree.Node, +): Array { + if (node.type === AST_NODE_TYPES.ArrayExpression) { + return node.elements.flatMap((element) => { + if (element !== null && ASTUtils.isObjectExpression(element)) { + return [element] + } + + return [] + }) + } + + if ( + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.MemberExpression && + ASTUtils.isIdentifierWithName(node.callee.property, 'map') + ) { + const mapper = node.arguments[0] + + if ( + mapper?.type === AST_NODE_TYPES.ArrowFunctionExpression || + mapper?.type === AST_NODE_TYPES.FunctionExpression + ) { + return getReturnedObjectExpressions(mapper) + } + } + + return [] +} + +function isTanstackQueryClient( + node: TSESTree.Node, + context: Readonly>>, + helpers: Helpers, +): boolean { + const source = resolveQueryClientSource(node, context) + + if ( + source.type === AST_NODE_TYPES.CallExpression && + ASTUtils.isIdentifier(source.callee) + ) { + return ( + getTanstackImportName(context, helpers, source.callee) === + 'useQueryClient' + ) + } + + if ( + source.type === AST_NODE_TYPES.NewExpression && + ASTUtils.isIdentifier(source.callee) + ) { + return ( + getTanstackImportName(context, helpers, source.callee) === 'QueryClient' + ) + } + + return false +} + +function getTanstackImportName( + context: Readonly>>, + helpers: Helpers, + node: TSESTree.Identifier, +): string | null { + if (!helpers.isTanstackQueryImport(node)) { + return null + } + + const definition = context.sourceCode + .getScope(node) + .references.find((reference) => reference.identifier === node)?.resolved + ?.defs[0]?.node + + if ( + definition?.type !== AST_NODE_TYPES.ImportSpecifier || + definition.imported.type !== AST_NODE_TYPES.Identifier + ) { + return null + } + + return definition.imported.name +} + +function resolveQueryClientSource( + node: TSESTree.Node, + context: Readonly>>, +): TSESTree.Node { + const visitedNodes = new Set() + + while (!visitedNodes.has(node)) { + visitedNodes.add(node) + + if (node.type === AST_NODE_TYPES.ChainExpression) { + node = node.expression + continue + } + + node = unwrapTypeAssertions(node) + + if (node.type !== AST_NODE_TYPES.Identifier) { + return node + } + + const expression = ASTUtils.getReferencedExpressionByIdentifier({ + context, + node, + }) + + if (expression === null) { + return node + } + + node = expression + } + + return node +} + +function unwrapTypeAssertions(node: TSESTree.Node): TSESTree.Node { + while ( + node.type === AST_NODE_TYPES.TSAsExpression || + node.type === AST_NODE_TYPES.TSSatisfiesExpression || + node.type === AST_NODE_TYPES.TSTypeAssertion + ) { + node = node.expression + } + + return node +} diff --git a/packages/eslint-plugin-query/src/utils/ast-utils.ts b/packages/eslint-plugin-query/src/utils/ast-utils.ts index 28c9b6f7421..a44ae864549 100644 --- a/packages/eslint-plugin-query/src/utils/ast-utils.ts +++ b/packages/eslint-plugin-query/src/utils/ast-utils.ts @@ -42,10 +42,9 @@ export const ASTUtils = { properties: Array, key: string, ): TSESTree.Property | undefined { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return properties.find((x) => + return properties.find((x): x is TSESTree.Property => ASTUtils.isPropertyWithIdentifierKey(x, key), - ) as TSESTree.Property | undefined + ) }, getNestedIdentifiers(node: TSESTree.Node): Array { const identifiers: Array = [] @@ -132,28 +131,6 @@ export const ASTUtils = { return identifiers }, - isAncestorIsCallee(identifier: TSESTree.Node) { - let previousNode = identifier - let currentNode = identifier.parent - - while (currentNode !== undefined) { - if ( - currentNode.type === AST_NODE_TYPES.CallExpression && - currentNode.callee === previousNode - ) { - return true - } - - if (currentNode.type !== AST_NODE_TYPES.MemberExpression) { - return false - } - - previousNode = currentNode - currentNode = currentNode.parent - } - - return false - }, traverseUpOnly( identifier: TSESTree.Node, allowedNodeTypes: Array, diff --git a/packages/eslint-plugin-query/tsconfig.json b/packages/eslint-plugin-query/tsconfig.json index bcd89cd0c8e..0cc454b2a77 100644 --- a/packages/eslint-plugin-query/tsconfig.json +++ b/packages/eslint-plugin-query/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "./dist-ts", "rootDir": "." }, - "include": ["src", "*.config.*", "package.json"] + "include": ["src", "*.config.ts", "*.config.js", "package.json"] } diff --git a/packages/eslint-plugin-query/tsconfig.prod.json b/packages/eslint-plugin-query/tsconfig.prod.json index 0f4c92da065..2bb29fdf02a 100644 --- a/packages/eslint-plugin-query/tsconfig.prod.json +++ b/packages/eslint-plugin-query/tsconfig.prod.json @@ -4,5 +4,7 @@ "incremental": false, "composite": false, "rootDir": "../../" - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/eslint-plugin-query/vite.config.ts b/packages/eslint-plugin-query/vite.config.ts index b9cb0a3ff6c..2014b4f928b 100644 --- a/packages/eslint-plugin-query/vite.config.ts +++ b/packages/eslint-plugin-query/vite.config.ts @@ -21,7 +21,7 @@ const config = defineConfig({ watch: false, globals: true, coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/lit-query/.editorconfig b/packages/lit-query/.editorconfig new file mode 100644 index 00000000000..9d08a1a828a --- /dev/null +++ b/packages/lit-query/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/packages/lit-query/.npmignore b/packages/lit-query/.npmignore new file mode 100644 index 00000000000..f497387664b --- /dev/null +++ b/packages/lit-query/.npmignore @@ -0,0 +1,2 @@ +docs/ +src/tests/ diff --git a/packages/lit-query/.prettierignore b/packages/lit-query/.prettierignore new file mode 100644 index 00000000000..f800743a0af --- /dev/null +++ b/packages/lit-query/.prettierignore @@ -0,0 +1,10 @@ +dist +dist-cjs +node_modules +coverage +.claude +.references +*.tgz +package-lock.json +examples/**/output +examples/**/dist diff --git a/packages/lit-query/CHANGELOG.md b/packages/lit-query/CHANGELOG.md new file mode 100644 index 00000000000..d9d2eda1f55 --- /dev/null +++ b/packages/lit-query/CHANGELOG.md @@ -0,0 +1,57 @@ +# @tanstack/lit-query + +## 0.2.7 + +### Patch Changes + +- [#10789](https://github.com/TanStack/query/pull/10789) [`3023dad`](https://github.com/TanStack/query/commit/3023dadec37ce74d4fbeb28f94adebacc6d43d60) - Fix redundant Lit host updates for function-backed query options, mutation state selectors, and tracked query result reads. + +- Updated dependencies []: + - @tanstack/query-core@5.101.0 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.14 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [[`d423168`](https://github.com/TanStack/query/commit/d423168f6261a5cb3d353e53b27c8150cc271151)]: + - @tanstack/query-core@5.100.13 + +## 0.2.4 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.12 + +## 0.2.3 + +### Patch Changes + +- Avoid scheduling redundant host updates when accessor function options resolve to an unchanged query result. ([#10716](https://github.com/TanStack/query/pull/10716)) + +## 0.2.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.11 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.10 + +## 0.2.0 + +### Minor Changes + +- Add initial @tanstack/lit-query package ([#10652](https://github.com/TanStack/query/pull/10652)) diff --git a/packages/lit-query/README.md b/packages/lit-query/README.md new file mode 100644 index 00000000000..c439074d806 --- /dev/null +++ b/packages/lit-query/README.md @@ -0,0 +1,127 @@ +# @tanstack/lit-query + +Lit adapter for `@tanstack/query-core` using Lit reactive controllers. + +This package is currently experimental and v0.1. Pin exact versions if you use +it in production while the API is early. + +## Install + +```bash +npm install @tanstack/lit-query @tanstack/query-core lit +``` + +For local development in this repository: + +```bash +pnpm install +pnpm --dir packages/lit-query run build +``` + +## Quick Start + +```ts +import { LitElement, html } from 'lit' +import { + QueryClient, + QueryClientProvider, + createQueryController, +} from '@tanstack/lit-query' + +const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +class AppProvider extends QueryClientProvider { + constructor() { + super() + this.client = client + } +} +customElements.define('app-provider', AppProvider) + +class UsersView extends LitElement { + private readonly users = createQueryController(this, { + queryKey: ['users'], + queryFn: async () => { + const response = await fetch('/api/users') + return response.json() as Promise> + }, + }) + + render() { + const query = this.users() + if (query.isPending) return html`Loading...` + if (query.isError) return html`Error` + return html`
    + ${query.data?.map((u) => html`
  • ${u.name}
  • `)} +
` + } +} +customElements.define('users-view', UsersView) +``` + +## API Surface + +- `QueryClientProvider`, `useQueryClient`, `resolveQueryClient` +- `createQueryController` +- `createMutationController` +- `createInfiniteQueryController` +- `createQueriesController` +- `useIsFetching`, `useIsMutating`, `useMutationState` +- `queryOptions`, `infiniteQueryOptions`, `mutationOptions` + +## Runnable Examples + +This repo includes runnable Lit examples under the top-level `examples/lit` +directory so they can be surfaced in the docs: + +- `examples/lit/basic`: Vite Lit app covering query and mutation primitives. +- `examples/lit/pagination`: pagination, prefetching, optimistic updates, and error recovery. +- `examples/lit/ssr`: Lit SSR render, dehydrate, and hydrate flow. + +Run an example from the repo root: + +```bash +pnpm --dir examples/lit/basic run dev +pnpm --dir examples/lit/pagination run dev +pnpm --dir examples/lit/ssr run dev +``` + +Open: + +- `http://127.0.0.1:4173/` (basic example) +- `http://127.0.0.1:4183/` (pagination example app) +- `http://127.0.0.1:4174/` (SSR example app) + +Use a different port (optional): + +```bash +DEMO_PORT=4180 pnpm --dir examples/lit/basic run dev +PAGINATION_DEMO_PORT=4181 PAGINATION_API_PORT=4182 pnpm --dir examples/lit/pagination run dev +SSR_PORT=4180 pnpm --dir examples/lit/ssr run dev +SSR_HOST=0.0.0.0 pnpm --dir examples/lit/ssr run dev +``` + +## Integration Smoke + +For the framework build smoke used in CI: + +```bash +pnpm --dir integrations/lit-vite run build +``` + +## Quality Gates + +From the repository root: + +```bash +pnpm --dir packages/lit-query run test:types +pnpm --dir packages/lit-query run test:lib +pnpm --dir packages/lit-query run build +pnpm --dir packages/lit-query run test:build +``` + +## License + +MIT diff --git a/packages/lit-query/eslint.config.js b/packages/lit-query/eslint.config.js new file mode 100644 index 00000000000..c3642e60191 --- /dev/null +++ b/packages/lit-query/eslint.config.js @@ -0,0 +1,52 @@ +// @ts-check + +import js from '@eslint/js' +import vitest from '@vitest/eslint-plugin' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { + ignores: [ + 'dist/**', + 'coverage/**', + 'node_modules/**', + '.claude/**', + '.references/**', + '**/dist/**', + '**/dist-cjs/**', + 'examples/**/output/**', + '**/*.d.ts', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,js,mjs,cjs}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'no-console': 'off', + }, + }, + { + files: ['src/tests/**/*.{ts,js,mjs}', 'examples/**/e2e/**/*.{js,mjs}'], + plugins: { + vitest, + }, + languageOptions: { + globals: { + ...vitest.environments.env.globals, + }, + }, + rules: { + ...vitest.configs.recommended.rules, + 'vitest/expect-expect': 'off', + }, + }, +) diff --git a/packages/lit-query/package.json b/packages/lit-query/package.json new file mode 100644 index 00000000000..635e5958e79 --- /dev/null +++ b/packages/lit-query/package.json @@ -0,0 +1,75 @@ +{ + "name": "@tanstack/lit-query", + "version": "0.2.7", + "description": "Lit adapter for TanStack Query Core", + "author": "tannerlinsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/query.git", + "directory": "packages/lit-query" + }, + "homepage": "https://tanstack.com/query", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "main": "dist-cjs/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "files": [ + "dist", + "dist-cjs", + "src/**/*.ts", + "!src/tests/**/*" + ], + "exports": { + ".": { + "@tanstack/custom-condition": "./src/index.ts", + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist-cjs/index.d.cts", + "default": "./dist-cjs/index.js" + }, + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "compile": "node ../../node_modules/typescript/lib/tsc.js --build", + "build:deps": "pnpm --dir ../query-core run build", + "build": "pnpm run build:deps && pnpm run build:esm && pnpm run build:cjs", + "build:esm": "node ../../node_modules/typescript/lib/tsc.js -p tsconfig.build.json", + "build:cjs": "node -e \"require('node:fs').rmSync('dist-cjs', { recursive: true, force: true })\" && node ../../node_modules/typescript/lib/tsc.js -p tsconfig.build.cjs.json && node scripts/write-cjs-package.mjs", + "test:types": "node ../../node_modules/typescript/lib/tsc.js --noEmit", + "test:eslint": "eslint .", + "lint:fix": "eslint . --fix", + "test:lib": "vitest run", + "test:lib:dev": "vitest", + "test:watch": "pnpm run test:lib:dev", + "test:build": "publint --strict && attw --pack && node scripts/check-cjs-types-smoke.mjs", + "measure:bundle": "pnpm run build && node scripts/measure-bundle.mjs", + "measure:bundle:raw": "node scripts/measure-bundle.mjs", + "perf:l3": "pnpm run build && node scripts/l3-stress.mjs", + "perf:l3:raw": "node scripts/l3-stress.mjs" + }, + "dependencies": { + "@lit/context": "^1.1.6", + "@tanstack/query-core": "workspace:*", + "lit": "^3.3.1" + }, + "peerDependencies": { + "@tanstack/query-core": "^5.0.0", + "lit": ">=2.8.0 <4" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "globals": "^17.4.0", + "typescript-eslint": "^8.54.0" + } +} diff --git a/packages/lit-query/scripts/check-cjs-types-smoke.mjs b/packages/lit-query/scripts/check-cjs-types-smoke.mjs new file mode 100644 index 00000000000..a8c508d11d7 --- /dev/null +++ b/packages/lit-query/scripts/check-cjs-types-smoke.mjs @@ -0,0 +1,137 @@ +import { execFile as execFileCallback } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +const execFile = promisify(execFileCallback) +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const workspaceRoot = resolve(projectDir, '..', '..') +const typeRootsDir = resolve(workspaceRoot, 'node_modules', '@types') +const tscEntrypoint = resolve( + workspaceRoot, + 'node_modules', + 'typescript', + 'lib', + 'tsc.js', +) + +const tempRoot = await mkdtemp(join(tmpdir(), 'tanstack-lit-query-cjs-smoke-')) +const packDir = resolve(tempRoot, 'pack') +const consumerDir = resolve(tempRoot, 'consumer') + +try { + const tarballPath = await packProject(packDir) + + await writeConsumerFixture(consumerDir) + await installConsumer(consumerDir, tarballPath) + await typecheckConsumer(consumerDir) + + console.log('CommonJS TypeScript smoke test passed.') +} finally { + await rm(tempRoot, { recursive: true, force: true }) +} + +async function packProject(destination) { + await mkdir(destination, { recursive: true }) + + const { stdout } = await execFile( + pnpmCommand, + ['pack', '--json', '--pack-destination', destination], + { + cwd: projectDir, + }, + ) + const packResult = JSON.parse(stdout) + const filename = Array.isArray(packResult) + ? packResult[0]?.filename + : packResult?.filename + + if (typeof filename !== 'string') { + throw new Error(`Unexpected pack output: ${stdout}`) + } + + return resolve(destination, filename) +} + +async function writeConsumerFixture(consumerDirectory) { + await rm(consumerDirectory, { recursive: true, force: true }) + await mkdir(consumerDirectory, { recursive: true }) + + await writeFile( + resolve(consumerDirectory, 'package.json'), + `${JSON.stringify( + { + private: true, + type: 'commonjs', + }, + null, + 2, + )}\n`, + 'utf8', + ) + + await writeFile( + resolve(consumerDirectory, 'index.cts'), + [ + "const pkg = require('@tanstack/lit-query')", + '', + "type CreateQueryOptions = import('@tanstack/lit-query').CreateQueryOptions", + '', + 'const options: CreateQueryOptions = {', + " queryKey: ['cjs-smoke'],", + " queryFn: async () => 'ok',", + '}', + '', + "if (typeof pkg.createQueryController !== 'function') {", + " throw new Error('createQueryController export is missing in CommonJS consumer.')", + '}', + '', + 'void pkg.queryOptions(options)', + '', + ].join('\n'), + 'utf8', + ) + + await writeFile( + resolve(consumerDirectory, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + module: 'Node16', + moduleResolution: 'Node16', + target: 'ES2022', + strict: true, + noEmit: true, + types: ['node'], + typeRoots: [typeRootsDir], + }, + include: ['index.cts'], + }, + null, + 2, + )}\n`, + 'utf8', + ) + + await writeFile( + resolve(consumerDirectory, '.npmrc'), + 'package-lock=false\n', + 'utf8', + ) +} + +async function installConsumer(consumerDirectory, tarballPath) { + await execFile(npmCommand, ['install', '--silent', tarballPath], { + cwd: consumerDirectory, + }) +} + +async function typecheckConsumer(consumerDirectory) { + await execFile(process.execPath, [tscEntrypoint, '-p', 'tsconfig.json'], { + cwd: consumerDirectory, + }) +} diff --git a/packages/lit-query/scripts/l3-stress.mjs b/packages/lit-query/scripts/l3-stress.mjs new file mode 100644 index 00000000000..55f52a9a73f --- /dev/null +++ b/packages/lit-query/scripts/l3-stress.mjs @@ -0,0 +1,124 @@ +import { QueryClient } from '@tanstack/query-core' +import { createQueryController } from '../dist/index.js' + +class TestControllerHost { + controllers = new Set() + updatesRequested = 0 + updateComplete = Promise.resolve(true) + + addController(controller) { + this.controllers.add(controller) + } + + removeController(controller) { + this.controllers.delete(controller) + } + + requestUpdate() { + this.updatesRequested += 1 + } + + connect() { + for (const controller of this.controllers) { + controller.hostConnected?.() + } + } + + disconnect() { + for (const controller of this.controllers) { + controller.hostDisconnected?.() + } + } + + update() { + for (const controller of this.controllers) { + controller.hostUpdate?.() + } + + for (const controller of this.controllers) { + controller.hostUpdated?.() + } + } +} + +async function waitFor(assertion, timeoutMs = 3000) { + const startedAt = Date.now() + while (!assertion()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out waiting for assertion after ${timeoutMs}ms`) + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +async function run(cycles = 1000) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }) + + const queryKey = ['l3-stress'] + const startedAt = Date.now() + const initialHeapMb = process.memoryUsage().heapUsed / (1024 * 1024) + + for (let cycle = 0; cycle < cycles; cycle += 1) { + const host = new TestControllerHost() + const query = createQueryController( + host, + { + queryKey, + gcTime: 0, + queryFn: async () => cycle, + }, + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess, 4000) + + const cacheQuery = client.getQueryCache().find({ queryKey }) + const connectedCount = cacheQuery?.getObserversCount() ?? 0 + if (connectedCount !== 1) { + throw new Error( + `observer_count_connected_invalid:${connectedCount}:cycle:${cycle}`, + ) + } + + host.disconnect() + query.destroy() + + const disconnectedCount = cacheQuery?.getObserversCount() ?? 0 + if (disconnectedCount !== 0) { + throw new Error( + `observer_count_disconnected_invalid:${disconnectedCount}:cycle:${cycle}`, + ) + } + } + + const elapsedMs = Date.now() - startedAt + const finalHeapMb = process.memoryUsage().heapUsed / (1024 * 1024) + const memoryGrowthMb = Number((finalHeapMb - initialHeapMb).toFixed(3)) + + const summary = { + measuredAt: new Date().toISOString(), + cycles, + elapsedMs, + initialHeapMb: Number(initialHeapMb.toFixed(3)), + finalHeapMb: Number(finalHeapMb.toFixed(3)), + memoryGrowthMb, + retainedObserversAfterRun: + client.getQueryCache().find({ queryKey })?.getObserversCount() ?? 0, + } + + console.log(JSON.stringify(summary, null, 2)) +} + +run().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/lit-query/scripts/measure-bundle.mjs b/packages/lit-query/scripts/measure-bundle.mjs new file mode 100644 index 00000000000..b8adec12b17 --- /dev/null +++ b/packages/lit-query/scripts/measure-bundle.mjs @@ -0,0 +1,43 @@ +import { readFile, readdir, stat } from 'node:fs/promises' +import path from 'node:path' +import { gzipSync } from 'node:zlib' +import { fileURLToPath } from 'node:url' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptDir, '..') +const distDir = path.join(repoRoot, 'dist') +const entryFile = path.join(distDir, 'index.js') + +async function getDirSizeBytes(dirPath) { + let total = 0 + const entries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + total += await getDirSizeBytes(fullPath) + } else if (entry.isFile()) { + total += (await stat(fullPath)).size + } + } + return total +} + +async function run() { + const entry = await readFile(entryFile) + const entryGzip = gzipSync(entry) + const distBytes = await getDirSizeBytes(distDir) + + const output = { + measuredAt: new Date().toISOString(), + entryJsBytes: entry.length, + entryJsGzipBytes: entryGzip.length, + distTotalBytes: distBytes, + } + + console.log(JSON.stringify(output, null, 2)) +} + +run().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/lit-query/scripts/write-cjs-package.mjs b/packages/lit-query/scripts/write-cjs-package.mjs new file mode 100644 index 00000000000..dcf076d7832 --- /dev/null +++ b/packages/lit-query/scripts/write-cjs-package.mjs @@ -0,0 +1,73 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' + +const projectDir = process.cwd() +const esmDir = resolve(projectDir, 'dist') +const outDir = resolve(process.cwd(), 'dist-cjs') +const esmOnlyPackages = new Set(['lit']) +const esmImportTypeRegex = /import type \{([^}]*)\} from (['"])([^'"]+)\2;/g +const esmValueImportRegex = /import \{([^}]*)\} from (['"])([^'"]+)\2;/g +const importTypeExpressionRegex = /import\((['"])([^'"]+)\1\)/g + +await mkdir(outDir, { recursive: true }) +await writeFile( + resolve(outDir, 'package.json'), + `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n`, + 'utf8', +) + +for (const declarationFile of await findDeclarationFiles(esmDir)) { + const source = await readFile(declarationFile, 'utf8') + const relativePath = relative(esmDir, declarationFile) + const outputPath = resolve(outDir, relativePath.replace(/\.d\.ts$/, '.d.cts')) + + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, rewriteDeclaration(source), 'utf8') +} + +async function findDeclarationFiles(rootDir) { + const entries = await readdir(rootDir, { withFileTypes: true }) + const files = [] + + for (const entry of entries) { + const entryPath = resolve(rootDir, entry.name) + + if (entry.isDirectory()) { + files.push(...(await findDeclarationFiles(entryPath))) + continue + } + + if (entry.isFile() && entry.name.endsWith('.d.ts')) { + files.push(entryPath) + } + } + + return files +} + +function rewriteDeclaration(source) { + return source + .replace(/^\/\/# sourceMappingURL=.*$\n?/gm, '') + .replace(/(['"])(\.\.?\/[^'"]+)\.js\1/g, '$1$2.cjs$1') + .replace(esmImportTypeRegex, (match, specifiers, quote, packageName) => { + if (!esmOnlyPackages.has(packageName)) { + return match + } + + return `import type {${specifiers}} from ${quote}${packageName}${quote} with { "resolution-mode": "import" };` + }) + .replace(esmValueImportRegex, (match, specifiers, quote, packageName) => { + if (!esmOnlyPackages.has(packageName)) { + return match + } + + return `import type {${specifiers}} from ${quote}${packageName}${quote} with { "resolution-mode": "import" };` + }) + .replace(importTypeExpressionRegex, (match, quote, packageName) => { + if (packageName !== 'lit-html') { + return match + } + + return `import(${quote}${packageName}${quote}, { with: { "resolution-mode": "import" } })` + }) +} diff --git a/packages/lit-query/src/QueryClientProvider.ts b/packages/lit-query/src/QueryClientProvider.ts new file mode 100644 index 00000000000..b3447aa5df9 --- /dev/null +++ b/packages/lit-query/src/QueryClientProvider.ts @@ -0,0 +1,174 @@ +import { ContextProvider } from '@lit/context' +import type { QueryClient } from '@tanstack/query-core' +import type { TemplateResult } from 'lit' +import { LitElement, html } from 'lit' +import { + createMissingQueryClientError, + queryClientContext, + registerDefaultQueryClient, + unregisterDefaultQueryClient, +} from './context.js' + +/** + * Lit element that provides a `QueryClient` to descendant Lit Query + * controllers through Lit context. + * + * The `client` is a property, not an attribute. When rendering this element in + * a Lit template, bind it with property binding: `.client=${queryClient}`. + * The provider throws if it connects without a client, or if an already + * connected provider has its client cleared. + * + * This class is not registered as a custom element by the package. Applications + * must register either a subclass or the class itself with + * `customElements.define`. + * + * @example + * ```ts + * import { html, LitElement } from 'lit' + * import { QueryClient, QueryClientProvider } from '@tanstack/lit-query' + * + * const queryClient = new QueryClient() + * + * class AppQueryProvider extends QueryClientProvider { + * constructor() { + * super() + * this.client = queryClient + * } + * } + * + * customElements.define('app-query-provider', AppQueryProvider) + * + * class AppRoot extends LitElement { + * render() { + * return html`` + * } + * } + * ``` + * + * @example + * ```ts + * import { html } from 'lit' + * import { QueryClient, QueryClientProvider } from '@tanstack/lit-query' + * + * const queryClient = new QueryClient() + * + * customElements.define('query-client-provider', QueryClientProvider) + * + * const view = html` + * + * + * + * ` + * ``` + */ +export class QueryClientProvider extends LitElement { + /** @internal */ + static properties = { + client: { attribute: false }, + } + + /** + * The `QueryClient` provided to descendant controllers and global fallback + * helpers while this provider is connected. + * + * Bind this as a property in Lit templates with `.client=${queryClient}`. + */ + declare client: QueryClient + + private readonly contextProvider: ContextProvider + + private mountedClient: QueryClient | undefined + + constructor() { + super() + this.contextProvider = new ContextProvider(this, { + context: queryClientContext, + }) + } + + /** @internal */ + connectedCallback(): void { + super.connectedCallback() + const client = this.requireClient() + this.contextProvider.setValue(client) + this.mountClient(client) + } + + /** @internal */ + disconnectedCallback(): void { + this.unmountClient(this.mountedClient) + super.disconnectedCallback() + } + + /** @internal */ + protected willUpdate(changedProperties: Map): void { + if (!changedProperties.has('client')) { + return + } + + const nextClient = this.client + if (!nextClient) { + if (this.isConnected) { + this.unmountClient(this.mountedClient) + // Sentinel: notify active consumers that the provider is now unbound. + this.contextProvider.setValue(undefined as unknown as QueryClient) + throw createMissingQueryClientError() + } + + return + } + + const previousClient = changedProperties.get('client') as + | QueryClient + | undefined + if (previousClient && previousClient !== nextClient && this.isConnected) { + this.unmountClient(previousClient) + } + + this.contextProvider.setValue(nextClient) + + if (this.isConnected) { + this.mountClient(nextClient) + } + } + + /** @internal */ + render(): TemplateResult { + return html`` + } + + private mountClient(client: QueryClient): void { + if (this.mountedClient === client) { + return + } + + if (this.mountedClient) { + this.unmountClient(this.mountedClient) + } + + client.mount() + registerDefaultQueryClient(client) + this.mountedClient = client + } + + private unmountClient(client?: QueryClient): void { + if (!client) { + return + } + + client.unmount() + unregisterDefaultQueryClient(client) + + if (this.mountedClient === client) { + this.mountedClient = undefined + } + } + + private requireClient(): QueryClient { + if (!this.client) { + throw createMissingQueryClientError() + } + + return this.client + } +} diff --git a/packages/lit-query/src/accessor.ts b/packages/lit-query/src/accessor.ts new file mode 100644 index 00000000000..35bc8098620 --- /dev/null +++ b/packages/lit-query/src/accessor.ts @@ -0,0 +1,43 @@ +/** + * A value that can be passed directly or read from a zero-argument getter. + * + * Lit Query APIs read function accessors during host updates, so the getter can + * depend on reactive host state. + * + * @example + * ```ts + * const staticKey: Accessor = ['todos'] + * const reactiveKey: Accessor = () => ['todos', this.userId] + * ``` + */ +export type Accessor = T | (() => T) + +export function readAccessor(value: Accessor): T { + return typeof value === 'function' ? (value as () => T)() : value +} + +/** + * A callable accessor with a `current` property for reading the latest + * controller result. + * + * Controller creators and cache state helpers return this shape so render code + * can use either `result()` or `result.current`. + * + * @example + * ```ts + * const query = this.todos() + * const sameQuery = this.todos.current + * ``` + */ +export type ValueAccessor = (() => T) & { + readonly current: T +} + +export function createValueAccessor(getter: () => T): ValueAccessor { + const accessor = (() => getter()) as ValueAccessor + Object.defineProperty(accessor, 'current', { + get: getter, + enumerable: true, + }) + return accessor +} diff --git a/packages/lit-query/src/context.ts b/packages/lit-query/src/context.ts new file mode 100644 index 00000000000..8e7c8a142f1 --- /dev/null +++ b/packages/lit-query/src/context.ts @@ -0,0 +1,120 @@ +import { createContext } from '@lit/context' +import type { QueryClient } from '@tanstack/query-core' + +/** + * Lit context key used by `QueryClientProvider` and host-bound APIs to share a + * `QueryClient` through the DOM tree. + * + * Most applications use `QueryClientProvider` instead of interacting with this + * context directly. + */ +export const queryClientContext = createContext( + Symbol.for('tanstack-query-client'), +) + +const missingQueryClientMessage = + 'No QueryClient available. Pass one explicitly or render within QueryClientProvider.' +const ambiguousQueryClientMessage = + 'Multiple QueryClients are mounted. Pass one explicitly instead of relying on global QueryClient helpers.' + +const registeredClients = new Map() +let defaultClient: QueryClient | undefined + +/** + * Registers a `QueryClient` as a process-local fallback for APIs that resolve a + * client without an explicit argument. + * + * `QueryClientProvider` calls this automatically while it is connected. Prefer + * passing an explicit client or rendering under a provider when possible. + * + * @param client - The query client to register as the current default. + */ +export function registerDefaultQueryClient(client: QueryClient): void { + registeredClients.set(client, (registeredClients.get(client) ?? 0) + 1) + defaultClient = client +} + +/** + * Unregisters a client previously registered with + * `registerDefaultQueryClient`. + * + * `QueryClientProvider` calls this automatically when it disconnects. + * + * @param client - The query client registration to release. + */ +export function unregisterDefaultQueryClient(client: QueryClient): void { + const count = registeredClients.get(client) + if (count === undefined) { + return + } + + if (count > 1) { + registeredClients.set(client, count - 1) + return + } + + registeredClients.delete(client) + if (defaultClient !== client) { + return + } + + const remaining = [...registeredClients.keys()] + defaultClient = remaining.at(-1) +} + +/** + * Returns the registered default `QueryClient`, if exactly one default client is + * available. + * + * @returns The default query client, or `undefined` when there is no registered + * client or more than one registered client. + */ +export function getDefaultQueryClient(): QueryClient | undefined { + if (registeredClients.size > 1) { + return undefined + } + + return defaultClient +} + +export function createMissingQueryClientError(): Error { + return new Error(missingQueryClientMessage) +} + +function createAmbiguousQueryClientError(): Error { + return new Error(ambiguousQueryClientMessage) +} + +/** + * Resolves the current default `QueryClient` registered by a connected + * `QueryClientProvider`. + * + * This helper is useful outside a Lit reactive controller when a single + * provider is mounted. It throws if no client is registered or if multiple + * clients are mounted and the default would be ambiguous. + * + * @returns The single registered query client. + */ +export function useQueryClient(): QueryClient { + const client = getDefaultQueryClient() + if (client) { + return client + } + + if (registeredClients.size > 1) { + throw createAmbiguousQueryClientError() + } + + throw createMissingQueryClientError() +} + +/** + * Resolves an explicit `QueryClient` or falls back to `useQueryClient`. + * + * @param explicit - Optional client supplied by the caller. + * @returns The explicit client when provided, otherwise the current default + * client. + */ +export function resolveQueryClient(explicit?: QueryClient): QueryClient { + return explicit ?? useQueryClient() +} diff --git a/packages/lit-query/src/controllers/BaseController.ts b/packages/lit-query/src/controllers/BaseController.ts new file mode 100644 index 00000000000..c619ece3158 --- /dev/null +++ b/packages/lit-query/src/controllers/BaseController.ts @@ -0,0 +1,292 @@ +import { ContextEvent } from '@lit/context' +import type { QueryClient } from '@tanstack/query-core' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { + createMissingQueryClientError, + queryClientContext, +} from '../context.js' + +type QueryClientResolutionState = + | 'pre-connect' + | 'awaiting-context' + | 'bound' + | 'missing' + +export abstract class BaseController implements ReactiveController { + protected result: TResult + + private readonly explicitClient?: QueryClient + private contextClient: QueryClient | undefined + private contextUnsubscribe: (() => void) | undefined + + private connected = false + private destroyed = false + private updateQueued = false + private clientChangeQueued = false + private connectionAttempt = 0 + private isHostUpdating = false + private queryClientResolutionState: QueryClientResolutionState + + protected constructor( + protected readonly host: ReactiveControllerHost, + initialResult: TResult, + queryClient?: QueryClient, + ) { + this.explicitClient = queryClient + this.result = initialResult + this.queryClientResolutionState = queryClient ? 'bound' : 'pre-connect' + + host.addController(this) + } + + hostConnected(): void { + if (this.connected || this.destroyed) { + return + } + + this.connected = true + let contextResolutionAttempt: number | undefined + + if (this.explicitClient) { + this.queryClientResolutionState = 'bound' + } else { + contextResolutionAttempt = ++this.connectionAttempt + this.beginContextResolution() + } + + // Defer onConnected to ensure subclass constructors complete before + // lifecycle callbacks access subclass state. This handles the case where + // addController is called on an already-connected host (e.g., during + // willUpdate), which synchronously triggers hostConnected before + // subclass field initialization. + queueMicrotask(() => { + if (this.connected && !this.destroyed) { + this.onConnected() + } + }) + + if (contextResolutionAttempt !== undefined) { + // Provider-backed controllers on already-connected hosts should finish + // their deferred onConnected pass before a context client binds. + this.queueContextResolution(contextResolutionAttempt) + } + } + + hostDisconnected(): void { + if (!this.connected) { + return + } + + this.connected = false + + if (!this.explicitClient) { + this.connectionAttempt += 1 + this.clearContextClient() + this.updateQueryClientResolutionState('pre-connect') + } + + this.onDisconnected() + } + + hostUpdate(): void { + if (this.destroyed) { + return + } + + this.isHostUpdating = true + try { + this.onHostUpdate() + } finally { + this.isHostUpdating = false + } + } + + destroy(): void { + if (this.destroyed) { + return + } + + this.destroyed = true + this.connected = false + this.connectionAttempt += 1 + this.clearContextClient() + this.queryClientResolutionState = this.explicitClient + ? 'bound' + : 'pre-connect' + this.onDisconnected() + + if ('removeController' in this.host) { + this.host.removeController(this) + } + } + + protected tryGetQueryClient(): QueryClient | undefined { + return this.explicitClient ?? this.contextClient + } + + protected getQueryClient(): QueryClient { + const client = this.tryGetQueryClient() + if (!client) { + throw createMissingQueryClientError() + } + + return client + } + + protected setResult(next: TResult): void { + if (Object.is(this.result, next)) { + return + } + + this.result = next + if (!this.isHostUpdating) { + this.queueUpdate() + } + } + + get current(): TResult { + if (this.queryClientResolutionState === 'missing') { + throw createMissingQueryClientError() + } + + return this.result + } + + protected get connectedState(): boolean { + return this.connected + } + + protected queueUpdate(): void { + if (this.updateQueued) { + return + } + + this.updateQueued = true + queueMicrotask(() => { + this.updateQueued = false + if (!this.destroyed) { + this.host.requestUpdate() + } + }) + } + + private queueQueryClientChanged(): void { + if (this.clientChangeQueued) { + return + } + + this.clientChangeQueued = true + queueMicrotask(() => { + this.clientChangeQueued = false + if (!this.destroyed) { + this.onQueryClientChanged() + } + }) + } + + private beginContextResolution(): void { + this.clearContextClient() + this.updateQueryClientResolutionState('awaiting-context') + } + + private queueContextResolution(attempt: number): void { + queueMicrotask(() => { + if ( + this.destroyed || + !this.connected || + attempt !== this.connectionAttempt || + this.queryClientResolutionState !== 'awaiting-context' + ) { + return + } + + this.dispatchContextRequest(attempt) + this.queueInitialContextResolutionCompletion(attempt) + }) + } + + private dispatchContextRequest(attempt: number): void { + if (!('dispatchEvent' in this.host)) { + return + } + + const contextTarget = this.host as ReactiveControllerHost & EventTarget + contextTarget.dispatchEvent( + new ContextEvent( + queryClientContext, + contextTarget as unknown as Element, + (value, unsubscribe) => { + if ( + this.destroyed || + !this.connected || + attempt !== this.connectionAttempt + ) { + unsubscribe?.() + return + } + + if ( + this.contextUnsubscribe && + this.contextUnsubscribe !== unsubscribe + ) { + this.contextUnsubscribe() + } + + const resolutionChanged = this.updateQueryClientResolutionState( + value === undefined ? 'missing' : 'bound', + ) + const clientChanged = this.contextClient !== value + + this.contextClient = value + this.contextUnsubscribe = unsubscribe + + if (resolutionChanged || clientChanged) { + this.queueUpdate() + this.queueQueryClientChanged() + } + }, + true, + ), + ) + } + + private queueInitialContextResolutionCompletion(attempt: number): void { + queueMicrotask(() => { + if ( + this.destroyed || + !this.connected || + attempt !== this.connectionAttempt || + this.queryClientResolutionState !== 'awaiting-context' + ) { + return + } + + if (this.updateQueryClientResolutionState('missing')) { + this.queueUpdate() + this.queueQueryClientChanged() + } + }) + } + + private clearContextClient(): void { + this.contextUnsubscribe?.() + this.contextUnsubscribe = undefined + this.contextClient = undefined + } + + private updateQueryClientResolutionState( + nextState: QueryClientResolutionState, + ): boolean { + if (this.queryClientResolutionState === nextState) { + return false + } + + this.queryClientResolutionState = nextState + return true + } + + protected abstract onConnected(): void + protected abstract onDisconnected(): void + protected abstract onHostUpdate(): void + protected abstract onQueryClientChanged(): void +} diff --git a/packages/lit-query/src/createInfiniteQueryController.ts b/packages/lit-query/src/createInfiniteQueryController.ts new file mode 100644 index 00000000000..6a41bb68371 --- /dev/null +++ b/packages/lit-query/src/createInfiniteQueryController.ts @@ -0,0 +1,433 @@ +import { + InfiniteQueryObserver, + type DefaultError, + type DefaultedInfiniteQueryObserverOptions, + type InfiniteData, + type InfiniteQueryObserverOptions, + type InfiniteQueryObserverResult, + type QueryKey, +} from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' +import type { ReactiveControllerHost } from 'lit' +import { + createValueAccessor, + readAccessor, + type Accessor, + type ValueAccessor, +} from './accessor.js' +import { createMissingQueryClientError } from './context.js' +import { BaseController } from './controllers/BaseController.js' +import { QueryObserverResultTracker } from './queryObserverResultTracker.js' + +/** + * Options accepted by `createInfiniteQueryController`. + * + * This is the Lit adapter shape for `InfiniteQueryObserverOptions`. Pass it + * directly or through an `Accessor` when the options depend on Lit host state. + */ +export type CreateInfiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> + +/** + * Accessor returned by `createInfiniteQueryController`. + * + * Call the accessor or read its `current` property to get the latest infinite + * query result. The attached methods delegate to the active infinite query + * observer. + */ +export type InfiniteQueryResultAccessor = ValueAccessor< + InfiniteQueryObserverResult +> & { + /** Refetches the current infinite query. */ + refetch: InfiniteQueryObserverResult['refetch'] + /** Fetches the next page for the current infinite query. */ + fetchNextPage: InfiniteQueryObserverResult['fetchNextPage'] + /** Fetches the previous page for the current infinite query. */ + fetchPreviousPage: InfiniteQueryObserverResult< + TData, + TError + >['fetchPreviousPage'] + /** Removes the controller from its Lit host and unsubscribes observers. */ + destroy: () => void +} + +function createPendingInfiniteQueryResult< + TData, + TError, +>(): InfiniteQueryObserverResult { + return { + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPaused: false, + isPending: true, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isEnabled: true, + isSuccess: false, + fetchStatus: 'idle', + status: 'pending', + refetch: (() => + Promise.reject( + createMissingQueryClientError(), + )) as InfiniteQueryObserverResult['refetch'], + fetchNextPage: (() => + Promise.reject( + createMissingQueryClientError(), + )) as InfiniteQueryObserverResult['fetchNextPage'], + fetchPreviousPage: (() => + Promise.reject( + createMissingQueryClientError(), + )) as InfiniteQueryObserverResult['fetchPreviousPage'], + hasNextPage: false, + hasPreviousPage: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + promise: Promise.resolve(undefined as never), + } as unknown as InfiniteQueryObserverResult +} + +class InfiniteQueryController< + TQueryFnData, + TError, + TData, + TQueryKey extends QueryKey, + TPageParam, +> extends BaseController> { + private readonly options: Accessor< + CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + > + private observer: + | InfiniteQueryObserver + | undefined + private readonly resultTracker = new QueryObserverResultTracker< + InfiniteQueryObserverResult + >() + private unsubscribe: (() => void) | undefined + private queryClient: QueryClient | undefined + + constructor( + host: ReactiveControllerHost, + options: Accessor< + CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + >, + queryClient?: QueryClient, + ) { + super(host, createPendingInfiniteQueryResult(), queryClient) + this.options = options + + if (!queryClient) { + return + } + + if (typeof options === 'function') { + return + } + + const defaulted = this.defaultOptions(queryClient) + const observer = new InfiniteQueryObserver(queryClient, defaulted) + this.queryClient = queryClient + this.observer = observer + this.assignObserverResult(observer.getOptimisticResult(defaulted)) + } + + protected onConnected(): void { + if (!this.syncClient()) { + return + } + + this.refreshOptions() + this.subscribe() + this.observer?.updateResult() + if (this.observer) { + this.setObserverResult(this.observer.getCurrentResult()) + } + } + + protected onDisconnected(): void { + this.unsubscribeObserver() + this.syncClient() + } + + protected onHostUpdate(): void { + if (typeof this.options !== 'function') { + return + } + + this.refreshOptions() + } + + protected onQueryClientChanged(): void { + if (!this.syncClient() || !this.connectedState) { + return + } + + this.refreshOptions() + this.subscribe() + this.observer?.updateResult() + if (this.observer) { + this.setObserverResult(this.observer.getCurrentResult()) + } + } + + refetch: InfiniteQueryObserverResult['refetch'] = ( + ...args + ) => { + if (!this.applyOptions() || !this.observer) { + return Promise.reject(createMissingQueryClientError()) + } + + return this.observer.refetch(...args) + } + + fetchNextPage: InfiniteQueryObserverResult['fetchNextPage'] = ( + ...args + ) => { + if (!this.applyOptions() || !this.observer) { + return Promise.reject(createMissingQueryClientError()) + } + + return this.observer.fetchNextPage(...args) + } + + fetchPreviousPage: InfiniteQueryObserverResult< + TData, + TError + >['fetchPreviousPage'] = (...args) => { + if (!this.applyOptions() || !this.observer) { + return Promise.reject(createMissingQueryClientError()) + } + + return this.observer.fetchPreviousPage(...args) + } + + readCurrent(): InfiniteQueryObserverResult { + if (this.observer) { + this.assignObserverResult(this.observer.getCurrentResult()) + } + + return this.current + } + + private subscribe(): void { + if (!this.observer) { + return + } + + if (this.unsubscribe) { + return + } + + this.unsubscribe = this.observer.subscribe((next) => { + this.setObserverResult(next) + }) + } + + private unsubscribeObserver(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + private syncClient(): boolean { + const nextClient = this.tryGetQueryClient() + if (!nextClient) { + this.unsubscribeObserver() + this.queryClient = undefined + this.observer = undefined + this.resultTracker.reset() + this.setResult(createPendingInfiniteQueryResult()) + return false + } + + if (nextClient === this.queryClient) { + return true + } + + this.unsubscribeObserver() + this.queryClient = nextClient + const options = this.defaultOptions(this.queryClient) + this.observer = new InfiniteQueryObserver(this.queryClient, options) + this.setObserverResult(this.observer.getOptimisticResult(options)) + return true + } + + private applyOptions(): boolean { + if (!this.syncClient() || !this.observer || !this.queryClient) { + return false + } + + const options = this.defaultOptions(this.queryClient) + this.observer.setOptions(options) + return true + } + + private refreshOptions(): boolean { + if (!this.applyOptions() || !this.observer) { + return false + } + + this.setObserverResult(this.observer.getCurrentResult()) + return true + } + + private assignObserverResult( + result: InfiniteQueryObserverResult, + ): void { + const trackedResult = this.resultTracker.update(this.observer, result) + if (trackedResult) { + this.result = trackedResult + } + } + + private setObserverResult( + result: InfiniteQueryObserverResult, + ): void { + const trackedResult = this.resultTracker.update(this.observer, result) + if (trackedResult) { + this.setResult(trackedResult) + } + } + + private defaultOptions( + client = this.queryClient, + ): DefaultedInfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > { + if (!client) { + throw createMissingQueryClientError() + } + + const defaulted = client.defaultQueryOptions( + readAccessor(this.options), + ) as DefaultedInfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + ;(defaulted as { _optimisticResults?: 'optimistic' })._optimisticResults = + 'optimistic' + return defaulted + } +} + +/** + * Creates a Lit reactive controller that subscribes the host to an infinite + * query. + * + * The returned accessor is callable and also exposes `current`, `refetch`, + * `fetchNextPage`, `fetchPreviousPage`, and `destroy`. When `options` is a + * function, it is re-read during host updates so query keys and options can + * follow reactive host state. + * + * If `queryClient` is omitted, the controller resolves the client from the + * nearest connected `QueryClientProvider`. + * + * @param host - The Lit reactive controller host that owns the infinite query + * subscription. + * @param options - Infinite query observer options, or a getter that returns + * options. + * @param queryClient - Optional explicit query client. Provide this for + * controllers that should not resolve a client from Lit context. + * @returns An accessor for the latest infinite query result with page helper + * methods. + * + * @example + * ```ts + * import { LitElement, html } from 'lit' + * import { createInfiniteQueryController } from '@tanstack/lit-query' + * + * class ProjectsView extends LitElement { + * private readonly projects = createInfiniteQueryController(this, { + * queryKey: ['projects'], + * queryFn: ({ pageParam }) => fetchProjects(pageParam), + * initialPageParam: 0, + * getNextPageParam: (lastPage) => lastPage.nextCursor, + * }) + * + * render() { + * const query = this.projects() + * + * return html` + * + * ` + * } + * } + * ``` + */ +export function createInfiniteQueryController< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + host: ReactiveControllerHost, + options: Accessor< + CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + >, + queryClient?: QueryClient, +): InfiniteQueryResultAccessor { + const controller = new InfiniteQueryController(host, options, queryClient) + + return Object.assign( + createValueAccessor(() => controller.readCurrent()), + { + refetch: controller.refetch, + fetchNextPage: controller.fetchNextPage, + fetchPreviousPage: controller.fetchPreviousPage, + destroy: () => controller.destroy(), + }, + ) +} diff --git a/packages/lit-query/src/createMutationController.ts b/packages/lit-query/src/createMutationController.ts new file mode 100644 index 00000000000..d3133982ddd --- /dev/null +++ b/packages/lit-query/src/createMutationController.ts @@ -0,0 +1,361 @@ +import { + MutationObserver, + type DefaultError, + type MutateOptions, + type MutationObserverOptions, + type MutationObserverResult, +} from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' +import type { ReactiveControllerHost } from 'lit' +import { + createValueAccessor, + readAccessor, + type Accessor, + type ValueAccessor, +} from './accessor.js' +import { createMissingQueryClientError } from './context.js' +import { BaseController } from './controllers/BaseController.js' + +/** + * Options accepted by `createMutationController`. + * + * This is the Lit adapter shape for `MutationObserverOptions`. Pass it directly + * or through an `Accessor` when the options depend on Lit host state. + */ +export type CreateMutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = MutationObserverOptions + +/** + * Accessor returned by `createMutationController`. + * + * Call the accessor or read its `current` property to get the latest mutation + * result. The attached methods delegate to the active mutation observer. + */ +export type MutationResultAccessor = + ValueAccessor< + MutationObserverResult + > & { + /** + * Starts the mutation and swallows the returned promise. + * + * Throws synchronously if no `QueryClient` can be resolved. + */ + mutate: ( + variables: TVariables, + options?: MutateOptions, + ) => void + /** + * Starts the mutation and returns the observer promise. + * + * Rejects if no `QueryClient` can be resolved. + */ + mutateAsync: MutationObserverResult< + TData, + TError, + TVariables, + TOnMutateResult + >['mutate'] + /** Resets the mutation observer to its idle state. */ + reset: MutationObserverResult< + TData, + TError, + TVariables, + TOnMutateResult + >['reset'] + /** Removes the controller from its Lit host and unsubscribes observers. */ + destroy: () => void + } + +function createIdleMutationResult< + TData, + TError, + TVariables, + TOnMutateResult, +>(): MutationObserverResult { + return { + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isError: false, + isIdle: true, + isPending: false, + isPaused: false, + isSuccess: false, + status: 'idle', + submittedAt: 0, + variables: undefined, + mutate: (() => + Promise.reject( + createMissingQueryClientError(), + )) as MutationObserverResult< + TData, + TError, + TVariables, + TOnMutateResult + >['mutate'], + reset: (() => undefined) as MutationObserverResult< + TData, + TError, + TVariables, + TOnMutateResult + >['reset'], + } as MutationObserverResult +} + +class MutationController< + TData, + TError, + TVariables, + TOnMutateResult, +> extends BaseController< + MutationObserverResult +> { + private readonly options: Accessor< + CreateMutationOptions + > + private observer: + | MutationObserver + | undefined + private unsubscribe: (() => void) | undefined + private queryClient: QueryClient | undefined + + constructor( + host: ReactiveControllerHost, + options: Accessor< + CreateMutationOptions + >, + queryClient?: QueryClient, + ) { + super(host, createIdleMutationResult(), queryClient) + this.options = options + + if (!queryClient) { + return + } + + if (typeof options === 'function') { + return + } + + const observer = new MutationObserver( + queryClient, + this.defaultOptions(queryClient), + ) + this.queryClient = queryClient + this.observer = observer + this.result = observer.getCurrentResult() + } + + protected onConnected(): void { + if (!this.syncClient()) { + return + } + + this.refreshOptions() + this.subscribe() + if (this.observer) { + this.setResult(this.observer.getCurrentResult()) + } + } + + protected onDisconnected(): void { + this.unsubscribeObserver() + this.syncClient() + } + + protected onHostUpdate(): void { + if (typeof this.options !== 'function') { + return + } + + this.refreshOptions() + } + + protected onQueryClientChanged(): void { + if (!this.syncClient() || !this.connectedState) { + return + } + + this.refreshOptions() + this.subscribe() + if (this.observer) { + this.setResult(this.observer.getCurrentResult()) + } + } + + mutate = ( + variables: TVariables, + mutateOptions?: MutateOptions, + ): void => { + if (!this.syncClient() || !this.observer) { + throw createMissingQueryClientError() + } + + void this.observer.mutate(variables, mutateOptions).catch(() => { + // Intentionally swallow in sync mutate path. + }) + } + + mutateAsync: MutationObserverResult< + TData, + TError, + TVariables, + TOnMutateResult + >['mutate'] = (...args) => { + if (!this.syncClient() || !this.observer) { + return Promise.reject(createMissingQueryClientError()) + } + + return this.observer.mutate(...args) + } + + reset: MutationObserverResult< + TData, + TError, + TVariables, + TOnMutateResult + >['reset'] = () => { + if (!this.syncClient() || !this.observer) { + return + } + + this.observer.reset() + this.setResult(this.observer.getCurrentResult()) + } + + private subscribe(): void { + if (!this.observer) { + return + } + + if (this.unsubscribe) { + return + } + + this.unsubscribe = this.observer.subscribe((next) => { + this.setResult(next) + }) + } + + private unsubscribeObserver(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + private syncClient(): boolean { + const nextClient = this.tryGetQueryClient() + if (!nextClient) { + this.unsubscribeObserver() + this.queryClient = undefined + this.observer = undefined + this.setResult(createIdleMutationResult()) + return false + } + + if (nextClient === this.queryClient) { + return true + } + + this.unsubscribeObserver() + this.queryClient = nextClient + this.observer = new MutationObserver( + this.queryClient, + this.defaultOptions(this.queryClient), + ) + this.setResult(this.observer.getCurrentResult()) + return true + } + + private refreshOptions(): boolean { + if (!this.syncClient() || !this.observer || !this.queryClient) { + return false + } + + this.observer.setOptions(this.defaultOptions()) + this.setResult(this.observer.getCurrentResult()) + return true + } + + private defaultOptions( + client = this.queryClient, + ): MutationObserverOptions { + if (!client) { + throw createMissingQueryClientError() + } + + return client.defaultMutationOptions(readAccessor(this.options)) + } +} + +/** + * Creates a Lit reactive controller that subscribes the host to a mutation. + * + * The returned accessor is callable and also exposes `current`, `mutate`, + * `mutateAsync`, `reset`, and `destroy`. When `options` is a function, it is + * re-read during host updates so mutation options can follow reactive host + * state. + * + * If `queryClient` is omitted, the controller resolves the client from the + * nearest connected `QueryClientProvider`. + * + * @param host - The Lit reactive controller host that owns the mutation + * subscription. + * @param options - Mutation observer options, or a getter that returns options. + * @param queryClient - Optional explicit query client. Provide this for + * controllers that should not resolve a client from Lit context. + * @returns An accessor for the latest mutation result with mutation helper + * methods. + * + * @example + * ```ts + * import { LitElement, html } from 'lit' + * import { createMutationController } from '@tanstack/lit-query' + * + * class AddTodoForm extends LitElement { + * private readonly addTodo = createMutationController(this, { + * mutationFn: (title: string) => + * fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }), + * }) + * + * render() { + * const mutation = this.addTodo() + * + * return html` + * + * ` + * } + * } + * ``` + */ +export function createMutationController< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + host: ReactiveControllerHost, + options: Accessor< + CreateMutationOptions + >, + queryClient?: QueryClient, +): MutationResultAccessor { + const controller = new MutationController(host, options, queryClient) + + return Object.assign( + createValueAccessor(() => controller.current), + { + mutate: controller.mutate, + mutateAsync: controller.mutateAsync, + reset: controller.reset, + destroy: () => controller.destroy(), + }, + ) +} diff --git a/packages/lit-query/src/createQueriesController.ts b/packages/lit-query/src/createQueriesController.ts new file mode 100644 index 00000000000..2af3b312004 --- /dev/null +++ b/packages/lit-query/src/createQueriesController.ts @@ -0,0 +1,721 @@ +import { + QueriesObserver, + replaceEqualDeep, + type DefaultError, + type DefinedQueryObserverResult, + type OmitKeyof, + type QueriesObserverOptions, + type QueryFunction, + type QueryKey, + type QueryObserverOptions, + type QueryObserverResult, + type ThrowOnError, +} from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' +import type { ReactiveControllerHost } from 'lit' +import { + createValueAccessor, + readAccessor, + type Accessor, + type ValueAccessor, +} from './accessor.js' +import { createMissingQueryClientError } from './context.js' +import { BaseController } from './controllers/BaseController.js' + +/** + * Options for one query inside `createQueriesController`. + * + * This mirrors `QueryObserverOptions` and is used by the tuple inference that + * maps each input query to its corresponding result. + */ +export type CreateQueriesInput< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = QueryObserverOptions + +type CreateQueriesInputForController< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = OmitKeyof, never> + +type MAXIMUM_DEPTH = 20 + +type SkipTokenForCreateQueries = symbol + +type GetCreateQueriesInput = T extends { + queryFnData: infer TQueryFnData + error?: infer TError + data: infer TData +} + ? CreateQueriesInputForController + : T extends { queryFnData: infer TQueryFnData; error?: infer TError } + ? CreateQueriesInputForController + : T extends { data: infer TData; error?: infer TError } + ? CreateQueriesInputForController + : T extends [infer TQueryFnData, infer TError, infer TData] + ? CreateQueriesInputForController + : T extends [infer TQueryFnData, infer TError] + ? CreateQueriesInputForController + : T extends [infer TQueryFnData] + ? CreateQueriesInputForController + : T extends { + queryFn?: + | QueryFunction + | SkipTokenForCreateQueries + select?: (data: any) => infer TData + throwOnError?: ThrowOnError + } + ? CreateQueriesInputForController< + TQueryFnData, + unknown extends TError ? DefaultError : TError, + unknown extends TData ? TQueryFnData : TData, + TQueryKey + > + : CreateQueriesInputForController + +type GetDefinedOrUndefinedCreateQueriesResult< + T, + TData, + TError = unknown, +> = T extends { + initialData?: infer TInitialData +} + ? unknown extends TInitialData + ? QueryObserverResult + : TInitialData extends TData + ? DefinedQueryObserverResult + : TInitialData extends () => infer TInitialDataResult + ? unknown extends TInitialDataResult + ? QueryObserverResult + : TInitialDataResult extends TData + ? DefinedQueryObserverResult + : QueryObserverResult + : QueryObserverResult + : QueryObserverResult + +type GetCreateQueriesResult = T extends { + queryFnData: any + error?: infer TError + data: infer TData +} + ? GetDefinedOrUndefinedCreateQueriesResult + : T extends { queryFnData: infer TQueryFnData; error?: infer TError } + ? GetDefinedOrUndefinedCreateQueriesResult + : T extends { data: infer TData; error?: infer TError } + ? GetDefinedOrUndefinedCreateQueriesResult + : T extends [any, infer TError, infer TData] + ? GetDefinedOrUndefinedCreateQueriesResult + : T extends [infer TQueryFnData, infer TError] + ? GetDefinedOrUndefinedCreateQueriesResult + : T extends [infer TQueryFnData] + ? GetDefinedOrUndefinedCreateQueriesResult + : T extends { + queryFn?: + | QueryFunction + | SkipTokenForCreateQueries + select?: (data: any) => infer TData + throwOnError?: ThrowOnError + } + ? GetDefinedOrUndefinedCreateQueriesResult< + T, + unknown extends TData ? TQueryFnData : TData, + unknown extends TError ? DefaultError : TError + > + : QueryObserverResult + +export type CreateQueriesOptions< + T extends Array, + TResults extends Array = [], + TDepth extends ReadonlyArray = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetCreateQueriesInput] + : T extends [infer Head, ...infer Tails] + ? CreateQueriesOptions< + [...Tails], + [...TResults, GetCreateQueriesInput], + [...TDepth, 1] + > + : ReadonlyArray extends T + ? T + : T extends Array< + CreateQueriesInputForController< + infer TQueryFnData, + infer TError, + infer TData, + infer TQueryKey + > + > + ? Array< + CreateQueriesInputForController< + TQueryFnData, + TError, + TData, + TQueryKey + > + > + : Array + +/** + * Tuple of query results inferred from the query inputs passed to + * `createQueriesController`. + */ +export type CreateQueriesResults< + T extends Array, + TResults extends Array = [], + TDepth extends ReadonlyArray = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetCreateQueriesResult] + : T extends [infer Head, ...infer Tails] + ? CreateQueriesResults< + [...Tails], + [...TResults, GetCreateQueriesResult], + [...TDepth, 1] + > + : { [K in keyof T]: GetCreateQueriesResult } + +/** + * Options accepted by `createQueriesController`. + * + * `queries` can be a static list or a getter that returns the current list. + * `combine` can reshape the array of query results into a single value for the + * returned accessor. + */ +export type CreateQueriesControllerOptions< + TQueryOptions extends Array = Array, + TCombinedResult = CreateQueriesResults, +> = { + /** Query options to observe, or a getter that returns the current options. */ + queries: Accessor< + | readonly [...CreateQueriesOptions] + | readonly [ + ...{ + [K in keyof TQueryOptions]: GetCreateQueriesInput + }, + ] + > + /** Optional function that combines the query result array into one value. */ + combine?: (result: CreateQueriesResults) => TCombinedResult +} + +/** + * Accessor returned by `createQueriesController`. + * + * Call the accessor or read its `current` property to get the latest combined + * value. + */ +export type QueriesResultAccessor = + ValueAccessor & { + /** Removes the controller from its Lit host and unsubscribes observers. */ + destroy: () => void + } + +function createPendingQueryObserverResult(): QueryObserverResult { + return { + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPaused: false, + isPending: true, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isEnabled: true, + isSuccess: false, + fetchStatus: 'idle', + status: 'pending', + refetch: (() => + Promise.reject( + createMissingQueryClientError(), + )) as QueryObserverResult['refetch'], + promise: Promise.resolve(undefined as never), + } as unknown as QueryObserverResult +} + +function createPlaceholderQueryObserverResult( + query: QueryObserverOptions, +): QueryObserverResult { + const initialData = + typeof query.initialData === 'function' + ? query.initialData() + : query.initialData + + if (initialData === undefined) { + return createPendingQueryObserverResult() + } + + const data = query.select ? query.select(initialData) : initialData + const initialDataUpdatedAt = + typeof query.initialDataUpdatedAt === 'function' + ? query.initialDataUpdatedAt() + : query.initialDataUpdatedAt + + return { + ...createPendingQueryObserverResult(), + data, + dataUpdatedAt: initialDataUpdatedAt ?? Date.now(), + isPending: false, + isInitialLoading: false, + isLoading: false, + isSuccess: true, + status: 'success', + promise: Promise.resolve(data as never), + } as QueryObserverResult +} + +function resolveQueriesOptions( + optionsAccessor: Accessor< + CreateQueriesControllerOptions + >, + client: QueryClient, +): { + queries: Array + combine: QueriesObserverOptions['combine'] +} { + const resolvedOptions = readAccessor(optionsAccessor) + const resolvedQueries = readAccessor(resolvedOptions.queries) + const combine = + resolvedOptions.combine as QueriesObserverOptions['combine'] + + return { + queries: resolvedQueries.map((query) => { + const defaulted = client.defaultQueryOptions( + query as QueryObserverOptions, + ) + ;(defaulted as { _optimisticResults?: 'optimistic' })._optimisticResults = + 'optimistic' + return defaulted + }), + combine, + } +} + +class QueriesController< + TQueryOptions extends Array, + TCombinedResult, +> extends BaseController { + private readonly options: Accessor< + CreateQueriesControllerOptions + > + private observer: QueriesObserver | undefined + private unsubscribe: (() => void) | undefined + private queryClient: QueryClient | undefined + private queries: Array = [] + private combine: QueriesObserverOptions['combine'] + private combinedResult: TCombinedResult | undefined + private rawResult: Array = [] + private explicitInitializationError: unknown | undefined + private placeholderInitialized = false + private placeholderRetryableFailure = true + + constructor( + host: ReactiveControllerHost, + options: Accessor< + CreateQueriesControllerOptions + >, + queryClient?: QueryClient, + ) { + super(host, [] as unknown as TCombinedResult, queryClient) + this.options = options + + queueMicrotask(() => { + this.placeholderRetryableFailure = false + }) + + if (!queryClient) { + return + } + + if (this.shouldRefreshOnHostUpdate()) { + return + } + + this.tryInitializeExplicitClient(queryClient) + } + + protected onConnected(): void { + if (!this.syncClient()) { + return + } + + this.refreshOptions() + this.subscribe() + } + + protected onDisconnected(): void { + this.unsubscribeObserver() + this.syncClient() + } + + protected onHostUpdate(): void { + if (!this.shouldRefreshOnHostUpdate()) { + return + } + + if (!this.refreshOptions()) { + this.setPlaceholderResult() + } + } + + protected onQueryClientChanged(): void { + if (!this.syncClient() || !this.connectedState) { + return + } + + this.refreshOptions() + this.subscribe() + } + + private subscribe(): void { + if (!this.observer) { + return + } + + if (this.unsubscribe) { + return + } + + this.unsubscribe = this.observer.subscribe((next) => { + this.setObserverResult(next) + }) + } + + private tryInitializeExplicitClient(queryClient: QueryClient): boolean { + try { + const { queries, combine } = resolveQueriesOptions( + this.options, + queryClient, + ) + this.queries = queries + this.combine = combine + const observer = new QueriesObserver(queryClient, this.queries, { + combine: this.combine, + } as QueriesObserverOptions) + this.queryClient = queryClient + this.observer = observer + this.assignObserverResult(observer.getCurrentResult(), true) + this.explicitInitializationError = undefined + this.placeholderInitialized = true + return true + } catch (error) { + // Retry after construction completes so late host fields used by + // static queries/combine callbacks can finish initializing first. + this.explicitInitializationError = error + this.queryClient = undefined + this.observer = undefined + return false + } + } + + private retryExplicitInitializationIfNeeded(): boolean { + if (!this.explicitInitializationError || this.shouldRefreshOnHostUpdate()) { + return false + } + + const explicitClient = this.tryGetQueryClient() + if (!explicitClient) { + return false + } + + return this.tryInitializeExplicitClient(explicitClient) + } + + private unsubscribeObserver(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + private syncClient(): boolean { + const nextClient = this.tryGetQueryClient() + if (!nextClient) { + this.unsubscribeObserver() + this.queryClient = undefined + this.observer = undefined + this.queries = [] + this.combine = undefined + this.combinedResult = undefined + this.rawResult = [] + this.setPlaceholderResult() + return false + } + + if (nextClient === this.queryClient) { + return true + } + + this.unsubscribeObserver() + this.queryClient = nextClient + const { queries, combine } = this.readResolvedOptions() + this.queries = queries + this.combine = combine + this.observer = new QueriesObserver(this.queryClient, this.queries, { + combine: this.combine, + } as QueriesObserverOptions) + this.setObserverResult(this.observer.getCurrentResult(), true) + this.placeholderInitialized = true + return true + } + + private refreshOptions(): boolean { + if (!this.syncClient() || !this.observer) { + return false + } + + const { queries, combine } = this.readResolvedOptions() + this.queries = queries + this.combine = combine + + this.observer.setQueries(this.queries, { + combine: this.combine, + } as QueriesObserverOptions) + + this.setObserverResult(this.observer.getCurrentResult(), true) + return true + } + + private readResolvedOptions(client = this.queryClient): { + queries: Array + combine: QueriesObserverOptions['combine'] + } { + if (!client) { + throw createMissingQueryClientError() + } + + return resolveQueriesOptions( + this.options as Accessor< + CreateQueriesControllerOptions + >, + client, + ) + } + + private shouldRefreshOnHostUpdate(): boolean { + if (typeof this.options === 'function') { + return true + } + + return typeof this.options.queries === 'function' + } + + private createResult(rawResult: Array): TCombinedResult { + const trackedResult = this.trackResult(rawResult) + const combine = this.combine + + if (!combine) { + return trackedResult as TCombinedResult + } + + this.combinedResult = replaceEqualDeep( + this.combinedResult, + combine(trackedResult), + ) as TCombinedResult + + return this.combinedResult + } + + private assignObserverResult( + rawResult: Array, + force = false, + ): void { + if (!force && this.hasObserverResult(rawResult)) { + return + } + + this.rawResult = rawResult + this.result = this.createResult(rawResult) + } + + private setObserverResult( + rawResult: Array, + force = false, + ): void { + if (!force && this.hasObserverResult(rawResult)) { + return + } + + this.rawResult = rawResult + this.setResult(this.createResult(rawResult)) + } + + private hasObserverResult(rawResult: Array): boolean { + return ( + this.rawResult.length === rawResult.length && + rawResult.every((result, index) => + Object.is(this.rawResult[index], result), + ) + ) + } + + private getCurrentObserverResults(): Array { + if (!this.observer) { + return [] + } + + return this.observer + .getObservers() + .map((observer) => observer.getCurrentResult()) + } + + private trackResult( + rawResult: Array, + ): Array { + if (!this.observer) { + return rawResult + } + + const observers = this.observer.getObservers() + + return rawResult.map((result, index) => { + const observer = observers[index] + + if (!observer || observer.options.notifyOnChangeProps) { + return result + } + + return observer.trackResult(result, (accessedProp) => { + observers.forEach((observer) => { + observer.trackProp(accessedProp) + }) + }) + }) + } + + private static createPlaceholderResult( + optionsAccessor: Accessor< + CreateQueriesControllerOptions + >, + ): TCombinedResult { + const resolvedOptions = readAccessor(optionsAccessor) + const queries = readAccessor(resolvedOptions.queries) + const placeholders = queries.map((query) => + createPlaceholderQueryObserverResult(query as QueryObserverOptions), + ) + return ( + resolvedOptions.combine + ? resolvedOptions.combine(placeholders as never) + : placeholders + ) as TCombinedResult + } + + readCurrent(): TCombinedResult { + if (this.retryExplicitInitializationIfNeeded()) { + return this.current + } + + if (this.explicitInitializationError && !this.placeholderRetryableFailure) { + throw this.explicitInitializationError + } + + if (!this.queryClient && !this.observer && !this.placeholderInitialized) { + try { + // Early reads can happen during class-field initialization, before + // accessors referenced by queries/combine are ready. Retry normally + // after construction finishes and only surface errors after that point. + this.setPlaceholderResult() + } catch (error) { + if (!this.placeholderRetryableFailure) { + throw error + } + } + } + + if (this.observer) { + this.assignObserverResult(this.getCurrentObserverResults()) + } + + return this.current + } + + private setPlaceholderResult(): void { + this.rawResult = [] + this.result = QueriesController.createPlaceholderResult(this.options) + this.placeholderInitialized = true + } +} + +/** + * Creates a Lit reactive controller that subscribes the host to multiple + * queries. + * + * The returned accessor is callable and also exposes `current` and `destroy`. + * When `options` or `options.queries` is a function, it is re-read during host + * updates so the query list can follow reactive host state. + * + * If `queryClient` is omitted, the controller resolves the client from the + * nearest connected `QueryClientProvider`. + * + * @param host - The Lit reactive controller host that owns the queries + * subscription. + * @param options - Queries controller options, or a getter that returns options. + * @param queryClient - Optional explicit query client. Provide this for + * controllers that should not resolve a client from Lit context. + * @returns An accessor for the latest query results, or the value returned by + * `combine`. + * + * @example + * ```ts + * import { LitElement, html } from 'lit' + * import { createQueriesController } from '@tanstack/lit-query' + * + * class DashboardView extends LitElement { + * private readonly dashboard = createQueriesController(this, { + * queries: [ + * { queryKey: ['stats'], queryFn: fetchStats }, + * { queryKey: ['projects'], queryFn: fetchProjects }, + * ], + * combine: ([stats, projects]) => ({ + * stats: stats.data, + * projects: projects.data ?? [], + * isPending: stats.isPending || projects.isPending, + * }), + * }) + * + * render() { + * const dashboard = this.dashboard() + * return html`

Projects: ${dashboard.projects.length}

` + * } + * } + * ``` + */ +export function createQueriesController< + TQueryOptions extends Array, + TCombinedResult = CreateQueriesResults, +>( + host: ReactiveControllerHost, + options: Accessor< + CreateQueriesControllerOptions + >, + queryClient?: QueryClient, +): QueriesResultAccessor { + const controller = new QueriesController(host, options, queryClient) + + return Object.assign( + createValueAccessor(() => controller.readCurrent()), + { + destroy: () => controller.destroy(), + }, + ) +} diff --git a/packages/lit-query/src/createQueryController.ts b/packages/lit-query/src/createQueryController.ts new file mode 100644 index 00000000000..09ec7af1391 --- /dev/null +++ b/packages/lit-query/src/createQueryController.ts @@ -0,0 +1,379 @@ +import { + QueryObserver, + type DefaultError, + type DefaultedQueryObserverOptions, + type QueryKey, + type QueryObserverOptions, + type QueryObserverResult, +} from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' +import type { ReactiveControllerHost } from 'lit' +import { + createValueAccessor, + readAccessor, + type Accessor, + type ValueAccessor, +} from './accessor.js' +import { createMissingQueryClientError } from './context.js' +import { BaseController } from './controllers/BaseController.js' +import { QueryObserverResultTracker } from './queryObserverResultTracker.js' + +/** + * Options accepted by `createQueryController`. + * + * This is the Lit adapter shape for `QueryObserverOptions`. It can be passed + * directly to `createQueryController`, or wrapped in an `Accessor` when the + * options depend on Lit host state. + */ +export type CreateQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = QueryObserverOptions + +/** + * Accessor returned by `createQueryController`. + * + * Call the accessor or read its `current` property to get the latest query + * result. The attached methods delegate to the active query observer. + */ +export type QueryResultAccessor = ValueAccessor< + QueryObserverResult +> & { + /** Refetches the current query. */ + refetch: QueryObserverResult['refetch'] + /** Resolves with an optimistic query result, fetching first when needed. */ + suspense: () => Promise> + /** Removes the controller from its Lit host and unsubscribes observers. */ + destroy: () => void +} + +function createPendingQueryResult(): QueryObserverResult< + TData, + TError +> { + return { + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPaused: false, + isPending: true, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isEnabled: true, + isSuccess: false, + fetchStatus: 'idle', + status: 'pending', + refetch: (() => + Promise.reject(createMissingQueryClientError())) as QueryObserverResult< + TData, + TError + >['refetch'], + promise: Promise.resolve(undefined as never), + } as unknown as QueryObserverResult +} + +class QueryController< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +> extends BaseController> { + private readonly options: Accessor< + CreateQueryOptions + > + private observer: + | QueryObserver + | undefined + private readonly resultTracker = new QueryObserverResultTracker< + QueryObserverResult + >() + private unsubscribe: (() => void) | undefined + private queryClient: QueryClient | undefined + + constructor( + host: ReactiveControllerHost, + options: Accessor< + CreateQueryOptions + >, + queryClient?: QueryClient, + ) { + const initialClient = queryClient + super(host, createPendingQueryResult(), queryClient) + this.options = options + + if (!initialClient) { + return + } + + if (typeof options === 'function') { + return + } + + const defaulted = this.defaultOptions(initialClient) + const observer = new QueryObserver(initialClient, defaulted) + this.queryClient = initialClient + this.observer = observer + this.assignObserverResult(observer.getOptimisticResult(defaulted)) + } + + protected onConnected(): void { + if (!this.syncClient()) { + return + } + + this.refreshOptions() + this.subscribe() + this.observer?.updateResult() + if (this.observer) { + this.setObserverResult(this.observer.getCurrentResult()) + } + } + + protected onDisconnected(): void { + this.unsubscribeObserver() + this.syncClient() + } + + protected onHostUpdate(): void { + if (typeof this.options !== 'function') { + return + } + + this.refreshOptions() + } + + protected onQueryClientChanged(): void { + if (!this.syncClient()) { + return + } + + if (!this.connectedState) { + return + } + + this.refreshOptions() + this.subscribe() + this.observer?.updateResult() + if (this.observer) { + this.setObserverResult(this.observer.getCurrentResult()) + } + } + + refetch: QueryObserverResult['refetch'] = (...args) => { + if (!this.applyOptions() || !this.observer) { + return Promise.reject(createMissingQueryClientError()) + } + + return this.observer.refetch(...args) + } + + suspense = async (): Promise> => { + if (!this.syncClient() || !this.observer || !this.queryClient) { + throw createMissingQueryClientError() + } + + const options = this.defaultOptions(this.queryClient) + this.observer.setOptions(options) + const optimistic = this.observer.getOptimisticResult(options) + if (options.enabled !== false && optimistic.isStale) { + return this.observer.fetchOptimistic(options) + } + + return optimistic + } + + readCurrent(): QueryObserverResult { + if (this.observer) { + this.assignObserverResult(this.observer.getCurrentResult()) + } + + return this.current + } + + private subscribe(): void { + if (!this.observer) { + return + } + + if (this.unsubscribe) { + return + } + + this.unsubscribe = this.observer.subscribe((next) => { + this.setObserverResult(next) + }) + } + + private unsubscribeObserver(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + private syncClient(): boolean { + const nextClient = this.tryGetQueryClient() + if (!nextClient) { + this.unsubscribeObserver() + this.queryClient = undefined + this.observer = undefined + this.resultTracker.reset() + this.setResult(createPendingQueryResult()) + return false + } + + if (nextClient === this.queryClient && this.observer) { + return true + } + + this.unsubscribeObserver() + this.queryClient = nextClient + const options = this.defaultOptions() + this.observer = new QueryObserver(this.queryClient, options) + this.setObserverResult(this.observer.getOptimisticResult(options)) + return true + } + + private applyOptions(): boolean { + if (!this.syncClient() || !this.observer) { + return false + } + + const options = this.defaultOptions(this.queryClient) + this.observer.setOptions(options) + return true + } + + private refreshOptions(): boolean { + if (!this.applyOptions() || !this.observer) { + return false + } + + this.setObserverResult(this.observer.getCurrentResult()) + return true + } + + private assignObserverResult( + result: QueryObserverResult, + ): void { + const trackedResult = this.resultTracker.update(this.observer, result) + if (trackedResult) { + this.result = trackedResult + } + } + + private setObserverResult(result: QueryObserverResult): void { + const trackedResult = this.resultTracker.update(this.observer, result) + if (trackedResult) { + this.setResult(trackedResult) + } + } + + private defaultOptions( + client = this.queryClient, + ): DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > { + const resolvedClient = client ?? this.tryGetQueryClient() + if (!resolvedClient) { + throw createMissingQueryClientError() + } + + this.queryClient = resolvedClient + const defaulted = resolvedClient.defaultQueryOptions( + readAccessor(this.options), + ) as DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > + ;(defaulted as { _optimisticResults?: 'optimistic' })._optimisticResults = + 'optimistic' + return defaulted + } +} + +/** + * Creates a Lit reactive controller that subscribes the host to a single query. + * + * The returned accessor is callable and also exposes `current`, `refetch`, + * `suspense`, and `destroy`. When `options` is a function, it is re-read during + * host updates so query keys and options can follow reactive host state. + * + * If `queryClient` is omitted, the controller resolves the client from the + * nearest connected `QueryClientProvider`. + * + * @param host - The Lit reactive controller host that owns the query + * subscription. + * @param options - Query observer options, or a getter that returns options. + * @param queryClient - Optional explicit query client. Provide this for + * controllers that should not resolve a client from Lit context. + * @returns An accessor for the latest query result with query helper methods. + * + * @example + * ```ts + * import { LitElement, html } from 'lit' + * import { createQueryController } from '@tanstack/lit-query' + * + * class TodosView extends LitElement { + * private readonly todos = createQueryController(this, { + * queryKey: ['todos'], + * queryFn: async () => fetch('/api/todos').then((r) => r.json()), + * }) + * + * render() { + * const query = this.todos() + * + * if (query.isPending) return html`Loading...` + * if (query.isError) return html`Error` + * + * return html`
    ${query.data.map((todo) => html`
  • ${todo.title}
  • `)}
` + * } + * } + * ``` + */ +export function createQueryController< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + host: ReactiveControllerHost, + options: Accessor< + CreateQueryOptions + >, + queryClient?: QueryClient, +): QueryResultAccessor { + const controller = new QueryController(host, options, queryClient) + + return Object.assign( + createValueAccessor(() => controller.readCurrent()), + { + refetch: controller.refetch, + suspense: controller.suspense, + destroy: () => controller.destroy(), + }, + ) +} diff --git a/packages/lit-query/src/index.ts b/packages/lit-query/src/index.ts new file mode 100644 index 00000000000..bf87f1dc45b --- /dev/null +++ b/packages/lit-query/src/index.ts @@ -0,0 +1,72 @@ +/* istanbul ignore file */ + +export * from '@tanstack/query-core' + +export type { Accessor, ValueAccessor } from './accessor.js' + +export { + getDefaultQueryClient, + queryClientContext, + registerDefaultQueryClient, + resolveQueryClient, + unregisterDefaultQueryClient, + useQueryClient, +} from './context.js' + +export { QueryClientProvider } from './QueryClientProvider.js' + +export type { + CreateInfiniteQueryOptions, + InfiniteQueryResultAccessor, +} from './createInfiniteQueryController.js' +export { createInfiniteQueryController } from './createInfiniteQueryController.js' + +export type { + CreateMutationOptions, + MutationResultAccessor, +} from './createMutationController.js' +export { createMutationController } from './createMutationController.js' + +export type { + CreateQueriesControllerOptions, + CreateQueriesInput, + QueriesResultAccessor, +} from './createQueriesController.js' +export { createQueriesController } from './createQueriesController.js' + +export type { + CreateQueryOptions, + QueryResultAccessor, +} from './createQueryController.js' +export { createQueryController } from './createQueryController.js' + +export type { IsFetchingAccessor } from './useIsFetching.js' +export { useIsFetching } from './useIsFetching.js' + +export type { IsMutatingAccessor } from './useIsMutating.js' +export { useIsMutating } from './useIsMutating.js' + +export type { + MutationStateAccessor, + MutationStateOptions, +} from './useMutationState.js' +export { useMutationState } from './useMutationState.js' + +export type { + DefinedInitialDataOptions, + UndefinedInitialDataOptions, + UnusedSkipTokenOptions, +} from './queryOptions.js' +export { queryOptions } from './queryOptions.js' + +export { infiniteQueryOptions } from './infiniteQueryOptions.js' +export { mutationOptions } from './mutationOptions.js' + +export type { + InfiniteQueryControllerOptions, + MutationControllerOptions, + MutationControllerResult, + QueriesControllerOptions, + QueryControllerOptions, + QueryControllerResult, +} from './types.js' diff --git a/packages/lit-query/src/infiniteQueryOptions.ts b/packages/lit-query/src/infiniteQueryOptions.ts new file mode 100644 index 00000000000..f4bc8851ebe --- /dev/null +++ b/packages/lit-query/src/infiniteQueryOptions.ts @@ -0,0 +1,48 @@ +import type { + DefaultError, + InfiniteData, + InfiniteQueryObserverOptions, + QueryKey, +} from '@tanstack/query-core' + +/** + * Preserves and types infinite query options for reuse across Lit Query APIs. + * + * @param options - Infinite query options to preserve. + * @returns The same options object. + * + * @example + * ```ts + * import { infiniteQueryOptions } from '@tanstack/lit-query' + * + * const projectsOptions = infiniteQueryOptions({ + * queryKey: ['projects'], + * queryFn: ({ pageParam }) => fetchProjects(pageParam), + * initialPageParam: 0, + * getNextPageParam: (lastPage) => lastPage.nextCursor, + * }) + * ``` + */ +export function infiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, +): InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> { + return options +} diff --git a/packages/lit-query/src/mutationOptions.ts b/packages/lit-query/src/mutationOptions.ts new file mode 100644 index 00000000000..ce5ef1b085e --- /dev/null +++ b/packages/lit-query/src/mutationOptions.ts @@ -0,0 +1,31 @@ +import type { + DefaultError, + MutationObserverOptions, +} from '@tanstack/query-core' + +/** + * Preserves and types mutation options for reuse across Lit Query APIs. + * + * @param options - Mutation options to preserve. + * @returns The same options object. + * + * @example + * ```ts + * import { mutationOptions } from '@tanstack/lit-query' + * + * const addTodoOptions = mutationOptions({ + * mutationKey: ['add-todo'], + * mutationFn: (title: string) => addTodo(title), + * }) + * ``` + */ +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: MutationObserverOptions, +): MutationObserverOptions { + return options +} diff --git a/packages/lit-query/src/queryObserverResultTracker.ts b/packages/lit-query/src/queryObserverResultTracker.ts new file mode 100644 index 00000000000..650df3937c8 --- /dev/null +++ b/packages/lit-query/src/queryObserverResultTracker.ts @@ -0,0 +1,30 @@ +type TrackableQueryObserver = { + options: { notifyOnChangeProps?: unknown } + trackResult: (result: TResult) => unknown +} + +export class QueryObserverResultTracker { + private result: TResult | undefined + private usesTracking = false + + reset(): void { + this.result = undefined + this.usesTracking = false + } + + update( + observer: TrackableQueryObserver | undefined, + result: TResult, + ): TResult | undefined { + const usesTracking = !!observer && !observer.options.notifyOnChangeProps + + if (Object.is(this.result, result) && this.usesTracking === usesTracking) { + return undefined + } + + this.result = result + this.usesTracking = usesTracking + + return usesTracking ? (observer.trackResult(result) as TResult) : result + } +} diff --git a/packages/lit-query/src/queryOptions.ts b/packages/lit-query/src/queryOptions.ts new file mode 100644 index 00000000000..c97c6683970 --- /dev/null +++ b/packages/lit-query/src/queryOptions.ts @@ -0,0 +1,143 @@ +import type { + DataTag, + DefaultError, + InitialDataFunction, + NonUndefinedGuard, + OmitKeyof, + QueryFunction, + QueryKey, + QueryObserverOptions, + SkipToken, +} from '@tanstack/query-core' + +/** + * Query options with `initialData` that guarantees defined query data. + */ +export type DefinedInitialDataOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + QueryObserverOptions, + 'queryFn' +> & { + initialData: + | NonUndefinedGuard + | (() => NonUndefinedGuard) + queryFn?: QueryFunction +} + +/** + * Query options where `queryFn` is present and not a `skipToken`. + */ +export type UnusedSkipTokenOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = OmitKeyof< + QueryObserverOptions, + 'queryFn' +> & { + queryFn?: Exclude< + QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >['queryFn'], + SkipToken | undefined + > +} + +/** + * Query options where `initialData` can be omitted or undefined. + */ +export type UndefinedInitialDataOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey +> & { + initialData?: + | undefined + | InitialDataFunction> + | NonUndefinedGuard +} + +/** + * Brands query options so the `queryKey` carries the query function data and + * error types across TanStack Query APIs. + * + * @param options - Query options to preserve and brand. + * @returns The same options object with a typed `queryKey`. + * + * @example + * ```ts + * import { queryOptions } from '@tanstack/lit-query' + * + * const todosOptions = queryOptions({ + * queryKey: ['todos'], + * queryFn: fetchTodos, + * initialData: [], + * }) + * ``` + */ +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: DefinedInitialDataOptions, +): DefinedInitialDataOptions & { + queryKey: DataTag +} + +/** + * Brands query options so the `queryKey` carries the query function data and + * error types across TanStack Query APIs. + * + * @param options - Query options to preserve and brand. + * @returns The same options object with a typed `queryKey`. + */ +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UnusedSkipTokenOptions, +): UnusedSkipTokenOptions & { + queryKey: DataTag +} + +/** + * Brands query options so the `queryKey` carries the query function data and + * error types across TanStack Query APIs. + * + * @param options - Query options to preserve and brand. + * @returns The same options object with a typed `queryKey`. + */ +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UndefinedInitialDataOptions, +): UndefinedInitialDataOptions & { + queryKey: DataTag +} + +export function queryOptions(options: unknown) { + return options +} diff --git a/packages/lit-query/src/tests/base-controller.test.ts b/packages/lit-query/src/tests/base-controller.test.ts new file mode 100644 index 00000000000..8587632fb8d --- /dev/null +++ b/packages/lit-query/src/tests/base-controller.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { QueryClient } from '@tanstack/query-core' +import { QueryClientProvider } from '../QueryClientProvider.js' +import { BaseController } from '../controllers/BaseController.js' + +const providerTagName = 'test-query-client-provider-base-controller' +if (!customElements.get(providerTagName)) { + customElements.define(providerTagName, QueryClientProvider) +} + +class RecordingController extends BaseController { + readonly lifecycle: string[] = [] + + constructor(host: ReactiveControllerHost) { + super(host, 'pending') + } + + protected onConnected(): void { + this.lifecycle.push( + `connected:${this.tryGetQueryClient() ? 'client' : 'missing'}`, + ) + } + + protected onDisconnected(): void {} + + protected onHostUpdate(): void {} + + protected onQueryClientChanged(): void { + this.lifecycle.push( + `changed:${this.tryGetQueryClient() ? 'client' : 'missing'}`, + ) + } +} + +class AlreadyConnectedContextHost + extends HTMLElement + implements ReactiveControllerHost +{ + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + if (this.isConnected) { + controller.hostConnected?.() + } + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + + connectedCallback(): void { + for (const controller of this.controllers) { + controller.hostConnected?.() + } + } + + disconnectedCallback(): void { + for (const controller of this.controllers) { + controller.hostDisconnected?.() + } + } + + attachController(): RecordingController { + return new RecordingController(this) + } +} + +const hostTagName = 'test-base-controller-context-host' +if (!customElements.get(hostTagName)) { + customElements.define(hostTagName, AlreadyConnectedContextHost) +} + +describe('BaseController', () => { + it('defers provider resolution on already-connected hosts until after onConnected', async () => { + const client = new QueryClient() + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + + const host = document.createElement( + hostTagName, + ) as AlreadyConnectedContextHost + provider.append(host) + + document.body.append(provider) + await provider.updateComplete + + const controller = host.attachController() + await Promise.resolve() + await Promise.resolve() + + expect(controller.lifecycle).toEqual([ + 'connected:missing', + 'changed:client', + ]) + + controller.destroy() + provider.remove() + await Promise.resolve() + }) +}) diff --git a/packages/lit-query/src/tests/client-switch-controllers.test.ts b/packages/lit-query/src/tests/client-switch-controllers.test.ts new file mode 100644 index 00000000000..3e60bfd88b9 --- /dev/null +++ b/packages/lit-query/src/tests/client-switch-controllers.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { QueryClientProvider } from '../QueryClientProvider.js' +import { createInfiniteQueryController } from '../createInfiniteQueryController.js' +import { createMutationController } from '../createMutationController.js' +import { createQueriesController } from '../createQueriesController.js' +import { waitFor } from './testHost.js' + +const providerTagName = 'test-query-client-provider-switch' +if (!customElements.get(providerTagName)) { + customElements.define(providerTagName, QueryClientProvider) +} + +class BaseControllerHostElement + extends HTMLElement + implements ReactiveControllerHost +{ + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + + connectedCallback(): void { + for (const controller of this.controllers) { + controller.hostConnected?.() + } + } + + disconnectedCallback(): void { + for (const controller of this.controllers) { + controller.hostDisconnected?.() + } + } +} + +class MutationSwitchHostElement extends BaseControllerHostElement { + mutationCalls = 0 + readonly mutationKey = ['switch-mutation'] as const + + readonly mutation = createMutationController(this, () => ({ + mutationKey: this.mutationKey, + mutationFn: async (value: number) => { + this.mutationCalls += 1 + return value + 1 + }, + })) +} + +const mutationHostTagName = 'test-mutation-switch-host' +if (!customElements.get(mutationHostTagName)) { + customElements.define(mutationHostTagName, MutationSwitchHostElement) +} + +class QueriesSwitchHostElement extends BaseControllerHostElement { + queryCalls = 0 + readonly queryKey = ['switch-queries'] as const + + readonly queries = createQueriesController(this, () => ({ + queries: [ + { + queryKey: this.queryKey, + queryFn: async () => { + this.queryCalls += 1 + return `q-${this.queryCalls}` + }, + retry: false, + }, + ] as const, + combine: (results) => results.map((result) => result.data), + })) +} + +const queriesHostTagName = 'test-queries-switch-host' +if (!customElements.get(queriesHostTagName)) { + customElements.define(queriesHostTagName, QueriesSwitchHostElement) +} + +class InfiniteSwitchHostElement extends BaseControllerHostElement { + pageCalls = 0 + readonly queryKey = ['switch-infinite'] as const + + readonly infinite = createInfiniteQueryController(this, () => ({ + queryKey: this.queryKey, + initialPageParam: 0, + queryFn: async ({ pageParam }) => { + this.pageCalls += 1 + return Number(pageParam) + }, + getNextPageParam: (lastPage: number) => + lastPage < 1 ? lastPage + 1 : undefined, + retry: false, + })) +} + +const infiniteHostTagName = 'test-infinite-switch-host' +if (!customElements.get(infiniteHostTagName)) { + customElements.define(infiniteHostTagName, InfiniteSwitchHostElement) +} + +describe('LQ-003 client-switch coverage across controllers', () => { + it('switches mutation controller to new provider client while connected', async () => { + const clientA = new QueryClient() + const clientB = new QueryClient() + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = clientA + document.body.append(provider) + await provider.updateComplete + + const consumer = document.createElement( + mutationHostTagName, + ) as MutationSwitchHostElement + provider.append(consumer) + + await Promise.resolve() + await Promise.resolve() + await expect(consumer.mutation.mutateAsync(1)).resolves.toBe(2) + + const countAAfterFirst = clientA + .getMutationCache() + .findAll({ mutationKey: consumer.mutationKey }).length + expect(countAAfterFirst).toBeGreaterThan(0) + + provider.client = clientB + await provider.updateComplete + await Promise.resolve() + + await expect(consumer.mutation.mutateAsync(2)).resolves.toBe(3) + + const countAAfterSecond = clientA + .getMutationCache() + .findAll({ mutationKey: consumer.mutationKey }).length + const countBAfterSecond = clientB + .getMutationCache() + .findAll({ mutationKey: consumer.mutationKey }).length + + expect(countAAfterSecond).toBe(countAAfterFirst) + expect(countBAfterSecond).toBeGreaterThan(0) + + consumer.mutation.destroy() + provider.remove() + await Promise.resolve() + }) + + it('switches queries controller to new provider client while connected', async () => { + const clientA = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const clientB = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = clientA + document.body.append(provider) + await provider.updateComplete + + const consumer = document.createElement( + queriesHostTagName, + ) as QueriesSwitchHostElement + provider.append(consumer) + + await Promise.resolve() + await waitFor(() => typeof consumer.queries()[0] === 'string') + + const cacheAEntryBeforeSwitch = clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + expect(cacheAEntryBeforeSwitch?.getObserversCount()).toBe(1) + + provider.client = clientB + await provider.updateComplete + + await waitFor(() => { + const cacheBEntry = clientB + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + return Boolean(cacheBEntry && cacheBEntry.getObserversCount() === 1) + }) + + const cacheAEntryAfterSwitch = clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + expect(cacheAEntryAfterSwitch?.getObserversCount() ?? 0).toBe(0) + + void clientB.invalidateQueries({ queryKey: consumer.queryKey }) + await waitFor(() => consumer.queryCalls >= 2) + + consumer.queries.destroy() + provider.remove() + await Promise.resolve() + }) + + it('switches infinite query controller to new provider client while connected', async () => { + const clientA = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const clientB = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = clientA + document.body.append(provider) + await provider.updateComplete + + const consumer = document.createElement( + infiniteHostTagName, + ) as InfiniteSwitchHostElement + provider.append(consumer) + + await Promise.resolve() + await waitFor(() => consumer.infinite().isSuccess) + expect(consumer.infinite().data?.pages).toEqual([0]) + + const cacheAEntryBeforeSwitch = clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + expect(cacheAEntryBeforeSwitch?.getObserversCount()).toBe(1) + + provider.client = clientB + await provider.updateComplete + + await waitFor(() => { + const cacheBEntry = clientB + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + return Boolean(cacheBEntry && cacheBEntry.getObserversCount() === 1) + }) + await waitFor( + () => + consumer.infinite().isSuccess && + (consumer.infinite().data?.pages.length ?? 0) >= 1, + 4000, + ) + await waitFor(() => consumer.infinite().hasNextPage === true, 4000) + + const cacheAEntryAfterSwitch = clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + expect(cacheAEntryAfterSwitch?.getObserversCount() ?? 0).toBe(0) + + await consumer.infinite.fetchNextPage() + await waitFor(() => consumer.infinite().data?.pages.length === 2, 4000) + expect(consumer.infinite().data?.pages).toEqual([0, 1]) + + consumer.infinite.destroy() + provider.remove() + await Promise.resolve() + }) + + it('reparents mutation controller under a different provider and binds the new nearest client', async () => { + const clientA = new QueryClient() + const clientB = new QueryClient() + + const providerA = document.createElement( + providerTagName, + ) as QueryClientProvider + providerA.client = clientA + const providerB = document.createElement( + providerTagName, + ) as QueryClientProvider + providerB.client = clientB + + const consumer = document.createElement( + mutationHostTagName, + ) as MutationSwitchHostElement + providerA.append(consumer) + + document.body.append(providerA) + await providerA.updateComplete + await Promise.resolve() + await Promise.resolve() + + await expect(consumer.mutation.mutateAsync(1)).resolves.toBe(2) + + consumer.remove() + await new Promise((resolve) => setTimeout(resolve, 0)) + providerA.remove() + + providerB.append(consumer) + document.body.append(providerB) + await providerB.updateComplete + await Promise.resolve() + await Promise.resolve() + + await expect(consumer.mutation.mutateAsync(2)).resolves.toBe(3) + expect( + clientA.getMutationCache().findAll({ mutationKey: consumer.mutationKey }) + .length, + ).toBeGreaterThan(0) + expect( + clientB.getMutationCache().findAll({ mutationKey: consumer.mutationKey }) + .length, + ).toBeGreaterThan(0) + + consumer.mutation.destroy() + providerB.remove() + await Promise.resolve() + }) + + it('reparents queries controller under a different provider without cross-tree leakage', async () => { + const clientA = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const clientB = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const providerA = document.createElement( + providerTagName, + ) as QueryClientProvider + providerA.client = clientA + const providerB = document.createElement( + providerTagName, + ) as QueryClientProvider + providerB.client = clientB + + const consumer = document.createElement( + queriesHostTagName, + ) as QueriesSwitchHostElement + providerA.append(consumer) + + document.body.append(providerA) + await providerA.updateComplete + + await waitFor(() => typeof consumer.queries()[0] === 'string') + + consumer.remove() + await waitFor( + () => + (clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount() ?? 0) === 0, + ) + providerA.remove() + + providerB.append(consumer) + document.body.append(providerB) + await providerB.updateComplete + await waitFor( + () => + typeof consumer.queries()[0] === 'string' && consumer.queryCalls >= 2, + ) + + expect( + clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount() ?? 0, + ).toBe(0) + expect( + clientB + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount(), + ).toBe(1) + + consumer.queries.destroy() + providerA.remove() + providerB.remove() + await Promise.resolve() + }) + + it('reparents infinite query controller under a different provider and binds the new nearest client', async () => { + const clientA = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const clientB = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const providerA = document.createElement( + providerTagName, + ) as QueryClientProvider + providerA.client = clientA + const providerB = document.createElement( + providerTagName, + ) as QueryClientProvider + providerB.client = clientB + + const consumer = document.createElement( + infiniteHostTagName, + ) as InfiniteSwitchHostElement + providerA.append(consumer) + + document.body.append(providerA) + await providerA.updateComplete + + await waitFor(() => consumer.infinite().isSuccess) + + consumer.remove() + await waitFor( + () => + (clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount() ?? 0) === 0, + ) + providerA.remove() + + providerB.append(consumer) + document.body.append(providerB) + await providerB.updateComplete + await waitFor( + () => + consumer.infinite().isSuccess && + (consumer.infinite().data?.pages.length ?? 0) >= 1 && + consumer.pageCalls >= 2, + ) + + expect( + clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount() ?? 0, + ).toBe(0) + expect( + clientB + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount(), + ).toBe(1) + + consumer.infinite.destroy() + providerA.remove() + providerB.remove() + await Promise.resolve() + }) +}) diff --git a/packages/lit-query/src/tests/context-provider.test.ts b/packages/lit-query/src/tests/context-provider.test.ts new file mode 100644 index 00000000000..a3bfbaa1154 --- /dev/null +++ b/packages/lit-query/src/tests/context-provider.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import { createQueryController } from '../createQueryController.js' +import { + getDefaultQueryClient, + resolveQueryClient, + useQueryClient, +} from '../index.js' +import { QueryClientProvider } from '../QueryClientProvider.js' +import { + TestElementHost, + waitFor, + waitForMissingQueryClient, +} from './testHost.js' + +const tagName = 'test-query-client-provider' +if (!customElements.get(tagName)) { + customElements.define(tagName, QueryClientProvider) +} + +class ProviderContextConsumerElement extends TestElementHost { + readonly query = createQueryController(this, { + queryKey: ['provider-context-consumer'] as const, + queryFn: async () => 'ok', + retry: false, + }) +} + +const consumerTagName = 'test-query-client-provider-consumer' +if (!customElements.get(consumerTagName)) { + customElements.define(consumerTagName, ProviderContextConsumerElement) +} + +describe('QueryClientProvider/context', () => { + it('registers and unregisters the default query client for public helpers', async () => { + const client = new QueryClient() + const provider = document.createElement(tagName) as QueryClientProvider + provider.client = client + + document.body.append(provider) + await provider.updateComplete + + expect(useQueryClient()).toBe(client) + expect(resolveQueryClient()).toBe(client) + + provider.remove() + await Promise.resolve() + + expect(() => useQueryClient()).toThrow(/No QueryClient available/) + }) + + it('prefers an explicit client in resolveQueryClient', () => { + const explicit = new QueryClient() + expect(resolveQueryClient(explicit)).toBe(explicit) + }) + + it('keeps the default client registered until the last provider using it disconnects', async () => { + const client = new QueryClient() + const providerA = document.createElement(tagName) as QueryClientProvider + const providerB = document.createElement(tagName) as QueryClientProvider + providerA.client = client + providerB.client = client + + document.body.append(providerA) + document.body.append(providerB) + await providerA.updateComplete + await providerB.updateComplete + + expect(useQueryClient()).toBe(client) + + providerB.remove() + await Promise.resolve() + + expect(useQueryClient()).toBe(client) + + providerA.remove() + await Promise.resolve() + + expect(() => useQueryClient()).toThrow(/No QueryClient available/) + }) + + it('throws when multiple different providers make global lookup ambiguous', async () => { + const clientA = new QueryClient() + const clientB = new QueryClient() + const providerA = document.createElement(tagName) as QueryClientProvider + const providerB = document.createElement(tagName) as QueryClientProvider + providerA.client = clientA + providerB.client = clientB + + document.body.append(providerA) + document.body.append(providerB) + await providerA.updateComplete + await providerB.updateComplete + + expect(getDefaultQueryClient()).toBeUndefined() + expect(() => useQueryClient()).toThrow(/Multiple QueryClients are mounted/) + expect(() => resolveQueryClient()).toThrow( + /Multiple QueryClients are mounted/, + ) + + providerB.remove() + await Promise.resolve() + + expect(getDefaultQueryClient()).toBe(clientA) + expect(useQueryClient()).toBe(clientA) + + providerA.remove() + await Promise.resolve() + }) + + it('requires an explicit client before connect', () => { + const provider = document.createElement(tagName) as QueryClientProvider + expect(() => provider.connectedCallback()).toThrow( + /No QueryClient available/, + ) + }) + + it('S8: provider swap while disconnected preserves mount/unmount contract', async () => { + const clientA = new QueryClient() + const clientB = new QueryClient() + + const mountA = vi.spyOn(clientA, 'mount') + const unmountA = vi.spyOn(clientA, 'unmount') + const mountB = vi.spyOn(clientB, 'mount') + const unmountB = vi.spyOn(clientB, 'unmount') + + const provider = document.createElement(tagName) as QueryClientProvider + provider.client = clientA + + document.body.append(provider) + await provider.updateComplete + + expect(mountA).toHaveBeenCalledTimes(1) + expect(unmountA).toHaveBeenCalledTimes(0) + expect(mountB).toHaveBeenCalledTimes(0) + expect(unmountB).toHaveBeenCalledTimes(0) + + provider.remove() + await Promise.resolve() + + expect(unmountA).toHaveBeenCalledTimes(1) + expect(mountB).toHaveBeenCalledTimes(0) + + provider.client = clientB + await provider.updateComplete + + expect(unmountA).toHaveBeenCalledTimes(1) + expect(mountB).toHaveBeenCalledTimes(0) + + document.body.append(provider) + await provider.updateComplete + + expect(mountA).toHaveBeenCalledTimes(1) + expect(unmountA).toHaveBeenCalledTimes(1) + expect(mountB).toHaveBeenCalledTimes(1) + expect(unmountB).toHaveBeenCalledTimes(0) + + provider.remove() + await Promise.resolve() + + expect(unmountB).toHaveBeenCalledTimes(1) + + mountA.mockRestore() + unmountA.mockRestore() + mountB.mockRestore() + unmountB.mockRestore() + }) + + it('LC-PROVIDER-01: invalid connected client updates tear down the mounted client before surfacing the error', async () => { + const client = new QueryClient() + const mount = vi.spyOn(client, 'mount') + const unmount = vi.spyOn(client, 'unmount') + + const provider = document.createElement(tagName) as QueryClientProvider + const consumer = document.createElement( + consumerTagName, + ) as ProviderContextConsumerElement + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + await waitFor(() => consumer.query().isSuccess) + + expect(mount).toHaveBeenCalledTimes(1) + expect(unmount).toHaveBeenCalledTimes(0) + expect(consumer.query().data).toBe('ok') + + provider.client = undefined as unknown as QueryClient + await expect(provider.updateComplete).rejects.toThrow( + /No QueryClient available/, + ) + expect(unmount).toHaveBeenCalledTimes(1) + expect(getDefaultQueryClient()).toBeUndefined() + expect(() => useQueryClient()).toThrow(/No QueryClient available/) + await waitForMissingQueryClient(() => consumer.query()) + await expect(consumer.query.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + + consumer.query.destroy() + provider.remove() + await Promise.resolve() + + expect(unmount).toHaveBeenCalledTimes(1) + + mount.mockRestore() + unmount.mockRestore() + }) +}) diff --git a/packages/lit-query/src/tests/counters-and-state.test.ts b/packages/lit-query/src/tests/counters-and-state.test.ts new file mode 100644 index 00000000000..d2b14739f4a --- /dev/null +++ b/packages/lit-query/src/tests/counters-and-state.test.ts @@ -0,0 +1,734 @@ +import { describe, expect, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { QueryClientProvider } from '../QueryClientProvider.js' +import { createMutationController } from '../createMutationController.js' +import { createQueryController } from '../createQueryController.js' +import { useIsFetching } from '../useIsFetching.js' +import { useIsMutating } from '../useIsMutating.js' +import { useMutationState } from '../useMutationState.js' +import { + TestControllerHost, + TestElementHost, + waitFor, + waitForMissingQueryClient, +} from './testHost.js' + +const providerTagName = 'test-query-client-provider-counters' +if (!customElements.get(providerTagName)) { + customElements.define(providerTagName, QueryClientProvider) +} + +let explicitCountersClient: QueryClient | undefined + +class ContextCountersHostElement extends TestElementHost { + readonly queryKey = ['context-counters', 'query'] as const + readonly mutationKey = ['context-counters', 'mutation'] as const + + private resolveQuery: (() => void) | undefined + private resolveMutation: (() => void) | undefined + + readonly query = createQueryController( + this, + { + queryKey: this.queryKey, + queryFn: () => + new Promise((resolve) => { + this.resolveQuery = () => resolve('query-ok') + }), + retry: false, + }, + explicitCountersClient, + ) + + readonly mutation = createMutationController( + this, + { + mutationKey: this.mutationKey, + mutationFn: () => + new Promise((resolve) => { + this.resolveMutation = () => resolve('mutation-ok') + }), + }, + explicitCountersClient, + ) + + readonly isFetching = useIsFetching( + this, + { queryKey: this.queryKey }, + explicitCountersClient, + ) + + readonly isMutating = useIsMutating( + this, + { mutationKey: this.mutationKey }, + explicitCountersClient, + ) + + readonly mutationStatuses = useMutationState( + this, + { + filters: { mutationKey: this.mutationKey }, + select: (mutation) => mutation.state.status, + }, + explicitCountersClient, + ) + + resolvePendingQuery(): void { + this.resolveQuery?.() + } + + resolvePendingMutation(): void { + this.resolveMutation?.() + } +} + +const contextCountersTagName = 'test-context-counters-host' +if (!customElements.get(contextCountersTagName)) { + customElements.define(contextCountersTagName, ContextCountersHostElement) +} + +describe('useIsFetching/useIsMutating/useMutationState', () => { + it('does not request another update when stable mutation state selectors refresh during host update', async () => { + const client = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + const mutationStates = useMutationState( + host, + { + select: (mutation) => mutation.state.status, + }, + client, + ) + + try { + host.connect() + await Promise.resolve() + + expect(mutationStates()).toEqual([]) + + host.updatesRequested = 0 + + for (let i = 0; i < 5; i += 1) { + host.update() + await Promise.resolve() + } + + expect(host.updatesRequested).toBe(0) + expect(mutationStates()).toEqual([]) + } finally { + mutationStates.destroy() + } + }) + + it('does not request another update when mutation cache emits with unchanged selected state', async () => { + const client = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }) + + const mutationKey = ['mutation-state', 'stable-cache-events'] as const + const mutation = client.getMutationCache().build(client, { + mutationKey, + }) + + const host = new TestControllerHost() + const mutationStates = useMutationState( + host, + { + filters: { mutationKey }, + select: (mutation) => mutation.state.status, + }, + client, + ) + + try { + host.connect() + await Promise.resolve() + + expect(mutationStates()).toEqual(['idle']) + + host.updatesRequested = 0 + + for (let i = 0; i < 5; i += 1) { + client.getMutationCache().notify({ + type: 'updated', + mutation, + action: { type: 'pause' } as never, + }) + await Promise.resolve() + } + + expect(host.updatesRequested).toBe(0) + expect(mutationStates()).toEqual(['idle']) + } finally { + mutationStates.destroy() + } + }) + + it('LC-COUNTERS-01: pre-connect placeholders stay zero/empty until a provider binds', async () => { + const consumer = document.createElement( + contextCountersTagName, + ) as ContextCountersHostElement + + expect(consumer.query().status).toBe('pending') + expect(consumer.isFetching()).toBe(0) + expect(consumer.isMutating()).toBe(0) + expect(consumer.mutationStatuses()).toEqual([]) + + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor(() => consumer.isFetching() === 1) + consumer.resolvePendingQuery() + await waitFor(() => consumer.query().isSuccess) + await waitFor(() => consumer.isFetching() === 0) + + consumer.mutation.mutate() + await waitFor(() => consumer.isMutating() === 1) + consumer.resolvePendingMutation() + await waitFor(() => consumer.isMutating() === 0) + await waitFor(() => consumer.mutationStatuses().includes('success')) + + consumer.query.destroy() + consumer.mutation.destroy() + consumer.isFetching.destroy() + consumer.isMutating.destroy() + consumer.mutationStatuses.destroy() + provider.remove() + await Promise.resolve() + }) + + it('LC-COUNTERS-02: explicit client takes precedence over provider context', async () => { + const explicitClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + const providerClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + explicitCountersClient = explicitClient + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = providerClient + + const consumer = document.createElement( + contextCountersTagName, + ) as ContextCountersHostElement + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor(() => consumer.isFetching() === 1) + consumer.resolvePendingQuery() + await waitFor(() => consumer.query().isSuccess) + + consumer.mutation.mutate() + await waitFor(() => consumer.isMutating() === 1) + consumer.resolvePendingMutation() + await waitFor(() => consumer.isMutating() === 0) + + expect( + explicitClient.getQueryCache().find({ queryKey: consumer.queryKey }), + ).toBeDefined() + expect( + providerClient.getQueryCache().find({ queryKey: consumer.queryKey }), + ).toBeUndefined() + expect( + explicitClient + .getMutationCache() + .findAll({ mutationKey: consumer.mutationKey }).length, + ).toBeGreaterThan(0) + expect( + providerClient + .getMutationCache() + .findAll({ mutationKey: consumer.mutationKey }).length, + ).toBe(0) + + consumer.query.destroy() + consumer.mutation.destroy() + consumer.isFetching.destroy() + consumer.isMutating.destroy() + consumer.mutationStatuses.destroy() + provider.remove() + explicitCountersClient = undefined + await Promise.resolve() + }) + + it('tracks fetch/mutate counters and mutation state', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + + const query = createQueryController( + host, + { + queryKey: ['counter-test'], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 40)) + return 'done' + }, + }, + client, + ) + + const mutation = createMutationController( + host, + { + mutationFn: async (value: number) => { + await new Promise((resolve) => setTimeout(resolve, 40)) + return value + 10 + }, + }, + client, + ) + + const isFetching = useIsFetching(host, {}, client) + const isMutating = useIsMutating(host, {}, client) + const mutationStatuses = useMutationState( + host, + { + select: (item) => item.state.status, + }, + client, + ) + + host.connect() + host.update() + + await waitFor(() => isFetching() === 1) + await waitFor(() => query().isSuccess) + await waitFor(() => isFetching() === 0) + + mutation.mutate(1) + await waitFor(() => isMutating() === 1) + await waitFor(() => isMutating() === 0) + await waitFor(() => mutationStatuses().includes('success')) + }) + + it('S1: useIsFetching tracks filters and filter reactivity', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let resolveA: (() => void) | undefined + let resolveB: (() => void) | undefined + let activeFilter: { queryKey?: readonly string[] } = { + queryKey: ['fetch-a'], + } + + createQueryController( + host, + { + queryKey: ['fetch-a'], + queryFn: () => + new Promise((resolve) => { + resolveA = () => resolve('a') + }), + }, + client, + ) + + createQueryController( + host, + { + queryKey: ['fetch-b'], + queryFn: () => + new Promise((resolve) => { + resolveB = () => resolve('b') + }), + }, + client, + ) + + const isFetchingAll = useIsFetching(host, {}, client) + const isFetchingFiltered = useIsFetching(host, () => activeFilter, client) + + host.connect() + host.update() + + await waitFor(() => isFetchingAll() === 2) + await waitFor(() => isFetchingFiltered() === 1) + + activeFilter = { queryKey: ['fetch-b'] } + host.update() + await waitFor(() => isFetchingFiltered() === 1) + + resolveA?.() + await waitFor(() => isFetchingAll() === 1) + await waitFor(() => isFetchingFiltered() === 1) + + resolveB?.() + await waitFor(() => isFetchingAll() === 0) + await waitFor(() => isFetchingFiltered() === 0) + }) + + it('S2: useIsMutating tracks mutation filters and reactivity', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + let resolveA: (() => void) | undefined + let resolveB: (() => void) | undefined + let activeFilter: { mutationKey?: readonly string[] } = { + mutationKey: ['mut-a'], + } + + const mutationA = createMutationController( + host, + { + mutationKey: ['mut-a'], + mutationFn: () => + new Promise((resolve) => { + resolveA = () => resolve(1) + }), + }, + client, + ) + + const mutationB = createMutationController( + host, + { + mutationKey: ['mut-b'], + mutationFn: () => + new Promise((resolve) => { + resolveB = () => resolve(2) + }), + }, + client, + ) + + const isMutatingAll = useIsMutating(host, {}, client) + const isMutatingFiltered = useIsMutating(host, () => activeFilter, client) + + host.connect() + host.update() + + mutationA.mutate() + mutationB.mutate() + + await waitFor(() => isMutatingAll() === 2) + await waitFor(() => isMutatingFiltered() === 1) + + activeFilter = { mutationKey: ['mut-b'] } + host.update() + await waitFor(() => isMutatingFiltered() === 1) + + resolveA?.() + await waitFor(() => isMutatingAll() === 1) + await waitFor(() => isMutatingFiltered() === 1) + + resolveB?.() + await waitFor(() => isMutatingAll() === 0) + await waitFor(() => isMutatingFiltered() === 0) + }) + + it('S3: useMutationState selects and filters by mutation key/status', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + let activeFilter: { mutationKey?: readonly string[] } = { + mutationKey: ['state-a'], + } + + const mutationA = createMutationController( + host, + { + mutationKey: ['state-a'], + mutationFn: async () => 'ok', + }, + client, + ) + + const mutationB = createMutationController( + host, + { + mutationKey: ['state-b'], + mutationFn: async () => { + throw new Error('state-b-failure') + }, + }, + client, + ) + + const mutationStatuses = useMutationState( + host, + { + filters: () => activeFilter, + select: (item) => item.state.status, + }, + client, + ) + + host.connect() + host.update() + + await expect(mutationA.mutateAsync(undefined)).resolves.toBe('ok') + await expect(mutationB.mutateAsync(undefined)).rejects.toThrow( + 'state-b-failure', + ) + + await waitFor( + () => + mutationStatuses().length === 1 && mutationStatuses()[0] === 'success', + ) + + activeFilter = { mutationKey: ['state-b'] } + host.update() + + await waitFor( + () => + mutationStatuses().length === 1 && mutationStatuses()[0] === 'error', + ) + }) + + it('S4: useMutationState refreshes when the select closure changes on host update', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + let label = 'before' + + const mutation = createMutationController( + host, + { + mutationKey: ['state-select-reactivity'], + mutationFn: async () => 'ok', + }, + client, + ) + + const mutationLabels = useMutationState( + host, + { + filters: { + mutationKey: ['state-select-reactivity'], + }, + select: () => label, + }, + client, + ) + + host.connect() + host.update() + + await expect(mutation.mutateAsync(undefined)).resolves.toBe('ok') + await waitFor( + () => mutationLabels().length === 1 && mutationLabels()[0] === 'before', + ) + + label = 'after' + host.update() + + await waitFor( + () => mutationLabels().length === 1 && mutationLabels()[0] === 'after', + ) + + mutation.destroy() + mutationLabels.destroy() + }) + + it('LC-COUNTERS-03: read-only helpers fail after handshake and recover under a provider', async () => { + const consumer = document.createElement( + contextCountersTagName, + ) as ContextCountersHostElement + + expect(consumer.query().status).toBe('pending') + expect(consumer.isFetching()).toBe(0) + expect(consumer.isMutating()).toBe(0) + expect(consumer.mutationStatuses()).toEqual([]) + + document.body.append(consumer) + await waitForMissingQueryClient(() => consumer.query()) + + expect(() => consumer.isFetching()).toThrow(/No QueryClient available/) + expect(() => consumer.isMutating()).toThrow(/No QueryClient available/) + expect(() => consumer.mutationStatuses()).toThrow( + /No QueryClient available/, + ) + + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor(() => consumer.isFetching() === 1) + consumer.resolvePendingQuery() + await waitFor(() => consumer.query().isSuccess) + await waitFor(() => consumer.isFetching() === 0) + + consumer.query.destroy() + consumer.mutation.destroy() + consumer.isFetching.destroy() + consumer.isMutating.destroy() + consumer.mutationStatuses.destroy() + provider.remove() + await Promise.resolve() + }) + + it('ALREADYCONN-COUNTERS-01: read-only helpers on already-connected host with explicit client do not throw', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + + const producerHost = new TestControllerHost() + let resolveQuery: (() => void) | undefined + let resolveMutation: (() => void) | undefined + + createQueryController( + producerHost, + { + queryKey: ['already-connected-counters-query'], + queryFn: () => + new Promise((resolve) => { + resolveQuery = () => resolve('query-ok') + }), + retry: false, + }, + client, + ) + + const producerMutation = createMutationController( + producerHost, + { + mutationKey: ['already-connected-counters-mutation'], + mutationFn: () => + new Promise((resolve) => { + resolveMutation = () => resolve('mutation-ok') + }), + }, + client, + ) + + producerHost.connect() + producerHost.update() + await waitFor(() => client.isFetching() === 1) + + producerMutation.mutate() + await waitFor(() => client.isMutating() === 1) + + class AlreadyConnectedHost implements ReactiveControllerHost { + private readonly controllers = new Set() + private isConnected = true + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + if (this.isConnected) { + controller.hostConnected?.() + } + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + const host = new AlreadyConnectedHost() + const isFetching = useIsFetching(host, {}, client) + const isMutating = useIsMutating(host, {}, client) + const mutationStatuses = useMutationState( + host, + { + filters: { mutationKey: ['already-connected-counters-mutation'] }, + select: (mutation) => mutation.state.status, + }, + client, + ) + + await Promise.resolve() + await Promise.resolve() + + await waitFor(() => isFetching() === 1) + await waitFor(() => isMutating() === 1) + await waitFor(() => mutationStatuses().includes('pending')) + + resolveQuery?.() + resolveMutation?.() + + await waitFor(() => isFetching() === 0) + await waitFor(() => isMutating() === 0) + await waitFor(() => mutationStatuses().includes('success')) + + isFetching.destroy() + isMutating.destroy() + mutationStatuses.destroy() + producerMutation.destroy() + }) +}) diff --git a/packages/lit-query/src/tests/infinite-and-options.test.ts b/packages/lit-query/src/tests/infinite-and-options.test.ts new file mode 100644 index 00000000000..6ef06b6cdc1 --- /dev/null +++ b/packages/lit-query/src/tests/infinite-and-options.test.ts @@ -0,0 +1,582 @@ +import { describe, expect, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { QueryClientProvider } from '../QueryClientProvider.js' +import { createInfiniteQueryController } from '../createInfiniteQueryController.js' +import { createMutationController } from '../createMutationController.js' +import { createQueryController } from '../createQueryController.js' +import { infiniteQueryOptions } from '../infiniteQueryOptions.js' +import { mutationOptions } from '../mutationOptions.js' +import { queryOptions } from '../queryOptions.js' +import { + TestControllerHost, + TestElementHost, + waitFor, + waitForMissingQueryClient, +} from './testHost.js' + +const providerTagName = 'test-query-client-provider-infinite' +if (!customElements.get(providerTagName)) { + customElements.define(providerTagName, QueryClientProvider) +} + +let explicitInfiniteClient: QueryClient | undefined + +class ContextInfiniteHostElement extends TestElementHost { + readonly queryKey = ['context-infinite'] as const + + readonly infinite = createInfiniteQueryController( + this, + { + queryKey: this.queryKey, + initialPageParam: 0, + queryFn: async ({ pageParam }) => Number(pageParam), + getNextPageParam: (lastPage) => (lastPage < 1 ? lastPage + 1 : undefined), + getPreviousPageParam: (firstPage) => + firstPage > -1 ? firstPage - 1 : undefined, + retry: false, + }, + explicitInfiniteClient, + ) +} + +const contextInfiniteTagName = 'test-context-infinite-host' +if (!customElements.get(contextInfiniteTagName)) { + customElements.define(contextInfiniteTagName, ContextInfiniteHostElement) +} + +describe('createInfiniteQueryController', () => { + it('LC-INF-01: first provider connection resolves from the pre-connect placeholder state', async () => { + const consumer = document.createElement( + contextInfiniteTagName, + ) as ContextInfiniteHostElement + + expect(consumer.infinite().status).toBe('pending') + await expect(consumer.infinite.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + await expect(consumer.infinite.fetchNextPage()).rejects.toThrow( + /No QueryClient available/, + ) + + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor(() => consumer.infinite().isSuccess) + expect(consumer.infinite().data?.pages).toEqual([0]) + + consumer.infinite.destroy() + provider.remove() + await Promise.resolve() + }) + + it('LC-INF-02: explicit client takes precedence over provider context', async () => { + const explicitClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const providerClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + explicitInfiniteClient = explicitClient + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = providerClient + + const consumer = document.createElement( + contextInfiniteTagName, + ) as ContextInfiniteHostElement + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor(() => consumer.infinite().isSuccess) + expect(consumer.infinite().data?.pages).toEqual([0]) + expect( + explicitClient.getQueryCache().find({ queryKey: consumer.queryKey }), + ).toBeDefined() + expect( + providerClient.getQueryCache().find({ queryKey: consumer.queryKey }), + ).toBeUndefined() + + consumer.infinite.destroy() + provider.remove() + explicitInfiniteClient = undefined + await Promise.resolve() + }) + + it('M14: supports initial page, fetchNextPage, and fetchPreviousPage', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + const infinite = createInfiniteQueryController( + host, + { + queryKey: ['m14', 'infinite'], + initialPageParam: 0, + queryFn: async ({ pageParam }) => Number(pageParam), + getNextPageParam: (lastPage) => + lastPage < 1 ? lastPage + 1 : undefined, + getPreviousPageParam: (firstPage) => + firstPage > -1 ? firstPage - 1 : undefined, + }, + client, + ) + + host.connect() + host.update() + + await waitFor(() => infinite().isSuccess) + expect(infinite().data?.pages).toEqual([0]) + + await infinite.fetchNextPage() + await waitFor(() => (infinite().data?.pages.length ?? 0) === 2) + expect(infinite().data?.pages).toEqual([0, 1]) + + await infinite.fetchPreviousPage() + await waitFor(() => (infinite().data?.pages.length ?? 0) === 3) + expect(infinite().data?.pages).toEqual([-1, 0, 1]) + }) + + it('does not request another update when stable function options refresh during host update', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let callCount = 0 + + const infinite = createInfiniteQueryController( + host, + () => ({ + queryKey: ['infinite-controller', 'stable-function-options'], + initialPageParam: 0, + queryFn: async ({ pageParam }) => { + callCount += 1 + return Number(pageParam) + }, + getNextPageParam: () => undefined, + staleTime: Infinity, + }), + client, + ) + + try { + host.connect() + host.update() + + await waitFor(() => infinite().isSuccess) + + host.updatesRequested = 0 + + for (let i = 0; i < 5; i += 1) { + host.update() + await Promise.resolve() + } + + expect(host.updatesRequested).toBe(0) + expect(infinite().data?.pages).toEqual([0]) + expect(callCount).toBe(1) + } finally { + infinite.destroy() + } + }) + + it('does not request an update for refetch-only state changes when only data was read', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let resolveRefetch: (() => void) | undefined + + const infinite = createInfiniteQueryController( + host, + { + queryKey: ['infinite-controller', 'tracked-data-only'], + initialPageParam: 0, + initialData: { + pages: ['stable-page'], + pageParams: [0], + }, + staleTime: Infinity, + queryFn: () => + new Promise((resolve) => { + resolveRefetch = () => resolve('stable-page') + }), + getNextPageParam: () => undefined, + }, + client, + ) + + try { + host.connect() + host.update() + + expect(infinite().data?.pages).toEqual(['stable-page']) + + await Promise.resolve() + await Promise.resolve() + + host.updatesRequested = 0 + + const refetch = infinite.refetch() + + await waitFor(() => resolveRefetch !== undefined) + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + + resolveRefetch!() + await refetch + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + } finally { + infinite.destroy() + } + }) + + it('refreshes a suppressed result on the next accessor read when a newly read property changed', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const queryKey = ['infinite-controller', 'late-read-freshness'] as const + const host = new TestControllerHost() + + const infinite = createInfiniteQueryController( + host, + { + queryKey, + initialPageParam: 0, + initialData: { + pages: ['initial-page'], + pageParams: [0], + }, + staleTime: Infinity, + queryFn: async () => 'unused', + getNextPageParam: () => undefined, + }, + client, + ) + + try { + host.connect() + host.update() + + expect(infinite().status).toBe('success') + + await Promise.resolve() + await Promise.resolve() + + host.updatesRequested = 0 + + client.setQueryData(queryKey, { + pages: ['updated-page'], + pageParams: [0], + }) + await Promise.resolve() + + expect(infinite().data?.pages).toEqual(['updated-page']) + expect(host.updatesRequested).toBe(0) + } finally { + infinite.destroy() + } + }) + + it('INFEDGE-01: next-page failure preserves prior pages consistently', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + const infinite = createInfiniteQueryController( + host, + { + queryKey: ['infedge-01'], + initialPageParam: 0, + queryFn: async ({ pageParam }) => { + const page = Number(pageParam) + if (page === 1) { + throw new Error('next-page-failed') + } + return page + }, + getNextPageParam: (lastPage) => + lastPage < 1 ? lastPage + 1 : undefined, + }, + client, + ) + + host.connect() + host.update() + + await waitFor(() => infinite().isSuccess) + expect(infinite().data?.pages).toEqual([0]) + + const nextPageResult = await infinite.fetchNextPage() + expect(nextPageResult.isFetchNextPageError).toBe(true) + expect(nextPageResult.error).toBeInstanceOf(Error) + await waitFor(() => infinite().isFetchNextPageError) + expect(infinite().data?.pages).toEqual([0]) + }) + + it('LC-INF-03: missing provider fails deterministically and imperative methods align', async () => { + const consumer = document.createElement( + contextInfiniteTagName, + ) as ContextInfiniteHostElement + const placeholderResult = consumer.infinite() + + expect(placeholderResult.status).toBe('pending') + + document.body.append(consumer) + + expect(() => consumer.infinite()).not.toThrow() + await waitForMissingQueryClient(() => consumer.infinite()) + + await expect(consumer.infinite.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + await expect(consumer.infinite.fetchNextPage()).rejects.toThrow( + /No QueryClient available/, + ) + await expect(placeholderResult.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + await expect(placeholderResult.fetchNextPage()).rejects.toThrow( + /No QueryClient available/, + ) + await expect(placeholderResult.fetchPreviousPage()).rejects.toThrow( + /No QueryClient available/, + ) + + consumer.infinite.destroy() + consumer.remove() + await Promise.resolve() + }) + + it('ALREADYCONN-INF-01: infinite query controller on already-connected host with explicit client does not throw', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + client.setQueryData(['already-connected-infinite'], { + pages: [0], + pageParams: [0], + }) + + class AlreadyConnectedHost implements ReactiveControllerHost { + private readonly controllers = new Set() + private isConnected = true + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + if (this.isConnected) { + controller.hostConnected?.() + } + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + const host = new AlreadyConnectedHost() + const infinite = createInfiniteQueryController( + host, + { + queryKey: ['already-connected-infinite'], + initialPageParam: 0, + queryFn: async ({ pageParam }) => Number(pageParam), + getNextPageParam: (lastPage) => + lastPage < 1 ? lastPage + 1 : undefined, + staleTime: 30_000, + }, + client, + ) + + await Promise.resolve() + await Promise.resolve() + + expect(infinite().isSuccess).toBe(true) + expect(infinite().data?.pages).toEqual([0]) + + infinite.destroy() + }) + + it('LC-INF-04: explicit-client infinite accessors defer until host fields are initialized', () => { + const client = new QueryClient() + + class DeferredExplicitInfiniteHost implements ReactiveControllerHost { + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + readonly infinite = createInfiniteQueryController( + this, + () => ({ + queryKey: ['deferred-explicit-infinite', this.id] as const, + initialPageParam: 0, + queryFn: async ({ pageParam }) => Number(pageParam), + getNextPageParam: (lastPage) => + lastPage < 1 ? lastPage + 1 : undefined, + retry: false, + }), + client, + ) + + readonly firstRead = this.infinite() + readonly id = 'alpha' + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + expect(() => new DeferredExplicitInfiniteHost()).not.toThrow() + + const host = new DeferredExplicitInfiniteHost() + expect(host.infinite().status).toBe('pending') + + host.infinite.destroy() + }) +}) + +describe('options helpers integration', () => { + it('OPT-01: queryOptions integrates with createQueryController', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const host = new TestControllerHost() + + const query = createQueryController( + host, + queryOptions({ + queryKey: ['opt-01', 'query'] as const, + queryFn: async () => 'query-ok', + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + expect(query().data).toBe('query-ok') + }) + + it('OPT-01: mutationOptions integrates with createMutationController', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + + const mutation = createMutationController( + host, + mutationOptions({ + mutationFn: async (value: number) => value + 10, + }), + client, + ) + + host.connect() + host.update() + await expect(mutation.mutateAsync(5)).resolves.toBe(15) + expect(mutation().isSuccess).toBe(true) + }) + + it('OPT-01: infiniteQueryOptions integrates with createInfiniteQueryController', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const host = new TestControllerHost() + + const infinite = createInfiniteQueryController( + host, + infiniteQueryOptions({ + queryKey: ['opt-01', 'infinite'], + initialPageParam: 0, + queryFn: async ({ pageParam }) => Number(pageParam), + getNextPageParam: (lastPage) => + lastPage < 1 ? lastPage + 1 : undefined, + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => infinite().isSuccess) + expect(infinite().data?.pages).toEqual([0]) + }) +}) diff --git a/packages/lit-query/src/tests/mutation-controller.test.ts b/packages/lit-query/src/tests/mutation-controller.test.ts new file mode 100644 index 00000000000..e205ac05018 --- /dev/null +++ b/packages/lit-query/src/tests/mutation-controller.test.ts @@ -0,0 +1,472 @@ +import { describe, expect, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { QueryClientProvider } from '../QueryClientProvider.js' +import { createMutationController } from '../createMutationController.js' +import { + TestControllerHost, + TestElementHost, + waitFor, + waitForMissingQueryClient, +} from './testHost.js' + +const providerTagName = 'test-query-client-provider-mutation' +if (!customElements.get(providerTagName)) { + customElements.define(providerTagName, QueryClientProvider) +} + +let explicitMutationClient: QueryClient | undefined + +class ContextMutationHostElement extends TestElementHost { + readonly mutationKey = ['context-mutation'] as const + + readonly mutation = createMutationController( + this, + { + mutationKey: this.mutationKey, + mutationFn: async (value: number) => value + 1, + }, + explicitMutationClient, + ) +} + +const contextMutationTagName = 'test-context-mutation-host' +if (!customElements.get(contextMutationTagName)) { + customElements.define(contextMutationTagName, ContextMutationHostElement) +} + +describe('createMutationController', () => { + it('LC-MUT-01: first provider connection resolves from the pre-connect placeholder state', async () => { + const consumer = document.createElement( + contextMutationTagName, + ) as ContextMutationHostElement + + expect(consumer.mutation().isIdle).toBe(true) + expect(() => consumer.mutation.mutate(1)).toThrow( + /No QueryClient available/, + ) + await expect(consumer.mutation.mutateAsync(1)).rejects.toThrow( + /No QueryClient available/, + ) + + const client = new QueryClient() + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await Promise.resolve() + + await expect(consumer.mutation.mutateAsync(1)).resolves.toBe(2) + expect(consumer.mutation().isSuccess).toBe(true) + + consumer.mutation.destroy() + provider.remove() + await Promise.resolve() + }) + + it('LC-MUT-02: explicit client takes precedence over provider context', async () => { + const explicitClient = new QueryClient() + const providerClient = new QueryClient() + explicitMutationClient = explicitClient + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = providerClient + + const consumer = document.createElement( + contextMutationTagName, + ) as ContextMutationHostElement + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await Promise.resolve() + + await expect(consumer.mutation.mutateAsync(2)).resolves.toBe(3) + + expect( + explicitClient + .getMutationCache() + .findAll({ mutationKey: consumer.mutationKey }).length, + ).toBeGreaterThan(0) + expect( + providerClient + .getMutationCache() + .findAll({ mutationKey: consumer.mutationKey }).length, + ).toBe(0) + + consumer.mutation.destroy() + provider.remove() + explicitMutationClient = undefined + await Promise.resolve() + }) + + it('supports mutate and mutateAsync paths', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + + const mutation = createMutationController( + host, + { + mutationFn: async (value: number) => value + 1, + }, + client, + ) + + host.connect() + host.update() + + const result = await mutation.mutateAsync(1) + expect(result).toBe(2) + expect(mutation().isSuccess).toBe(true) + expect(mutation().data).toBe(2) + + mutation.mutate(2) + await waitFor(() => mutation().data === 3) + expect(mutation().isSuccess).toBe(true) + }) + + it('M9: mutation state transitions cover idle/pending/success/error', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + + const mutation = createMutationController( + host, + { + mutationFn: async (value: number) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + if (value < 0) { + throw new Error('negative-not-allowed') + } + return value + 1 + }, + }, + client, + ) + + host.connect() + host.update() + + expect(mutation().isIdle).toBe(true) + + const successPromise = mutation.mutateAsync(10) + await waitFor(() => mutation().isPending) + await expect(successPromise).resolves.toBe(11) + await waitFor(() => mutation().isSuccess) + expect(mutation().data).toBe(11) + + const errorPromise = mutation.mutateAsync(-1) + await waitFor(() => mutation().isPending) + await expect(errorPromise).rejects.toThrow('negative-not-allowed') + await waitFor(() => mutation().isError) + expect(mutation().error).toBeInstanceOf(Error) + }) + + it('M10: reset clears mutation state back to idle baseline', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + + const mutation = createMutationController( + host, + { + mutationFn: async () => { + throw new Error('reset-target') + }, + }, + client, + ) + + host.connect() + host.update() + + await expect(mutation.mutateAsync(undefined)).rejects.toThrow( + 'reset-target', + ) + await waitFor(() => mutation().isError) + expect(mutation().error).toBeInstanceOf(Error) + + mutation.reset() + expect(mutation().isIdle).toBe(true) + expect(mutation().isPaused).toBe(false) + expect(mutation().isError).toBe(false) + expect(mutation().error).toBeNull() + expect(mutation().data).toBeUndefined() + }) + + it('M11: mutate is non-throwing while mutateAsync rejects on error', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + + const mutation = createMutationController( + host, + { + mutationFn: async (value: number) => { + if (value < 0) { + throw new Error('negative-not-allowed') + } + + return value + 1 + }, + }, + client, + ) + + host.connect() + host.update() + + expect(() => mutation.mutate(-1)).not.toThrow() + await waitFor(() => mutation().isError) + expect(mutation().error).toBeInstanceOf(Error) + + await expect(mutation.mutateAsync(-1)).rejects.toThrow( + 'negative-not-allowed', + ) + }) + + it('M12: mutation callback order and call counts are deterministic', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + const callbackEvents: string[] = [] + + const mutation = createMutationController( + host, + { + mutationFn: async (value: number) => { + await new Promise((resolve) => setTimeout(resolve, 5)) + if (value < 0) { + throw new Error('callback-order-failure') + } + return value + 1 + }, + onSuccess: (_data, value) => { + callbackEvents.push(`success:${value}`) + }, + onError: (_error, value) => { + callbackEvents.push(`error:${value}`) + }, + onSettled: (_data, _error, value) => { + callbackEvents.push(`settled:${value}`) + }, + }, + client, + ) + + host.connect() + host.update() + + await expect(mutation.mutateAsync(1)).resolves.toBe(2) + await expect(mutation.mutateAsync(-1)).rejects.toThrow( + 'callback-order-failure', + ) + + expect(callbackEvents).toEqual([ + 'success:1', + 'settled:1', + 'error:-1', + 'settled:-1', + ]) + }) + + it('AREACT-02: refreshed mutation callbacks use latest closures', async () => { + const client = new QueryClient() + const host = new TestControllerHost() + const callbackEvents: string[] = [] + let version = 'v1' + + const mutation = createMutationController( + host, + () => ({ + mutationFn: async (value: number) => { + if (value < 0) { + throw new Error('freshness-failure') + } + return value + 1 + }, + onSuccess: () => { + callbackEvents.push(`success:${version}`) + }, + onError: () => { + callbackEvents.push(`error:${version}`) + }, + onSettled: () => { + callbackEvents.push(`settled:${version}`) + }, + }), + client, + ) + + host.connect() + host.update() + + await expect(mutation.mutateAsync(1)).resolves.toBe(2) + expect(callbackEvents.slice(0, 2)).toEqual(['success:v1', 'settled:v1']) + + version = 'v2' + host.update() + + await expect(mutation.mutateAsync(-1)).rejects.toThrow('freshness-failure') + expect(callbackEvents.slice(2)).toEqual(['error:v2', 'settled:v2']) + }) + + it('LC-MUT-03: missing provider becomes a deterministic missing-client state', async () => { + const consumer = document.createElement( + contextMutationTagName, + ) as ContextMutationHostElement + const placeholderResult = consumer.mutation() + + expect(placeholderResult.isIdle).toBe(true) + expect(placeholderResult.isPaused).toBe(false) + + document.body.append(consumer) + + expect(() => consumer.mutation()).not.toThrow() + await waitForMissingQueryClient(() => consumer.mutation()) + + expect(() => consumer.mutation.mutate(1)).toThrow( + /No QueryClient available/, + ) + await expect(consumer.mutation.mutateAsync(1)).rejects.toThrow( + /No QueryClient available/, + ) + await expect(placeholderResult.mutate(1)).rejects.toThrow( + /No QueryClient available/, + ) + + expect(() => consumer.mutation.reset()).not.toThrow() + expect(() => placeholderResult.reset()).not.toThrow() + + consumer.mutation.destroy() + consumer.remove() + await Promise.resolve() + }) + + it('LC-MUT-04: later valid provider adoption recovers without reconstruction', async () => { + const consumer = document.createElement( + contextMutationTagName, + ) as ContextMutationHostElement + + document.body.append(consumer) + await waitForMissingQueryClient(() => consumer.mutation()) + + const client = new QueryClient() + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await Promise.resolve() + + await expect(consumer.mutation.mutateAsync(1)).resolves.toBe(2) + expect(consumer.mutation().isSuccess).toBe(true) + + consumer.mutation.destroy() + provider.remove() + await Promise.resolve() + }) + + it('ALREADYCONN-MUT-01: mutation controller on already-connected host with explicit client does not throw', async () => { + // Regression test for SSR hydration scenario where controller is created + // during willUpdate on an already-connected host. + const client = new QueryClient() + + // Create a host that simulates Lit's behavior: addController calls + // hostConnected immediately if the host is already connected + class AlreadyConnectedHost { + private readonly controllers = new Set<{ + hostConnected?: () => void + }>() + private isConnected = true + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: { hostConnected?: () => void }): void { + this.controllers.add(controller) + if (this.isConnected) { + controller.hostConnected?.() + } + } + + removeController(controller: { hostConnected?: () => void }): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + const host = new AlreadyConnectedHost() + + // This should NOT throw even though hostConnected runs during construction + const mutation = createMutationController( + host, + { + mutationKey: ['already-connected-mutation-test'], + mutationFn: async (value: number) => value * 2, + }, + client, + ) + + // Wait for the deferred onConnected to complete + await Promise.resolve() + await Promise.resolve() + + // Mutation controller should work correctly + expect(mutation().isIdle).toBe(true) + await expect(mutation.mutateAsync(5)).resolves.toBe(10) + expect(mutation().isSuccess).toBe(true) + + mutation.destroy() + }) + + it('LC-MUT-05: explicit-client mutation accessors defer until host fields are initialized', () => { + const client = new QueryClient() + + class DeferredExplicitMutationHost implements ReactiveControllerHost { + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + readonly mutation = createMutationController( + this, + () => ({ + mutationKey: ['deferred-explicit-mutation', this.id] as const, + mutationFn: async (value: number) => value + this.offset, + }), + client, + ) + + readonly firstRead = this.mutation() + readonly id = 'alpha' + readonly offset = 1 + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + expect(() => new DeferredExplicitMutationHost()).not.toThrow() + + const host = new DeferredExplicitMutationHost() + expect(host.mutation().isIdle).toBe(true) + + host.mutation.destroy() + }) +}) diff --git a/packages/lit-query/src/tests/queries-controller.test.ts b/packages/lit-query/src/tests/queries-controller.test.ts new file mode 100644 index 00000000000..67f6c5456bb --- /dev/null +++ b/packages/lit-query/src/tests/queries-controller.test.ts @@ -0,0 +1,935 @@ +import { describe, expect, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { QueryClientProvider } from '../QueryClientProvider.js' +import { createQueriesController } from '../createQueriesController.js' +import { queryOptions } from '../queryOptions.js' +import { + TestControllerHost, + TestElementHost, + waitFor, + waitForMissingQueryClient, +} from './testHost.js' + +const providerTagName = 'test-query-client-provider-queries' +if (!customElements.get(providerTagName)) { + customElements.define(providerTagName, QueryClientProvider) +} + +let explicitQueriesClient: QueryClient | undefined + +class ContextQueriesHostElement extends TestElementHost { + readonly queryKeys = [ + ['context-queries', 'alpha'] as const, + ['context-queries', 'beta'] as const, + ] + + readonly queries = createQueriesController( + this, + { + queries: this.queryKeys.map((queryKey) => ({ + queryKey, + queryFn: async () => queryKey[1], + retry: false, + })), + combine: (results) => + results.map((result) => ({ + status: result.status, + data: result.data, + })), + }, + explicitQueriesClient, + ) +} + +const contextQueriesTagName = 'test-context-queries-host' +if (!customElements.get(contextQueriesTagName)) { + customElements.define(contextQueriesTagName, ContextQueriesHostElement) +} + +class RawContextQueriesHostElement extends TestElementHost { + readonly queryKeys = [ + ['raw-context-queries', 'alpha'] as const, + ['raw-context-queries', 'beta'] as const, + ] + + readonly queries = createQueriesController(this, { + queries: this.queryKeys.map((queryKey) => ({ + queryKey, + queryFn: async () => queryKey[1], + retry: false, + })), + }) +} + +const rawContextQueriesTagName = 'test-raw-context-queries-host' +if (!customElements.get(rawContextQueriesTagName)) { + customElements.define(rawContextQueriesTagName, RawContextQueriesHostElement) +} + +class DeferredFieldsQueriesHost implements ReactiveControllerHost { + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + readonly queries = createQueriesController(this, () => ({ + queries: this.ids.map((id) => ({ + queryKey: ['deferred-fields-queries', id] as const, + queryFn: async () => id, + retry: false, + })), + combine: (results) => results.map((result) => result.status), + })) + + readonly firstRead = this.queries() + readonly ids = ['alpha', 'beta'] as const + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } +} + +describe('createQueriesController', () => { + it('LC-QUERIES-01: first provider connection resolves from the pre-connect placeholder state', async () => { + const consumer = document.createElement( + contextQueriesTagName, + ) as ContextQueriesHostElement + + expect(consumer.queries()).toEqual([ + { status: 'pending', data: undefined }, + { status: 'pending', data: undefined }, + ]) + + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor( + () => + consumer.queries()[0]?.status === 'success' && + consumer.queries()[1]?.status === 'success', + ) + expect(consumer.queries().map((item) => item.data)).toEqual([ + 'alpha', + 'beta', + ]) + + consumer.queries.destroy() + provider.remove() + await Promise.resolve() + }) + + it('LC-QUERIES-02: explicit client takes precedence over provider context', async () => { + const explicitClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const providerClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + explicitQueriesClient = explicitClient + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = providerClient + + const consumer = document.createElement( + contextQueriesTagName, + ) as ContextQueriesHostElement + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor( + () => + consumer.queries()[0]?.status === 'success' && + consumer.queries()[1]?.status === 'success', + ) + + expect( + explicitClient.getQueryCache().find({ queryKey: consumer.queryKeys[0]! }), + ).toBeDefined() + expect( + providerClient.getQueryCache().find({ queryKey: consumer.queryKeys[0]! }), + ).toBeUndefined() + + consumer.queries.destroy() + provider.remove() + explicitQueriesClient = undefined + await Promise.resolve() + }) + + it('combines multiple query results', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + + const queries = createQueriesController( + host, + { + queries: [ + { + queryKey: ['q1'], + queryFn: async () => 'alpha', + }, + { + queryKey: ['q2'], + queryFn: async () => 'beta', + }, + ] as const, + combine: (results) => results.map((result) => result.data), + }, + client, + ) + + host.connect() + host.update() + + await waitFor(() => queries().every((value) => typeof value === 'string')) + expect(queries()).toEqual(['alpha', 'beta']) + }) + + it('does not request another update when stable function query options refresh during host update', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let callCount = 0 + + const queries = createQueriesController( + host, + () => ({ + queries: [ + { + queryKey: [ + 'queries-controller', + 'stable-function-options', + ] as const, + queryFn: async () => { + callCount += 1 + return 'stable-result' + }, + staleTime: Infinity, + }, + ] as const, + }), + client, + ) + + try { + host.connect() + host.update() + + await waitFor(() => queries()[0]?.isSuccess === true) + + host.updatesRequested = 0 + + for (let i = 0; i < 5; i += 1) { + host.update() + await Promise.resolve() + } + + expect(host.updatesRequested).toBe(0) + expect(queries()[0]?.data).toBe('stable-result') + expect(callCount).toBe(1) + } finally { + queries.destroy() + } + }) + + it('does not request an update for refetch-only state changes when data was read and result refetch is invoked', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const queryKey = ['queries-controller', 'tracked-data-only'] as const + const host = new TestControllerHost() + let resolveRefetch: (() => void) | undefined + + const queries = createQueriesController( + host, + { + queries: [ + { + queryKey, + initialData: 'stable-data', + staleTime: Infinity, + queryFn: () => + new Promise((resolve) => { + resolveRefetch = () => resolve('stable-data') + }), + }, + ] as const, + }, + client, + ) + + try { + host.connect() + host.update() + + expect(queries()[0]?.data).toBe('stable-data') + + await Promise.resolve() + await Promise.resolve() + + host.updatesRequested = 0 + + const refetch = queries()[0]!.refetch() + + await waitFor(() => resolveRefetch !== undefined) + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + + resolveRefetch!() + await refetch + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + } finally { + queries.destroy() + } + }) + + it('does not re-default query options when subscribed queries emit', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const originalDefaultQueryOptions = client.defaultQueryOptions + let defaultQueryOptionsCalls = 0 + client.defaultQueryOptions = ((options) => { + defaultQueryOptionsCalls += 1 + return originalDefaultQueryOptions.call(client, options as never) + }) as typeof client.defaultQueryOptions + + const queryKey = ['queries-controller', 'per-emit-defaulting'] as const + const host = new TestControllerHost() + let resolveRefetch: (() => void) | undefined + + const queries = createQueriesController( + host, + { + queries: [ + { + queryKey, + initialData: 'stable-data', + staleTime: Infinity, + queryFn: () => + new Promise((resolve) => { + resolveRefetch = () => resolve('stable-data') + }), + }, + ] as const, + }, + client, + ) + + try { + host.connect() + host.update() + + expect(queries()[0]?.isFetching).toBe(false) + + await Promise.resolve() + await Promise.resolve() + + defaultQueryOptionsCalls = 0 + + const refetch = queries()[0]!.refetch() + + await waitFor(() => resolveRefetch !== undefined) + await Promise.resolve() + + expect(queries()[0]?.isFetching).toBe(true) + expect(defaultQueryOptionsCalls).toBe(0) + + resolveRefetch!() + await refetch + await Promise.resolve() + + expect(queries()[0]?.isFetching).toBe(false) + expect(defaultQueryOptionsCalls).toBe(0) + } finally { + queries.destroy() + } + }) + + it('refreshes suppressed query results on the next accessor read when a newly read property changed', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const queryKey = ['queries-controller', 'late-read-freshness'] as const + const host = new TestControllerHost() + + const queries = createQueriesController( + host, + { + queries: [ + { + queryKey, + initialData: 'initial-data', + staleTime: Infinity, + queryFn: async () => 'unused', + }, + ] as const, + }, + client, + ) + + try { + host.connect() + host.update() + + expect(queries()[0]?.status).toBe('success') + + await Promise.resolve() + await Promise.resolve() + + host.updatesRequested = 0 + + client.setQueryData(queryKey, 'updated-data') + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + + expect(queries()[0]?.data).toBe('updated-data') + expect(host.updatesRequested).toBe(0) + } finally { + queries.destroy() + } + }) + + it('M13: supports dynamic add/remove and keeps partial failure stability', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let includeThird = false + let includeFailing = true + + const queries = createQueriesController( + host, + () => ({ + queries: [ + { + queryKey: ['m13', 'alpha'] as const, + queryFn: async () => 'alpha', + }, + ...(includeFailing + ? [ + { + queryKey: ['m13', 'failing'] as const, + queryFn: async () => { + throw new Error('m13-fail') + }, + }, + ] + : []), + ...(includeThird + ? [ + { + queryKey: ['m13', 'gamma'] as const, + queryFn: async () => 'gamma', + }, + ] + : []), + ] as const, + combine: (results) => + results.map((result) => ({ + status: result.status, + data: result.data, + error: result.error instanceof Error ? result.error.message : null, + })), + }), + client, + ) + + host.connect() + host.update() + + await waitFor(() => queries().length === 2) + await waitFor( + () => + queries()[0]?.status === 'success' && queries()[1]?.status === 'error', + ) + expect(queries()[0]).toMatchObject({ status: 'success', data: 'alpha' }) + expect(queries()[1]).toMatchObject({ status: 'error', error: 'm13-fail' }) + + includeThird = true + host.update() + await waitFor(() => queries().length === 3) + await waitFor( + () => + queries()[0]?.status === 'success' && + queries()[1]?.status === 'error' && + queries()[2]?.status === 'success', + ) + expect(queries()[2]).toMatchObject({ status: 'success', data: 'gamma' }) + + includeFailing = false + host.update() + await waitFor(() => queries().length === 2) + await waitFor( + () => + queries()[0]?.status === 'success' && + queries()[1]?.status === 'success', + ) + expect(queries().map((item) => item.data)).toEqual(['alpha', 'gamma']) + }) + + it('CQS-ADV-01: reordering queries preserves documented result order mapping', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let order: Array<'first' | 'second'> = ['first', 'second'] + + const queries = createQueriesController( + host, + () => ({ + queries: order.map((id) => ({ + queryKey: ['cqs-adv-01', id] as const, + queryFn: async () => id, + })), + combine: (results) => results.map((result) => result.data), + }), + client, + ) + + host.connect() + host.update() + + await waitFor(() => queries().every((value) => typeof value === 'string')) + expect(queries()).toEqual(['first', 'second']) + + order = ['second', 'first'] + host.update() + await waitFor(() => queries()[0] === 'second' && queries()[1] === 'first') + expect(queries()).toEqual(['second', 'first']) + }) + + it('CQS-ADV-02: duplicate query keys return stable per-index results by contract', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let callCount = 0 + + const queries = createQueriesController( + host, + { + queries: [ + { + queryKey: ['dup-key'] as const, + queryFn: async () => { + callCount += 1 + return 'shared-value' + }, + }, + { + queryKey: ['dup-key'] as const, + queryFn: async () => { + callCount += 1 + return 'shared-value' + }, + }, + ] as const, + combine: (results) => + results.map((result) => ({ + status: result.status, + data: result.data, + })), + }, + client, + ) + + host.connect() + host.update() + + await waitFor( + () => + queries().length === 2 && + queries()[0]?.status === 'success' && + queries()[1]?.status === 'success', + ) + expect(queries()[0]?.data).toBe('shared-value') + expect(queries()[1]?.data).toBe('shared-value') + expect(callCount).toBeGreaterThan(0) + }) + + it('LC-QUERIES-03: missing provider fails after handshake and later provider adoption recovers', async () => { + const consumer = document.createElement( + contextQueriesTagName, + ) as ContextQueriesHostElement + + expect(consumer.queries()).toEqual([ + { status: 'pending', data: undefined }, + { status: 'pending', data: undefined }, + ]) + + document.body.append(consumer) + + expect(() => consumer.queries()).not.toThrow() + await waitForMissingQueryClient(() => consumer.queries()) + + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor( + () => + consumer.queries()[0]?.status === 'success' && + consumer.queries()[1]?.status === 'success', + ) + expect(consumer.queries().map((item) => item.data)).toEqual([ + 'alpha', + 'beta', + ]) + + consumer.queries.destroy() + provider.remove() + await Promise.resolve() + }) + + it('LC-QUERIES-04: raw query results reject placeholder refetch after missing-client handshake', async () => { + const consumer = document.createElement( + rawContextQueriesTagName, + ) as RawContextQueriesHostElement + + const firstQuery = consumer.queries()[0] + expect(firstQuery?.status).toBe('pending') + + document.body.append(consumer) + + await waitForMissingQueryClient(() => consumer.queries()) + await expect(firstQuery?.refetch()).rejects.toThrow( + 'No QueryClient available. Pass one explicitly or render within QueryClientProvider.', + ) + + consumer.queries.destroy() + consumer.remove() + await Promise.resolve() + }) + + it('LC-QUERIES-05: constructor defers placeholder accessors until host fields are initialized', () => { + expect(() => new DeferredFieldsQueriesHost()).not.toThrow() + + const host = new DeferredFieldsQueriesHost() + expect(host.queries()).toEqual(['pending', 'pending']) + }) + + it('LC-QUERIES-06: placeholder combine materializes defined initialData before a client is available', () => { + const host = new TestControllerHost() + const queries = createQueriesController(host, { + queries: [ + queryOptions({ + queryKey: ['placeholder-initial-data'] as const, + queryFn: async () => ({ id: 4, name: 'Marie' }), + initialData: { id: 0, name: 'Seed' }, + }), + ] as const, + combine: (result) => result[0].data.name, + }) + + expect(queries()).toBe('Seed') + + queries.destroy() + }) + + it('LC-QUERIES-07: explicit-client constructor defers dynamic accessors until host fields are initialized', () => { + const client = new QueryClient() + + class DeferredExplicitQueriesHost implements ReactiveControllerHost { + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + readonly queries = createQueriesController( + this, + () => ({ + queries: this.ids.map((id) => ({ + queryKey: ['deferred-explicit-queries', id] as const, + queryFn: async () => id, + retry: false, + })), + combine: (results) => results.map((result) => result.status), + }), + client, + ) + + readonly firstRead = this.queries() + readonly ids = ['alpha', 'beta'] as const + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + expect(() => new DeferredExplicitQueriesHost()).not.toThrow() + + const host = new DeferredExplicitQueriesHost() + expect(host.queries()).toEqual(['pending', 'pending']) + + host.queries.destroy() + }) + + it('LC-QUERIES-08: explicit-client constructor defers static combine callbacks until host fields are initialized', () => { + const client = new QueryClient() + + class DeferredExplicitCombineQueriesHost implements ReactiveControllerHost { + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + readonly queries = createQueriesController( + this, + { + queries: [ + { + queryKey: ['deferred-explicit-combine-queries', 'alpha'] as const, + queryFn: async () => 'alpha', + retry: false, + }, + ] as const, + combine: (results) => + this.ids.map((id, index) => `${id}:${results[index]?.status}`), + }, + client, + ) + + readonly firstRead = this.queries() + readonly ids = ['alpha'] as const + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + expect(() => new DeferredExplicitCombineQueriesHost()).not.toThrow() + + const host = new DeferredExplicitCombineQueriesHost() + expect(host.queries()).toEqual(['alpha:pending']) + + host.queries.destroy() + }) + + it('LC-QUERIES-09: explicit-client constructor re-surfaces permanent static combine errors after initialization', async () => { + const client = new QueryClient() + + class InvalidExplicitCombineQueriesHost implements ReactiveControllerHost { + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + readonly queries = createQueriesController( + this, + { + queries: [ + { + queryKey: ['invalid-explicit-combine-queries', 'alpha'] as const, + queryFn: async () => 'alpha', + retry: false, + }, + ] as const, + combine: () => { + throw new Error('invalid combine') + }, + }, + client, + ) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + expect(() => new InvalidExplicitCombineQueriesHost()).not.toThrow() + + const host = new InvalidExplicitCombineQueriesHost() + await Promise.resolve() + + expect(() => host.queries()).toThrow('invalid combine') + }) + + it('ALREADYCONN-QUERIES-01: queries controller on already-connected host with explicit client does not throw', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + client.setQueryData(['already-connected-queries', 'alpha'], 'alpha') + client.setQueryData(['already-connected-queries', 'beta'], 'beta') + + class AlreadyConnectedHost implements ReactiveControllerHost { + private readonly controllers = new Set() + private isConnected = true + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + if (this.isConnected) { + controller.hostConnected?.() + } + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + const host = new AlreadyConnectedHost() + const queries = createQueriesController( + host, + { + queries: [ + { + queryKey: ['already-connected-queries', 'alpha'] as const, + queryFn: async () => 'fetched-alpha', + staleTime: 30_000, + }, + { + queryKey: ['already-connected-queries', 'beta'] as const, + queryFn: async () => 'fetched-beta', + staleTime: 30_000, + }, + ] as const, + combine: (results) => + results.map((result) => ({ + status: result.status, + data: result.data, + })), + }, + client, + ) + + await Promise.resolve() + await Promise.resolve() + + expect(queries()).toEqual([ + { status: 'success', data: 'alpha' }, + { status: 'success', data: 'beta' }, + ]) + + queries.destroy() + }) +}) diff --git a/packages/lit-query/src/tests/query-controller.test.ts b/packages/lit-query/src/tests/query-controller.test.ts new file mode 100644 index 00000000000..d8261e585c9 --- /dev/null +++ b/packages/lit-query/src/tests/query-controller.test.ts @@ -0,0 +1,1515 @@ +import { describe, expect, it } from 'vitest' +import { keepPreviousData, QueryClient } from '@tanstack/query-core' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { QueryClientProvider } from '../QueryClientProvider.js' +import { createQueryController } from '../createQueryController.js' +import { + TestControllerHost, + waitFor, + waitForMissingQueryClient, +} from './testHost.js' + +const providerTagName = 'test-query-client-provider-query' +if (!customElements.get(providerTagName)) { + customElements.define(providerTagName, QueryClientProvider) +} + +class QueryConsumerHostElement + extends HTMLElement + implements ReactiveControllerHost +{ + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + readonly queryKey = ['query-controller', 'provider-switch'] as const + queryCalls = 0 + + readonly query = createQueryController(this, () => ({ + queryKey: this.queryKey, + queryFn: async () => { + this.queryCalls += 1 + return `value-${this.queryCalls}` + }, + retry: false, + })) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + + connectedCallback(): void { + for (const controller of this.controllers) { + controller.hostConnected?.() + } + } + + disconnectedCallback(): void { + for (const controller of this.controllers) { + controller.hostDisconnected?.() + } + } +} + +const consumerTagName = 'test-query-consumer-host' +if (!customElements.get(consumerTagName)) { + customElements.define(consumerTagName, QueryConsumerHostElement) +} + +describe('createQueryController', () => { + it('M1: does not request update after destroy when microtask flushes', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + const query = createQueryController( + host, + { + queryKey: ['query-controller', 'm1'], + queryFn: async () => 'done', + }, + client, + ) + + host.connect() + query.destroy() + + await Promise.resolve() + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + }) + + it('M2: returns observer count to baseline after 100 lifecycle cycles', async () => { + const queryKey = ['query-controller', 'm2'] as const + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + for (let cycle = 0; cycle < 100; cycle += 1) { + const host = new TestControllerHost() + const query = createQueryController( + host, + { + queryKey, + queryFn: async () => cycle, + }, + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + + const cacheQuery = client.getQueryCache().find({ queryKey }) + expect(cacheQuery?.getObserversCount()).toBe(1) + + host.disconnect() + query.destroy() + expect(cacheQuery?.getObserversCount() ?? 0).toBe(0) + } + }) + + it('fetches and updates query state', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let callCount = 0 + + const query = createQueryController( + host, + { + queryKey: ['user', 'fetches-and-updates'], + queryFn: async () => { + callCount += 1 + return { id: 1, name: 'Ada' } + }, + }, + client, + ) + + host.connect() + host.update() + + await waitFor(() => query().isSuccess) + + expect(query().data).toEqual({ id: 1, name: 'Ada' }) + expect(callCount).toBe(1) + expect(host.updatesRequested).toBeGreaterThan(0) + }) + + it('does not request another update when stable function options refresh during host update', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let callCount = 0 + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 'stable-function-options'], + queryFn: async () => { + callCount += 1 + return 'stable-result' + }, + staleTime: Infinity, + }), + client, + ) + + try { + host.connect() + host.update() + + await waitFor(() => query().isSuccess) + + host.updatesRequested = 0 + + for (let i = 0; i < 5; i += 1) { + host.update() + await Promise.resolve() + } + + expect(host.updatesRequested).toBe(0) + expect(query().data).toBe('stable-result') + expect(callCount).toBe(1) + } finally { + query.destroy() + } + }) + + it('does not request an update for refetch-only state changes when only data was read', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const queryKey = ['query-controller', 'tracked-data-only'] as const + const host = new TestControllerHost() + let resolveRefetch: (() => void) | undefined + + const query = createQueryController( + host, + { + queryKey, + initialData: 'stable-data', + staleTime: Infinity, + queryFn: () => + new Promise((resolve) => { + resolveRefetch = () => resolve('stable-data') + }), + }, + client, + ) + + try { + host.connect() + host.update() + + expect(query().data).toBe('stable-data') + + await Promise.resolve() + await Promise.resolve() + + host.updatesRequested = 0 + + const refetch = query.refetch() + + await waitFor(() => resolveRefetch !== undefined) + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + + resolveRefetch!() + await refetch + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + } finally { + query.destroy() + } + }) + + it('refreshes a suppressed result on the next accessor read when a newly read property changed', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const queryKey = ['query-controller', 'late-read-freshness'] as const + const host = new TestControllerHost() + + const query = createQueryController( + host, + { + queryKey, + initialData: 'initial-data', + staleTime: Infinity, + queryFn: async () => 'unused', + }, + client, + ) + + try { + host.connect() + host.update() + + expect(query().status).toBe('success') + + await Promise.resolve() + await Promise.resolve() + + host.updatesRequested = 0 + + client.setQueryData(queryKey, 'updated-data') + await Promise.resolve() + + expect(host.updatesRequested).toBe(0) + + expect(query().data).toBe('updated-data') + expect(host.updatesRequested).toBe(0) + } finally { + query.destroy() + } + }) + + it('M4: transitions from pending to success with expected contract', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + const query = createQueryController( + host, + { + queryKey: ['query-controller', 'm4'], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return 'ok' + }, + }, + client, + ) + + expect(query().status).toBe('pending') + expect(query().isSuccess).toBe(false) + + host.connect() + host.update() + + await waitFor(() => query().isSuccess) + expect(query().status).toBe('success') + expect(query().data).toBe('ok') + }) + + it('M6: does not fetch when enabled=false and fetches after enabling', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let callCount = 0 + let enabled = false + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 'm6'], + enabled, + queryFn: async () => { + callCount += 1 + return 'enabled-result' + }, + }), + client, + ) + + host.connect() + host.update() + + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(callCount).toBe(0) + expect(query().isSuccess).toBe(false) + + enabled = true + host.update() + await waitFor(() => query().isSuccess) + + expect(callCount).toBe(1) + expect(query().data).toBe('enabled-result') + }) + + it('M7: remount with gcTime=0 has no observer leak and refetches', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const queryKey = ['query-controller', 'm7'] as const + let callCount = 0 + + const firstHost = new TestControllerHost() + const firstQuery = createQueryController( + firstHost, + { + queryKey, + gcTime: 0, + queryFn: async () => { + callCount += 1 + return `value-${callCount}` + }, + }, + client, + ) + + firstHost.connect() + firstHost.update() + await waitFor(() => firstQuery().isSuccess) + + const firstCacheEntry = client.getQueryCache().find({ queryKey }) + expect(firstCacheEntry?.getObserversCount()).toBe(1) + expect(callCount).toBe(1) + + firstHost.disconnect() + firstQuery.destroy() + + await waitFor(() => { + // With gcTime:0, cache entry may be immediately removed after last observer unmounts. + const entry = client.getQueryCache().find({ queryKey }) + return !entry || entry.getObserversCount() === 0 + }) + + const secondHost = new TestControllerHost() + const secondQuery = createQueryController( + secondHost, + { + queryKey, + gcTime: 0, + queryFn: async () => { + callCount += 1 + return `value-${callCount}` + }, + }, + client, + ) + + secondHost.connect() + secondHost.update() + await waitFor(() => secondQuery().isSuccess) + await waitFor(() => secondQuery().data === 'value-2') + + const secondCacheEntry = client.getQueryCache().find({ queryKey }) + expect(secondCacheEntry?.getObserversCount()).toBe(1) + expect(callCount).toBe(2) + expect(secondQuery().data).toBe('value-2') + }) + + it('M8: applies latest accessor key/options on updates and refetch', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let keyId = 1 + const seenKeys: number[] = [] + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', keyId] as const, + queryFn: async ({ queryKey }) => { + const id = queryKey[1] as number + seenKeys.push(id) + return `user-${id}` + }, + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + expect(query().data).toBe('user-1') + + keyId = 2 + host.update() + await waitFor(() => query().isSuccess && query().data === 'user-2') + + await query.refetch() + expect(query().data).toBe('user-2') + expect(seenKeys.includes(1)).toBe(true) + expect(seenKeys.includes(2)).toBe(true) + }) + + it('does not request a host update when function options resolve to an unchanged result', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let callCount = 0 + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 'stable-function-options'] as const, + staleTime: Infinity, + queryFn: async () => { + callCount += 1 + return 'stable' + }, + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + + await Promise.resolve() + await Promise.resolve() + const updatesAfterSuccess = host.updatesRequested + + host.update() + await Promise.resolve() + await Promise.resolve() + + expect(query().data).toBe('stable') + expect(callCount).toBe(1) + expect(host.updatesRequested).toBe(updatesAfterSuccess) + }) + + it('QSEM-01: refetchOnMount follows stale-vs-fresh policy', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + let staleCalls = 0 + const staleKey = ['query-controller', 'qsem-01', 'stale'] as const + + const staleHostA = new TestControllerHost() + const staleQueryA = createQueryController( + staleHostA, + { + queryKey: staleKey, + staleTime: 0, + refetchOnMount: true, + queryFn: async () => { + staleCalls += 1 + return `stale-${staleCalls}` + }, + }, + client, + ) + + staleHostA.connect() + staleHostA.update() + await waitFor(() => staleQueryA().isSuccess) + expect(staleCalls).toBe(1) + staleHostA.disconnect() + staleQueryA.destroy() + + const staleHostB = new TestControllerHost() + const staleQueryB = createQueryController( + staleHostB, + { + queryKey: staleKey, + staleTime: 0, + refetchOnMount: true, + queryFn: async () => { + staleCalls += 1 + return `stale-${staleCalls}` + }, + }, + client, + ) + + staleHostB.connect() + staleHostB.update() + await waitFor(() => staleCalls >= 2) + expect(staleQueryB().isSuccess).toBe(true) + staleHostB.disconnect() + staleQueryB.destroy() + + let freshCalls = 0 + const freshKey = ['query-controller', 'qsem-01', 'fresh'] as const + + const freshHostA = new TestControllerHost() + const freshQueryA = createQueryController( + freshHostA, + { + queryKey: freshKey, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: true, + queryFn: async () => { + freshCalls += 1 + return `fresh-${freshCalls}` + }, + }, + client, + ) + + freshHostA.connect() + freshHostA.update() + await waitFor(() => freshQueryA().isSuccess) + expect(freshCalls).toBe(1) + freshHostA.disconnect() + freshQueryA.destroy() + + const freshHostB = new TestControllerHost() + const freshQueryB = createQueryController( + freshHostB, + { + queryKey: freshKey, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: true, + queryFn: async () => { + freshCalls += 1 + return `fresh-${freshCalls}` + }, + }, + client, + ) + + freshHostB.connect() + freshHostB.update() + await waitFor(() => freshQueryB().isSuccess) + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(freshCalls).toBe(1) + expect(freshQueryB().data).toBe('fresh-1') + }) + + it('QSEM-02: select transforms data and select-throw surfaces as error', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let shouldThrow = false + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 'qsem-02'], + queryFn: async () => ({ value: 2 }), + select: (payload: { value: number }) => { + if (shouldThrow) { + throw new Error('select-failed') + } + + return payload.value * 10 + }, + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + expect(query().data).toBe(20) + + shouldThrow = true + await query.refetch() + await waitFor(() => query().isError) + expect(query().error).toBeInstanceOf(Error) + expect((query().error as Error).message).toContain('select-failed') + }) + + it('S5: keepPreviousData preserves prior data during key transitions', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let keyId = 1 + let resolveSecond: ((value: string) => void) | undefined + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 's5', keyId] as const, + queryFn: async ({ queryKey }) => { + const id = queryKey[2] as number + if (id === 1) { + return 'value-1' + } + + return new Promise((resolve) => { + resolveSecond = resolve + }) + }, + placeholderData: keepPreviousData, + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess && query().data === 'value-1') + + keyId = 2 + host.update() + + await waitFor(() => query().isFetching) + await waitFor(() => query().isPlaceholderData) + expect(query().data).toBe('value-1') + + resolveSecond?.('value-2') + await waitFor(() => query().isSuccess && query().data === 'value-2') + expect(query().isPlaceholderData).toBe(false) + }) + + it('QSEM-03: invalidation triggers refetch and updates result state', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + const queryKey = ['query-controller', 'qsem-03'] as const + let callCount = 0 + + const query = createQueryController( + host, + { + queryKey, + queryFn: async () => { + callCount += 1 + return `v${callCount}` + }, + }, + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + expect(query().data).toBe('v1') + expect(callCount).toBe(1) + + void client.invalidateQueries({ queryKey }) + await waitFor(() => callCount >= 2) + await waitFor(() => query().data === 'v2') + expect(query().isSuccess).toBe(true) + }) + + it('CANCEL-02: stale older response does not overwrite newer key result', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let keyId = 'old' + let resolveOld: ((value: string) => void) | undefined + let resolveNew: ((value: string) => void) | undefined + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 'cancel-02', keyId] as const, + queryFn: async ({ queryKey }) => { + const id = queryKey[2] as string + return new Promise((resolve) => { + if (id === 'old') { + resolveOld = resolve + } else { + resolveNew = resolve + } + }) + }, + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => typeof resolveOld === 'function') + + keyId = 'new' + host.update() + await waitFor(() => typeof resolveNew === 'function') + + resolveNew?.('new-value') + await waitFor(() => query().data === 'new-value') + + resolveOld?.('old-value') + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(query().data).toBe('new-value') + expect(query().isSuccess).toBe(true) + }) + + it('CANCEL-01: queryFn receives AbortSignal and prior request is aborted on key switch', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let keyId: 'old' | 'new' = 'old' + let oldSignal: AbortSignal | undefined + let resolveOld: ((value: string) => void) | undefined + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 'cancel-01', keyId] as const, + queryFn: async ({ signal, queryKey }) => { + const id = queryKey[2] as 'old' | 'new' + if (id === 'old') { + oldSignal = signal + return new Promise((resolve) => { + resolveOld = resolve + signal.addEventListener('abort', () => resolve('old-aborted'), { + once: true, + }) + }) + } + + return 'new-success' + }, + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => typeof oldSignal !== 'undefined') + + expect(oldSignal).toBeInstanceOf(AbortSignal) + expect(oldSignal?.aborted).toBe(false) + + keyId = 'new' + host.update() + await waitFor(() => query().data === 'new-success') + + expect(oldSignal?.aborted).toBe(true) + resolveOld?.('old-late') + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(query().data).toBe('new-success') + }) + + it('S6: rapid key churn maintains stable final state without duplicate observers', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let keyId = 0 + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 's6', keyId] as const, + queryFn: async ({ queryKey }) => { + const id = queryKey[2] as number + await new Promise((resolve) => + setTimeout(resolve, Math.max(1, 20 - id)), + ) + return `result-${id}` + }, + }), + client, + ) + + host.connect() + host.update() + + for (let i = 1; i <= 20; i += 1) { + keyId = i + host.update() + } + + await waitFor(() => query().isSuccess && query().data === 'result-20') + + const latestCacheEntry = client + .getQueryCache() + .find({ queryKey: ['query-controller', 's6', 20] }) + expect(latestCacheEntry?.getObserversCount()).toBe(1) + }) + + it('LIFE-01: disconnect while in-flight does not process detached updates', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let resolveFetch: ((value: string) => void) | undefined + + const query = createQueryController( + host, + { + queryKey: ['query-controller', 'life-01'], + queryFn: () => + new Promise((resolve) => { + resolveFetch = resolve + }), + }, + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isFetching) + + host.disconnect() + await Promise.resolve() + const updatesAfterDisconnect = host.updatesRequested + + resolveFetch?.('late-value') + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(host.updatesRequested).toBe(updatesAfterDisconnect) + }) + + it('LIFE-02: reconnect after in-flight settle yields correct snapshot', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let resolveFetch: ((value: string) => void) | undefined + + const query = createQueryController( + host, + { + queryKey: ['query-controller', 'life-02'], + queryFn: () => + new Promise((resolve) => { + resolveFetch = resolve + }), + }, + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isFetching) + + host.disconnect() + resolveFetch?.('reconnected-value') + await new Promise((resolve) => setTimeout(resolve, 20)) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + expect(query().data).toBe('reconnected-value') + }) + + it('AREACT-01: latest select closure is used after host updates', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let multiplier = 1 + + const query = createQueryController( + host, + () => ({ + queryKey: ['query-controller', 'areact-01'], + queryFn: async () => 2, + select: (value: number) => value * multiplier, + }), + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + expect(query().data).toBe(2) + + multiplier = 3 + host.update() + await waitFor(() => query().data === 6) + + multiplier = 4 + host.update() + await query.refetch() + expect(query().data).toBe(8) + }) + + it('M3: switches provider client while connected with a single active observer', async () => { + const clientA = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const clientB = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = clientA + + const consumer = document.createElement( + consumerTagName, + ) as QueryConsumerHostElement + provider.append(consumer) + document.body.append(provider) + + await provider.updateComplete + await consumer.updateComplete + await waitFor(() => consumer.query().isSuccess && consumer.queryCalls >= 1) + + const oldCacheQueryBeforeSwitch = clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + expect(oldCacheQueryBeforeSwitch?.getObserversCount()).toBe(1) + + provider.client = clientB + await provider.updateComplete + + await waitFor(() => { + const newCacheQuery = clientB + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + return Boolean(newCacheQuery && newCacheQuery.getObserversCount() === 1) + }) + + const oldCacheQueryAfterSwitch = clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + const newCacheQueryAfterSwitch = clientB + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + + expect(oldCacheQueryAfterSwitch?.getObserversCount() ?? 0).toBe(0) + expect(newCacheQueryAfterSwitch?.getObserversCount()).toBe(1) + + void clientB.invalidateQueries({ queryKey: consumer.queryKey }) + await waitFor(() => consumer.queryCalls >= 2) + + consumer.query.destroy() + provider.remove() + await Promise.resolve() + }) + + it('M5: tracks retry failure metadata before eventual success', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + let attempts = 0 + + const query = createQueryController( + host, + { + queryKey: ['query-controller', 'm5'], + retry: 2, + retryDelay: 30, + queryFn: async () => { + attempts += 1 + await new Promise((resolve) => setTimeout(resolve, 5)) + if (attempts < 3) { + throw new Error(`attempt-${attempts}`) + } + return 'success' + }, + }, + client, + ) + + host.connect() + host.update() + + await waitFor(() => query().failureCount >= 1) + expect(query().failureReason).toBeInstanceOf(Error) + expect(query().isPending || query().isError).toBe(true) + + await waitFor(() => query().isSuccess) + expect(query().data).toBe('success') + expect(attempts).toBe(3) + }) + + it('is reconnect-idempotent without duplicate subscriptions', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const host = new TestControllerHost() + const queryKey = ['todos', 'reconnect-idempotent'] as const + + const query = createQueryController( + host, + { + queryKey, + queryFn: async () => ['a', 'b'], + }, + client, + ) + + host.connect() + host.update() + await waitFor(() => query().isSuccess) + + const cacheQuery = client.getQueryCache().find({ queryKey }) + expect(cacheQuery).toBeDefined() + expect(cacheQuery?.getObserversCount()).toBe(1) + + host.disconnect() + expect(cacheQuery?.getObserversCount()).toBe(0) + + host.connect() + host.update() + await waitFor(() => cacheQuery?.getObserversCount() === 1) + + host.connect() + host.update() + + expect(cacheQuery?.getObserversCount()).toBe(1) + }) + + it('M17: no-explicit-client constructor path is safe before provider resolution', async () => { + const consumer = document.createElement( + consumerTagName, + ) as QueryConsumerHostElement + + expect(consumer.query().status).toBe('pending') + + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + provider.append(consumer) + + document.body.append(provider) + await provider.updateComplete + await consumer.updateComplete + + await waitFor(() => consumer.query().isSuccess) + expect(consumer.query().data).toBeDefined() + expect(consumer.queryCalls).toBeGreaterThan(0) + + consumer.query.destroy() + provider.remove() + await Promise.resolve() + }) + + it('LC-QUERY-01: provider-backed first connection does not spuriously throw during handshake', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + + const consumer = document.createElement( + consumerTagName, + ) as QueryConsumerHostElement + provider.append(consumer) + + expect(() => consumer.query()).not.toThrow() + + document.body.append(provider) + + expect(() => consumer.query()).not.toThrow() + + await provider.updateComplete + await consumer.updateComplete + await waitFor(() => consumer.query().isSuccess) + + expect(consumer.query().data).toBeDefined() + + consumer.query.destroy() + provider.remove() + await Promise.resolve() + }) + + it('throws after the initial placeholder phase when no provider is available', async () => { + const consumer = document.createElement( + consumerTagName, + ) as QueryConsumerHostElement + + expect(consumer.query().status).toBe('pending') + + document.body.append(consumer) + + expect(() => consumer.query()).not.toThrow() + await waitForMissingQueryClient(() => consumer.query()) + + await expect(consumer.query.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + await expect(consumer.query.suspense()).rejects.toThrow( + /No QueryClient available/, + ) + + consumer.query.destroy() + consumer.remove() + await Promise.resolve() + }) + + it('LC-QUERY-03: wrapper and result-object imperative methods share the missing-client contract', async () => { + const consumer = document.createElement( + consumerTagName, + ) as QueryConsumerHostElement + const placeholderResult = consumer.query() + + document.body.append(consumer) + await waitForMissingQueryClient(() => consumer.query()) + + await expect(consumer.query.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + await expect(placeholderResult.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + + consumer.query.destroy() + consumer.remove() + await Promise.resolve() + }) + + it('LC-QUERY-04: reconnect outside any provider clears stale provider-derived client state', async () => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const provider = document.createElement( + providerTagName, + ) as QueryClientProvider + provider.client = client + + const consumer = document.createElement( + consumerTagName, + ) as QueryConsumerHostElement + provider.append(consumer) + document.body.append(provider) + + await provider.updateComplete + await consumer.updateComplete + await waitFor(() => consumer.query().isSuccess) + + expect( + client + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount(), + ).toBe(1) + + provider.removeChild(consumer) + await waitFor( + () => + (client + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount() ?? 0) === 0, + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + consumer.connectedCallback() + + await expect(consumer.query.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + + consumer.query.destroy() + consumer.remove() + provider.remove() + await Promise.resolve() + }) + + it('LC-QUERY-05: reconnect under a different provider rebinds cleanly with later recovery', async () => { + const clientA = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const clientB = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const providerA = document.createElement( + providerTagName, + ) as QueryClientProvider + providerA.client = clientA + const providerB = document.createElement( + providerTagName, + ) as QueryClientProvider + providerB.client = clientB + + const consumer = document.createElement( + consumerTagName, + ) as QueryConsumerHostElement + providerA.append(consumer) + + document.body.append(providerA) + + await providerA.updateComplete + await consumer.updateComplete + await waitFor(() => consumer.query().isSuccess) + + providerA.removeChild(consumer) + await waitFor( + () => + (clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount() ?? 0) === 0, + ) + consumer.connectedCallback() + await expect(consumer.query.refetch()).rejects.toThrow( + /No QueryClient available/, + ) + + consumer.disconnectedCallback() + providerB.append(consumer) + document.body.append(providerB) + await providerB.updateComplete + await waitFor(() => consumer.query().isSuccess && consumer.queryCalls >= 2) + + expect( + clientA + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount() ?? 0, + ).toBe(0) + expect( + clientB + .getQueryCache() + .find({ queryKey: consumer.queryKey }) + ?.getObserversCount(), + ).toBe(1) + expect(consumer.query().data).toBe(`value-${consumer.queryCalls}`) + + consumer.query.destroy() + providerA.remove() + providerB.remove() + await Promise.resolve() + }) + + it('reuses hydrated data on an already-connected host without an eager refetch', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 30_000 } }, + }) + let queryFnCalls = 0 + + client.setQueryData(['already-connected-test'], 'hydrated-value') + + // Simulate Lit's synchronous hostConnected call on already-connected hosts. + class AlreadyConnectedHost implements ReactiveControllerHost { + private readonly controllers = new Set() + private isConnected = true + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + if (this.isConnected) { + controller.hostConnected?.() + } + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + const host = new AlreadyConnectedHost() + + const query = createQueryController( + host, + { + queryKey: ['already-connected-test'], + queryFn: async () => { + queryFnCalls += 1 + return 'fetched-value' + }, + staleTime: 30_000, + }, + client, + ) + + await Promise.resolve() + await Promise.resolve() + + expect(query().data).toBe('hydrated-value') + expect(query().isSuccess).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(queryFnCalls).toBe(0) + + await query.refetch() + await waitFor(() => query().data === 'fetched-value') + expect(queryFnCalls).toBe(1) + + query.destroy() + }) + + it('defers explicit-client query accessors until host fields are initialized', () => { + const client = new QueryClient() + + class DeferredExplicitQueryHost implements ReactiveControllerHost { + private readonly controllers = new Set() + + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + readonly query = createQueryController( + this, + () => ({ + queryKey: ['deferred-explicit-query', this.id] as const, + queryFn: async () => this.id, + retry: false, + }), + client, + ) + + readonly firstRead = this.query() + readonly id = 'alpha' + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + } + + expect(() => new DeferredExplicitQueryHost()).not.toThrow() + + const host = new DeferredExplicitQueryHost() + expect(host.query().status).toBe('pending') + + host.query.destroy() + }) +}) diff --git a/packages/lit-query/src/tests/testHost.ts b/packages/lit-query/src/tests/testHost.ts new file mode 100644 index 00000000000..186433fe467 --- /dev/null +++ b/packages/lit-query/src/tests/testHost.ts @@ -0,0 +1,117 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit' + +export class TestControllerHost implements ReactiveControllerHost { + private readonly controllers = new Set() + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + + connect(): void { + for (const controller of this.controllers) { + controller.hostConnected?.() + } + } + + disconnect(): void { + for (const controller of this.controllers) { + controller.hostDisconnected?.() + } + } + + update(): void { + for (const controller of this.controllers) { + controller.hostUpdate?.() + } + + for (const controller of this.controllers) { + controller.hostUpdated?.() + } + } +} + +export class TestElementHost + extends HTMLElement + implements ReactiveControllerHost +{ + protected readonly controllers = new Set() + updatesRequested = 0 + readonly updateComplete: Promise = Promise.resolve(true) + + addController(controller: ReactiveController): void { + this.controllers.add(controller) + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller) + } + + requestUpdate(): void { + this.updatesRequested += 1 + } + + connectedCallback(): void { + for (const controller of this.controllers) { + controller.hostConnected?.() + } + } + + disconnectedCallback(): void { + for (const controller of this.controllers) { + controller.hostDisconnected?.() + } + } + + flushHostUpdate(): void { + for (const controller of this.controllers) { + controller.hostUpdate?.() + } + + for (const controller of this.controllers) { + controller.hostUpdated?.() + } + } +} + +export async function waitFor( + assertion: () => boolean, + timeoutMs = 2000, +): Promise { + const startedAt = Date.now() + while (!assertion()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out waiting for assertion after ${timeoutMs}ms`) + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +function isMissingQueryClientError(error: unknown): boolean { + return ( + error instanceof Error && /No QueryClient available/.test(error.message) + ) +} + +export async function waitForMissingQueryClient( + read: () => unknown, + timeoutMs = 2000, +): Promise { + await waitFor(() => { + try { + read() + return false + } catch (error) { + return isMissingQueryClientError(error) + } + }, timeoutMs) +} diff --git a/packages/lit-query/src/tests/type-inference.test.ts b/packages/lit-query/src/tests/type-inference.test.ts new file mode 100644 index 00000000000..e8c6c3c525d --- /dev/null +++ b/packages/lit-query/src/tests/type-inference.test.ts @@ -0,0 +1,210 @@ +import { + dataTagSymbol, + QueryClient, + type DefinedQueryObserverResult, + type QueryObserverResult, +} from '@tanstack/query-core' +import { describe, expectTypeOf, it } from 'vitest' +import { createMutationController } from '../createMutationController.js' +import { createQueriesController } from '../createQueriesController.js' +import { createInfiniteQueryController } from '../createInfiniteQueryController.js' +import { createQueryController } from '../createQueryController.js' +import { infiniteQueryOptions } from '../infiniteQueryOptions.js' +import { mutationOptions } from '../mutationOptions.js' +import { queryOptions } from '../queryOptions.js' +import { TestControllerHost } from './testHost.js' + +describe('type inference', () => { + it('L1: createQueriesController preserves tuple/combine inference', () => { + const client = new QueryClient() + const host = new TestControllerHost() + const expectTupleResult = ( + value: [QueryObserverResult, QueryObserverResult], + ) => value + const expectDefinedInitialDataTuple = ( + value: [DefinedQueryObserverResult<{ id: number; name: string }>], + ) => value + const expectMappedQueriesResult = ( + value: [ + ...Array>, + QueryObserverResult, + ], + ) => value + + const tupleResult = createQueriesController( + host, + { + queries: [ + { + queryKey: ['type-inference', 'tuple-number'] as const, + queryFn: async () => 1, + }, + { + queryKey: ['type-inference', 'tuple-string'] as const, + queryFn: async () => 'x', + }, + ] as const, + }, + client, + ) + + const tupleData = expectTupleResult(tupleResult()) + expectTypeOf(tupleData[0].data).toEqualTypeOf() + expectTypeOf(tupleData[1].data).toEqualTypeOf() + + const combinedResult = createQueriesController( + host, + { + queries: [ + { + queryKey: ['type-inference', 'combined-number'] as const, + queryFn: async () => 7, + }, + { + queryKey: ['type-inference', 'combined-string'] as const, + queryFn: async () => 'ok', + }, + ] as const, + combine: (result) => ({ + first: result[0].data, + second: result[1].data, + }), + }, + client, + ) + + expectTypeOf(combinedResult().first).toEqualTypeOf() + expectTypeOf(combinedResult().second).toEqualTypeOf() + + const definedInitialDataResult = createQueriesController( + host, + { + queries: [ + queryOptions({ + queryKey: ['type-inference', 'defined-initial-data'] as const, + queryFn: async () => ({ id: 4, name: 'Marie' }), + initialData: { id: 0, name: 'Seed' }, + }), + ] as const, + }, + client, + ) + + const definedInitialDataTuple = expectDefinedInitialDataTuple( + definedInitialDataResult(), + ) + expectTypeOf(definedInitialDataTuple[0].data).toEqualTypeOf<{ + id: number + name: string + }>() + + const definedInitialDataCombined = createQueriesController( + host, + { + queries: [ + queryOptions({ + queryKey: [ + 'type-inference', + 'defined-initial-data-combine', + ] as const, + queryFn: async () => ({ id: 5, name: 'Katherine' }), + initialData: { id: 1, name: 'Init' }, + }), + ] as const, + combine: (result) => result[0].data.name, + }, + client, + ) + + const definedInitialDataCombinedValue: string = definedInitialDataCombined() + expectTypeOf(definedInitialDataCombinedValue).toEqualTypeOf() + + const numberQueries = [1, 2, 3].map((value) => + queryOptions({ + queryKey: ['type-inference', 'mapped-number', value] as const, + queryFn: async () => value, + }), + ) + const mappedQueriesResult = createQueriesController( + host, + { + queries: [ + ...numberQueries, + queryOptions({ + queryKey: ['type-inference', 'mapped-boolean'] as const, + queryFn: async () => true, + }), + ], + }, + client, + ) + + const mappedQueriesData = expectMappedQueriesResult(mappedQueriesResult()) + expectTypeOf(mappedQueriesData[0].data).toEqualTypeOf< + number | boolean | undefined + >() + }) + + it('L2: helper option generics preserve controller inference', () => { + const client = new QueryClient() + const host = new TestControllerHost() + + const query = createQueryController( + host, + queryOptions({ + queryKey: ['type-inference', 'query'] as const, + queryFn: async () => ({ id: 1, name: 'Ada' }), + }), + client, + ) + expectTypeOf(query().data).toEqualTypeOf< + { id: number; name: string } | undefined + >() + + const mutation = createMutationController( + host, + mutationOptions({ + mutationFn: async (input: { id: number }) => input.id.toString(), + }), + client, + ) + expectTypeOf(mutation().data).toEqualTypeOf() + expectTypeOf(mutation().variables).toEqualTypeOf< + { id: number } | undefined + >() + + const queryOpts = queryOptions({ + queryKey: ['type-inference', 'query-options'] as const, + queryFn: async () => ({ id: 2, name: 'Grace' }), + }) + expectTypeOf(queryOpts.queryKey[dataTagSymbol]).toEqualTypeOf<{ + id: number + name: string + }>() + const cachedData = client.getQueryData(queryOpts.queryKey) + expectTypeOf(cachedData).toEqualTypeOf< + { id: number; name: string } | undefined + >() + const updatedData = client.setQueryData(queryOpts.queryKey, { + id: 3, + name: 'Lin', + }) + expectTypeOf(updatedData).toEqualTypeOf< + { id: number; name: string } | undefined + >() + + const infinite = createInfiniteQueryController( + host, + infiniteQueryOptions({ + queryKey: ['type-inference', 'infinite'] as const, + initialPageParam: 0, + queryFn: async () => ({ page: 1 }), + getNextPageParam: (lastPage) => lastPage.page + 1, + }), + client, + ) + expectTypeOf(infinite().data?.pages).toEqualTypeOf< + Array<{ page: number }> | undefined + >() + }) +}) diff --git a/packages/lit-query/src/types.ts b/packages/lit-query/src/types.ts new file mode 100644 index 00000000000..4eca18ed4e1 --- /dev/null +++ b/packages/lit-query/src/types.ts @@ -0,0 +1,77 @@ +import type { + DefaultError, + InfiniteData, + MutationObserverResult, + QueryKey, + QueryObserverResult, +} from '@tanstack/query-core' +import type { Accessor } from './accessor.js' +import type { CreateInfiniteQueryOptions } from './createInfiniteQueryController.js' +import type { CreateMutationOptions } from './createMutationController.js' +import type { + CreateQueriesControllerOptions, + CreateQueriesResults, +} from './createQueriesController.js' +import type { CreateQueryOptions } from './createQueryController.js' + +/** + * Accessor-wrapped options accepted by `createQueryController`. + */ +export type QueryControllerOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Accessor< + CreateQueryOptions +> + +/** + * Result object produced by a Lit query controller. + */ +export type QueryControllerResult< + TData = unknown, + TError = DefaultError, +> = QueryObserverResult + +/** + * Accessor-wrapped options accepted by `createInfiniteQueryController`. + */ +export type InfiniteQueryControllerOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = Accessor< + CreateInfiniteQueryOptions +> + +/** + * Accessor-wrapped options accepted by `createMutationController`. + */ +export type MutationControllerOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = Accessor> + +/** + * Result object produced by a Lit mutation controller. + */ +export type MutationControllerResult< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = MutationObserverResult + +/** + * Accessor-wrapped options accepted by `createQueriesController`. + */ +export type QueriesControllerOptions< + TQueryOptions extends Array = Array, + TCombinedResult = CreateQueriesResults, +> = Accessor> diff --git a/packages/lit-query/src/useIsFetching.ts b/packages/lit-query/src/useIsFetching.ts new file mode 100644 index 00000000000..76abd9e408f --- /dev/null +++ b/packages/lit-query/src/useIsFetching.ts @@ -0,0 +1,159 @@ +import type { QueryClient, QueryFilters } from '@tanstack/query-core' +import type { ReactiveControllerHost } from 'lit' +import { + createValueAccessor, + readAccessor, + type Accessor, + type ValueAccessor, +} from './accessor.js' +import { BaseController } from './controllers/BaseController.js' + +/** + * Accessor returned by `useIsFetching`. + * + * Call the accessor or read its `current` property to get the number of + * currently fetching queries that match the filters. + */ +export type IsFetchingAccessor = ValueAccessor & { destroy: () => void } + +class IsFetchingController extends BaseController { + private queryClient: QueryClient | undefined + private unsubscribe: (() => void) | undefined + + constructor( + host: ReactiveControllerHost, + private readonly filters: Accessor = {}, + queryClient?: QueryClient, + ) { + super(host, 0, queryClient) + + if (!queryClient) { + return + } + + this.queryClient = queryClient + this.result = this.computeValue() + } + + protected onConnected(): void { + if (!this.syncClient()) { + this.setResult(0) + return + } + + this.subscribe() + this.setResult(this.computeValue()) + } + + protected onDisconnected(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + this.syncClient() + } + + protected onHostUpdate(): void { + if (typeof this.filters !== 'function') { + return + } + + this.setResult(this.syncClient() ? this.computeValue() : 0) + } + + protected onQueryClientChanged(): void { + if (!this.syncClient()) { + this.setResult(0) + return + } + + if (this.connectedState) { + this.subscribe() + this.setResult(this.computeValue()) + } + } + + private syncClient(): boolean { + const nextClient = this.tryGetQueryClient() + if (!nextClient) { + this.unsubscribe?.() + this.unsubscribe = undefined + this.queryClient = undefined + return false + } + + if (nextClient === this.queryClient) { + return true + } + + this.unsubscribe?.() + this.unsubscribe = undefined + this.queryClient = nextClient + return true + } + + private subscribe(): void { + if (!this.queryClient) { + return + } + + if (this.unsubscribe) { + return + } + + this.unsubscribe = this.queryClient.getQueryCache().subscribe(() => { + this.setResult(this.computeValue()) + }) + } + + private computeValue(): number { + if (!this.queryClient) { + return 0 + } + + return this.queryClient.isFetching(readAccessor(this.filters)) + } +} + +/** + * Creates a Lit reactive controller that tracks how many matching queries are + * currently fetching. + * + * When `filters` is a function, it is re-read during host updates so the count + * can follow reactive host state. If `queryClient` is omitted, the controller + * resolves the client from the nearest connected `QueryClientProvider`. + * + * @param host - The Lit reactive controller host that owns the cache + * subscription. + * @param filters - Query filters, or a getter that returns query filters. + * @param queryClient - Optional explicit query client. Provide this for + * controllers that should not resolve a client from Lit context. + * @returns An accessor for the current number of matching fetching queries. + * + * @example + * ```ts + * import { LitElement, html } from 'lit' + * import { useIsFetching } from '@tanstack/lit-query' + * + * class TodosStatus extends LitElement { + * private readonly todosFetching = useIsFetching(this, { + * queryKey: ['todos'], + * }) + * + * render() { + * return html`${this.todosFetching()} active todo fetches` + * } + * } + * ``` + */ +export function useIsFetching( + host: ReactiveControllerHost, + filters: Accessor = {}, + queryClient?: QueryClient, +): IsFetchingAccessor { + const controller = new IsFetchingController(host, filters, queryClient) + return Object.assign( + createValueAccessor(() => controller.current), + { + destroy: () => controller.destroy(), + }, + ) +} diff --git a/packages/lit-query/src/useIsMutating.ts b/packages/lit-query/src/useIsMutating.ts new file mode 100644 index 00000000000..154ad4e5e6a --- /dev/null +++ b/packages/lit-query/src/useIsMutating.ts @@ -0,0 +1,159 @@ +import type { MutationFilters, QueryClient } from '@tanstack/query-core' +import type { ReactiveControllerHost } from 'lit' +import { + createValueAccessor, + readAccessor, + type Accessor, + type ValueAccessor, +} from './accessor.js' +import { BaseController } from './controllers/BaseController.js' + +/** + * Accessor returned by `useIsMutating`. + * + * Call the accessor or read its `current` property to get the number of + * currently pending mutations that match the filters. + */ +export type IsMutatingAccessor = ValueAccessor & { destroy: () => void } + +class IsMutatingController extends BaseController { + private queryClient: QueryClient | undefined + private unsubscribe: (() => void) | undefined + + constructor( + host: ReactiveControllerHost, + private readonly filters: Accessor = {}, + queryClient?: QueryClient, + ) { + super(host, 0, queryClient) + + if (!queryClient) { + return + } + + this.queryClient = queryClient + this.result = this.computeValue() + } + + protected onConnected(): void { + if (!this.syncClient()) { + this.setResult(0) + return + } + + this.subscribe() + this.setResult(this.computeValue()) + } + + protected onDisconnected(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + this.syncClient() + } + + protected onHostUpdate(): void { + if (typeof this.filters !== 'function') { + return + } + + this.setResult(this.syncClient() ? this.computeValue() : 0) + } + + protected onQueryClientChanged(): void { + if (!this.syncClient()) { + this.setResult(0) + return + } + + if (this.connectedState) { + this.subscribe() + this.setResult(this.computeValue()) + } + } + + private syncClient(): boolean { + const nextClient = this.tryGetQueryClient() + if (!nextClient) { + this.unsubscribe?.() + this.unsubscribe = undefined + this.queryClient = undefined + return false + } + + if (nextClient === this.queryClient) { + return true + } + + this.unsubscribe?.() + this.unsubscribe = undefined + this.queryClient = nextClient + return true + } + + private subscribe(): void { + if (!this.queryClient) { + return + } + + if (this.unsubscribe) { + return + } + + this.unsubscribe = this.queryClient.getMutationCache().subscribe(() => { + this.setResult(this.computeValue()) + }) + } + + private computeValue(): number { + if (!this.queryClient) { + return 0 + } + + return this.queryClient.isMutating(readAccessor(this.filters)) + } +} + +/** + * Creates a Lit reactive controller that tracks how many matching mutations are + * currently pending. + * + * When `filters` is a function, it is re-read during host updates so the count + * can follow reactive host state. If `queryClient` is omitted, the controller + * resolves the client from the nearest connected `QueryClientProvider`. + * + * @param host - The Lit reactive controller host that owns the cache + * subscription. + * @param filters - Mutation filters, or a getter that returns mutation filters. + * @param queryClient - Optional explicit query client. Provide this for + * controllers that should not resolve a client from Lit context. + * @returns An accessor for the current number of matching pending mutations. + * + * @example + * ```ts + * import { LitElement, html } from 'lit' + * import { useIsMutating } from '@tanstack/lit-query' + * + * class MutationStatus extends LitElement { + * private readonly savesPending = useIsMutating(this, { + * mutationKey: ['save-project'], + * }) + * + * render() { + * return html`${this.savesPending()} saves pending` + * } + * } + * ``` + */ +export function useIsMutating( + host: ReactiveControllerHost, + filters: Accessor = {}, + queryClient?: QueryClient, +): IsMutatingAccessor { + const controller = new IsMutatingController(host, filters, queryClient) + return Object.assign( + createValueAccessor(() => controller.current), + { + destroy: () => controller.destroy(), + }, + ) +} diff --git a/packages/lit-query/src/useMutationState.ts b/packages/lit-query/src/useMutationState.ts new file mode 100644 index 00000000000..ed362ea0944 --- /dev/null +++ b/packages/lit-query/src/useMutationState.ts @@ -0,0 +1,206 @@ +import { + replaceEqualDeep, + type Mutation, + type MutationFilters, + type MutationState, + type QueryClient, +} from '@tanstack/query-core' +import type { ReactiveControllerHost } from 'lit' +import { + createValueAccessor, + readAccessor, + type Accessor, + type ValueAccessor, +} from './accessor.js' +import { BaseController } from './controllers/BaseController.js' + +/** + * Options accepted by `useMutationState`. + */ +export type MutationStateOptions = { + /** Filters used to select mutations from the mutation cache. */ + filters?: Accessor + /** Maps each matching mutation to the value returned by the accessor. */ + select?: (mutation: Mutation) => TResult +} + +/** + * Accessor returned by `useMutationState`. + * + * Call the accessor or read its `current` property to get the selected state for + * matching mutations. + */ +export type MutationStateAccessor = ValueAccessor & { + /** Removes the controller from its Lit host and unsubscribes observers. */ + destroy: () => void +} + +class MutationStateController extends BaseController { + private queryClient: QueryClient | undefined + private unsubscribe: (() => void) | undefined + + constructor( + host: ReactiveControllerHost, + private readonly options: MutationStateOptions, + queryClient?: QueryClient, + ) { + super(host, [], queryClient) + + if (!queryClient) { + return + } + + this.queryClient = queryClient + this.result = this.computeState() + } + + protected onConnected(): void { + if (!this.syncClient()) { + this.setMutationState([]) + return + } + + this.subscribe() + this.setMutationState(this.computeState()) + } + + protected onDisconnected(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + this.syncClient() + } + + protected onHostUpdate(): void { + if (!this.shouldRefreshOnHostUpdate()) { + return + } + + this.setMutationState(this.syncClient() ? this.computeState() : []) + } + + protected onQueryClientChanged(): void { + if (!this.syncClient()) { + this.setMutationState([]) + return + } + + if (this.connectedState) { + this.subscribe() + this.setMutationState(this.computeState()) + } + } + + private syncClient(): boolean { + const nextClient = this.tryGetQueryClient() + if (!nextClient) { + this.unsubscribe?.() + this.unsubscribe = undefined + this.queryClient = undefined + return false + } + + if (nextClient === this.queryClient) { + return true + } + + this.unsubscribe?.() + this.unsubscribe = undefined + this.queryClient = nextClient + return true + } + + private subscribe(): void { + if (!this.queryClient) { + return + } + + if (this.unsubscribe) { + return + } + + this.unsubscribe = this.queryClient.getMutationCache().subscribe(() => { + this.setMutationState(this.computeState()) + }) + } + + private setMutationState(next: TResult[]): void { + this.setResult(replaceEqualDeep(this.result, next)) + } + + private shouldRefreshOnHostUpdate(): boolean { + return ( + typeof this.options.filters === 'function' || + typeof this.options.select === 'function' + ) + } + + private computeState(): TResult[] { + if (!this.queryClient) { + return [] + } + + const filters = this.options.filters + ? readAccessor(this.options.filters) + : undefined + + const select = this.options.select + const mutations = this.queryClient.getMutationCache().findAll(filters) + + return mutations.map((mutation) => { + if (select) { + return select(mutation) + } + + return mutation.state as TResult + }) + } +} + +/** + * Creates a Lit reactive controller that selects state from matching mutations + * in the mutation cache. + * + * When `options.filters` is a function, it is re-read during host updates so + * the selection can follow reactive host state. If `queryClient` is omitted, + * the controller resolves the client from the nearest connected + * `QueryClientProvider`. + * + * @param host - The Lit reactive controller host that owns the mutation cache + * subscription. + * @param options - Mutation state filters and optional selector. + * @param queryClient - Optional explicit query client. Provide this for + * controllers that should not resolve a client from Lit context. + * @returns An accessor for the selected mutation state array. + * + * @example + * ```ts + * import { LitElement, html } from 'lit' + * import { useMutationState } from '@tanstack/lit-query' + * + * class PendingUploads extends LitElement { + * private readonly uploads = useMutationState(this, { + * filters: { mutationKey: ['upload'], status: 'pending' }, + * select: (mutation) => mutation.state.variables as File, + * }) + * + * render() { + * return html`${this.uploads().length} uploads pending` + * } + * } + * ``` + */ +export function useMutationState< + TResult = MutationState, +>( + host: ReactiveControllerHost, + options: MutationStateOptions = {}, + queryClient?: QueryClient, +): MutationStateAccessor { + const controller = new MutationStateController(host, options, queryClient) + return Object.assign( + createValueAccessor(() => controller.current), + { + destroy: () => controller.destroy(), + }, + ) +} diff --git a/packages/lit-query/tsconfig.build.cjs.json b/packages/lit-query/tsconfig.build.cjs.json new file mode 100644 index 00000000000..f6a332575d4 --- /dev/null +++ b/packages/lit-query/tsconfig.build.cjs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "customConditions": null, + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist-cjs", + "declaration": false, + "declarationMap": false + }, + "exclude": ["src/tests/**/*.ts", "dist", "dist-cjs", "node_modules"] +} diff --git a/packages/lit-query/tsconfig.build.json b/packages/lit-query/tsconfig.build.json new file mode 100644 index 00000000000..5a1dd43717c --- /dev/null +++ b/packages/lit-query/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "incremental": false, + "composite": false, + "customConditions": [], + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/tests/**/*.ts", "dist", "node_modules"] +} diff --git a/packages/lit-query/tsconfig.json b/packages/lit-query/tsconfig.json new file mode 100644 index 00000000000..9608d648e8c --- /dev/null +++ b/packages/lit-query/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "emitDeclarationOnly": false, + "rootDir": ".", + "outDir": "dist-ts" + }, + "include": ["src/**/*.ts", "*.config.*", "package.json"], + "exclude": ["dist", "node_modules"], + "references": [{ "path": "../query-core" }] +} diff --git a/packages/lit-query/vitest.config.ts b/packages/lit-query/vitest.config.ts new file mode 100644 index 00000000000..0ab94757be4 --- /dev/null +++ b/packages/lit-query/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + // fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660 + resolve: { + conditions: ['@tanstack/custom-condition'], + }, + environments: { + ssr: { + resolve: { + conditions: ['@tanstack/custom-condition'], + }, + }, + }, + test: { + dir: './src', + watch: false, + environment: 'jsdom', + include: ['tests/**/*.test.ts'], + coverage: { + enabled: false, + }, + typecheck: { enabled: true }, + restoreMocks: true, + }, +}) diff --git a/packages/preact-query-devtools/CHANGELOG.md b/packages/preact-query-devtools/CHANGELOG.md index b00cdcc47ae..6e59fe956d5 100644 --- a/packages/preact-query-devtools/CHANGELOG.md +++ b/packages/preact-query-devtools/CHANGELOG.md @@ -1,5 +1,245 @@ # @tanstack/preact-query-devtools +## 5.101.0 + +### Patch Changes + +- Updated dependencies [[`3042860`](https://github.com/TanStack/query/commit/3042860e3c8731c94ca4dec0e277e415d0484fce), [`e631dc3`](https://github.com/TanStack/query/commit/e631dc3fa17bff71f413246b7a770a730016d346)]: + - @tanstack/query-devtools@5.101.0 + - @tanstack/preact-query@5.101.0 + +## 5.100.14 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.14 + - @tanstack/query-devtools@5.100.14 + +## 5.100.13 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.13 + - @tanstack/query-devtools@5.100.13 + +## 5.100.12 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.12 + - @tanstack/query-devtools@5.100.12 + +## 5.100.11 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.11 + - @tanstack/query-devtools@5.100.11 + +## 5.100.10 + +### Patch Changes + +- Updated dependencies [[`4d130b9`](https://github.com/TanStack/query/commit/4d130b992d2ac396d75f921bfc530dd3a9d50577)]: + - @tanstack/query-devtools@5.100.10 + - @tanstack/preact-query@5.100.10 + +## 5.100.9 + +### Patch Changes + +- Updated dependencies [[`3d21cac`](https://github.com/TanStack/query/commit/3d21cacdec3028b700c4c2e3e0ff8dbe7a235e8c)]: + - @tanstack/query-devtools@5.100.9 + - @tanstack/preact-query@5.100.9 + +## 5.100.8 + +### Patch Changes + +- refactor(preact-query-devtools): replace deprecated 'JSX.CSSProperties' with 'CSSProperties' from Preact namespace ([#10622](https://github.com/TanStack/query/pull/10622)) + +- Updated dependencies []: + - @tanstack/preact-query@5.100.8 + - @tanstack/query-devtools@5.100.8 + +## 5.100.7 + +### Patch Changes + +- docs(devtools): align logo, panel, and 'buttonPosition' union descriptions across docs and JSDoc ([#10617](https://github.com/TanStack/query/pull/10617)) + +- Updated dependencies []: + - @tanstack/preact-query@5.100.7 + - @tanstack/query-devtools@5.100.7 + +## 5.100.6 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.6 + - @tanstack/query-devtools@5.100.6 + +## 5.100.5 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.5 + - @tanstack/query-devtools@5.100.5 + +## 5.100.4 + +### Patch Changes + +- Updated dependencies [[`3d1a62e`](https://github.com/TanStack/query/commit/3d1a62e63bd864359e369bb21356fa80d043f2ba)]: + - @tanstack/query-devtools@5.100.4 + - @tanstack/preact-query@5.100.4 + +## 5.100.3 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.3 + - @tanstack/query-devtools@5.100.3 + +## 5.100.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.2 + - @tanstack/query-devtools@5.100.2 + +## 5.100.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.1 + - @tanstack/query-devtools@5.100.1 + +## 5.100.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.0 + - @tanstack/query-devtools@5.100.0 + +## 5.99.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.99.2 + - @tanstack/query-devtools@5.99.2 + +## 5.99.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.99.1 + - @tanstack/query-devtools@5.99.1 + +## 5.99.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.99.0 + - @tanstack/query-devtools@5.99.0 + +## 5.98.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.98.0 + - @tanstack/query-devtools@5.98.0 + +## 5.97.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.97.0 + - @tanstack/query-devtools@5.97.0 + +## 5.96.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.96.2 + - @tanstack/query-devtools@5.96.2 + +## 5.96.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.96.1 + - @tanstack/query-devtools@5.96.1 + +## 5.96.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.96.0 + - @tanstack/query-devtools@5.96.0 + +## 5.95.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.95.2 + - @tanstack/query-devtools@5.95.2 + +## 5.95.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.95.1 + - @tanstack/query-devtools@5.95.1 + +## 5.95.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.95.0 + - @tanstack/query-devtools@5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]: + - @tanstack/preact-query@5.94.5 + - @tanstack/query-devtools@5.94.5 + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + +- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]: + - @tanstack/query-devtools@5.94.4 + - @tanstack/preact-query@5.94.4 + ## 5.92.0 ### Minor Changes diff --git a/packages/preact-query-devtools/package.json b/packages/preact-query-devtools/package.json index e046de1d872..03a27a29388 100644 --- a/packages/preact-query-devtools/package.json +++ b/packages/preact-query-devtools/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/preact-query-devtools", - "version": "5.92.0", + "version": "5.101.0", "description": "Developer tools to interact with and visualize the TanStack/preact-query cache", "author": "tannerlinsley", "license": "MIT", @@ -27,6 +27,8 @@ "test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js --build tsconfig.legacy.json", "test:types:tscurrent": "tsc --build", "test:types:ts60": "node ../../node_modules/typescript60/lib/tsc.js --build tsconfig.legacy.json", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", "test:build": "publint --strict && attw --pack", "build": "tsup --tsconfig tsconfig.prod.json", "build:dev": "tsup --watch" diff --git a/packages/preact-query-devtools/src/PreactQueryDevtools.tsx b/packages/preact-query-devtools/src/PreactQueryDevtools.tsx index e8186ccda15..8d1e479bd8b 100644 --- a/packages/preact-query-devtools/src/PreactQueryDevtools.tsx +++ b/packages/preact-query-devtools/src/PreactQueryDevtools.tsx @@ -17,11 +17,13 @@ export interface DevtoolsOptions { initialIsOpen?: boolean /** * The position of the TanStack logo to open and close the devtools panel. + * 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'relative' * Defaults to 'bottom-right'. */ buttonPosition?: DevtoolsButtonPosition /** * The position of the Preact Query devtools panel. + * 'top' | 'bottom' | 'left' | 'right' * Defaults to 'bottom'. */ position?: DevtoolsPosition diff --git a/packages/preact-query-devtools/src/PreactQueryDevtoolsPanel.tsx b/packages/preact-query-devtools/src/PreactQueryDevtoolsPanel.tsx index 2070f14603a..dc22578d991 100644 --- a/packages/preact-query-devtools/src/PreactQueryDevtoolsPanel.tsx +++ b/packages/preact-query-devtools/src/PreactQueryDevtoolsPanel.tsx @@ -3,7 +3,7 @@ import { onlineManager, useQueryClient } from '@tanstack/preact-query' import { TanstackQueryDevtoolsPanel } from '@tanstack/query-devtools' import type { DevtoolsErrorType, Theme } from '@tanstack/query-devtools' import type { QueryClient } from '@tanstack/preact-query' -import type { JSX, VNode } from 'preact' +import type { CSSProperties, VNode } from 'preact' export interface DevtoolsPanelOptions { /** @@ -28,7 +28,7 @@ export interface DevtoolsPanelOptions { /** * Custom styles for the devtools panel container. */ - style?: JSX.CSSProperties + style?: CSSProperties /** * Callback function when the devtools panel is closed. */ diff --git a/packages/preact-query-devtools/src/__tests__/PreactQueryDevtools.test.tsx b/packages/preact-query-devtools/src/__tests__/PreactQueryDevtools.test.tsx new file mode 100644 index 00000000000..cdbd85021d6 --- /dev/null +++ b/packages/preact-query-devtools/src/__tests__/PreactQueryDevtools.test.tsx @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/preact' +import { QueryClient, QueryClientProvider } from '@tanstack/preact-query' +import { TanstackQueryDevtools } from '@tanstack/query-devtools' + +const mountMock = vi.fn() +const unmountMock = vi.fn() +const setClientMock = vi.fn() +const setButtonPositionMock = vi.fn() +const setPositionMock = vi.fn() +const setInitialIsOpenMock = vi.fn() +const setErrorTypesMock = vi.fn() +const setThemeMock = vi.fn() + +vi.mock('@tanstack/query-devtools', () => ({ + TanstackQueryDevtools: vi.fn(function (this: TanstackQueryDevtools) { + this.mount = mountMock + this.unmount = unmountMock + this.setClient = setClientMock + this.setButtonPosition = setButtonPositionMock + this.setPosition = setPositionMock + this.setInitialIsOpen = setInitialIsOpenMock + this.setErrorTypes = setErrorTypesMock + this.setTheme = setThemeMock + }), +})) + +describe('PreactQueryDevtools', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should throw an error if no query client has been set', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + + expect(() => render()).toThrow( + 'No QueryClient set, use QueryClientProvider to set one', + ) + }) + + it('should not throw an error if query client is provided via context', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + expect(() => + render( + + + , + ), + ).not.toThrow() + expect(mountMock).toHaveBeenCalled() + }) + + it('should not throw an error if query client is provided via props', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + expect(() => + render(), + ).not.toThrow() + expect(mountMock).toHaveBeenCalled() + }) + + it('should forward "buttonPosition" to the devtools instance', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render( + , + ) + + expect(setButtonPositionMock).toHaveBeenCalledWith('top-left') + }) + + it('should forward "position" to the devtools instance', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render() + + expect(setPositionMock).toHaveBeenCalledWith('left') + }) + + it('should forward "initialIsOpen" to the devtools instance', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render() + + expect(setInitialIsOpenMock).toHaveBeenCalledWith(true) + }) + + it('should default "initialIsOpen" to "false" when the prop is omitted', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render() + + expect(setInitialIsOpenMock).toHaveBeenCalledWith(false) + }) + + it('should forward "errorTypes" to the devtools instance', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + const errorTypes = [ + { name: 'Network', initializer: () => new Error('Network') }, + ] + + render() + + expect(setErrorTypesMock).toHaveBeenCalledWith(errorTypes) + }) + + it('should default "errorTypes" to an empty array when the prop is omitted', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render() + + expect(setErrorTypesMock).toHaveBeenCalledWith([]) + }) + + it('should forward "theme" to the devtools instance', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render() + + expect(setThemeMock).toHaveBeenCalledWith('dark') + }) + + it('should forward the resolved "QueryClient" via "setClient"', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render() + + expect(setClientMock).toHaveBeenCalledWith(queryClient) + }) + + it('should forward "styleNonce" to the devtools constructor', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render() + + expect(TanstackQueryDevtools).toHaveBeenCalledWith( + expect.objectContaining({ styleNonce: 'abc' }), + ) + }) + + it('should forward "shadowDOMTarget" to the devtools constructor', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + const shadowDOMTarget = document + .createElement('div') + .attachShadow({ mode: 'open' }) + + render( + , + ) + + expect(TanstackQueryDevtools).toHaveBeenCalledWith( + expect.objectContaining({ shadowDOMTarget }), + ) + }) + + it('should forward "hideDisabledQueries" to the devtools constructor', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + render( + , + ) + + expect(TanstackQueryDevtools).toHaveBeenCalledWith( + expect.objectContaining({ hideDisabledQueries: true }), + ) + }) + + it('should call "unmount" on the devtools instance when the component unmounts', async () => { + const { PreactQueryDevtools } = await import('../PreactQueryDevtools') + const queryClient = new QueryClient() + + const { unmount } = render() + unmount() + + expect(unmountMock).toHaveBeenCalled() + }) + + it('should return null in non-development environments', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.resetModules() + + try { + const { PreactQueryDevtools } = await import('..') + expect(PreactQueryDevtools({})).toBeNull() + } finally { + vi.unstubAllEnvs() + vi.resetModules() + } + }) +}) diff --git a/packages/preact-query-devtools/src/__tests__/PreactQueryDevtoolsPanel.test.tsx b/packages/preact-query-devtools/src/__tests__/PreactQueryDevtoolsPanel.test.tsx new file mode 100644 index 00000000000..1d54b36bb60 --- /dev/null +++ b/packages/preact-query-devtools/src/__tests__/PreactQueryDevtoolsPanel.test.tsx @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/preact' +import { QueryClient, QueryClientProvider } from '@tanstack/preact-query' +import { TanstackQueryDevtoolsPanel } from '@tanstack/query-devtools' + +const mountMock = vi.fn() +const unmountMock = vi.fn() +const setClientMock = vi.fn() +const setOnCloseMock = vi.fn() +const setErrorTypesMock = vi.fn() +const setThemeMock = vi.fn() + +vi.mock('@tanstack/query-devtools', () => ({ + TanstackQueryDevtoolsPanel: vi.fn(function ( + this: TanstackQueryDevtoolsPanel, + ) { + this.mount = mountMock + this.unmount = unmountMock + this.setClient = setClientMock + this.setOnClose = setOnCloseMock + this.setErrorTypes = setErrorTypesMock + this.setTheme = setThemeMock + }), +})) + +describe('PreactQueryDevtoolsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should throw an error if no query client has been set', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + + expect(() => render()).toThrow( + 'No QueryClient set, use QueryClientProvider to set one', + ) + }) + + it('should not throw an error if query client is provided via context', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + expect(() => + render( + + + , + ), + ).not.toThrow() + expect(mountMock).toHaveBeenCalled() + }) + + it('should not throw an error if query client is provided via props', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + expect(() => + render(), + ).not.toThrow() + expect(mountMock).toHaveBeenCalled() + }) + + it('should forward "onClose" to the devtools instance', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + const onClose = vi.fn() + + render() + + expect(setOnCloseMock).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('should default "onClose" to a no-op function when the prop is omitted', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + render() + + expect(setOnCloseMock).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('should forward "errorTypes" to the devtools instance', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + const errorTypes = [ + { name: 'Network', initializer: () => new Error('Network') }, + ] + + render( + , + ) + + expect(setErrorTypesMock).toHaveBeenCalledWith(errorTypes) + }) + + it('should default "errorTypes" to an empty array when the prop is omitted', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + render() + + expect(setErrorTypesMock).toHaveBeenCalledWith([]) + }) + + it('should forward "theme" to the devtools instance', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + render() + + expect(setThemeMock).toHaveBeenCalledWith('dark') + }) + + it('should forward the resolved "QueryClient" via "setClient"', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + render() + + expect(setClientMock).toHaveBeenCalledWith(queryClient) + }) + + it('should forward "styleNonce" to the devtools constructor', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + render() + + expect(TanstackQueryDevtoolsPanel).toHaveBeenCalledWith( + expect.objectContaining({ styleNonce: 'abc' }), + ) + }) + + it('should forward "shadowDOMTarget" to the devtools constructor', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + const shadowDOMTarget = document + .createElement('div') + .attachShadow({ mode: 'open' }) + + render( + , + ) + + expect(TanstackQueryDevtoolsPanel).toHaveBeenCalledWith( + expect.objectContaining({ shadowDOMTarget }), + ) + }) + + it('should forward "hideDisabledQueries" to the devtools constructor', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + render( + , + ) + + expect(TanstackQueryDevtoolsPanel).toHaveBeenCalledWith( + expect.objectContaining({ hideDisabledQueries: true }), + ) + }) + + it('should preserve the default container height when "style" omits "height"', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + const { container } = render( + , + ) + + expect(container.querySelector('.tsqd-parent-container')).toHaveStyle({ + height: '500px', + width: '300px', + }) + }) + + it('should let "style" override the default container height on the rendered element', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + const { container } = render( + , + ) + + expect(container.querySelector('.tsqd-parent-container')).toHaveStyle({ + height: '300px', + width: '300px', + }) + }) + + it('should call "unmount" on the devtools instance when the component unmounts', async () => { + const { PreactQueryDevtoolsPanel } = + await import('../PreactQueryDevtoolsPanel') + const queryClient = new QueryClient() + + const { unmount } = render( + , + ) + unmount() + + expect(unmountMock).toHaveBeenCalled() + }) + + it('should return null in non-development environments', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.resetModules() + + try { + const { PreactQueryDevtoolsPanel } = await import('..') + expect(PreactQueryDevtoolsPanel({})).toBeNull() + } finally { + vi.unstubAllEnvs() + vi.resetModules() + } + }) +}) diff --git a/packages/preact-query-devtools/tsconfig.json b/packages/preact-query-devtools/tsconfig.json index 26cd88c4183..61684571df5 100644 --- a/packages/preact-query-devtools/tsconfig.json +++ b/packages/preact-query-devtools/tsconfig.json @@ -6,6 +6,12 @@ "jsx": "react-jsx", "jsxImportSource": "preact" }, - "include": ["src", "test-setup.ts", "*.config.*", "package.json"], + "include": [ + "src", + "test-setup.ts", + "*.config.ts", + "*.config.js", + "package.json" + ], "references": [{ "path": "../query-devtools" }, { "path": "../preact-query" }] } diff --git a/packages/preact-query-devtools/tsconfig.prod.json b/packages/preact-query-devtools/tsconfig.prod.json index f39338ce4ce..2de70e52771 100644 --- a/packages/preact-query-devtools/tsconfig.prod.json +++ b/packages/preact-query-devtools/tsconfig.prod.json @@ -5,5 +5,7 @@ "composite": false, "rootDir": "../../", "customConditions": [] - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/preact-query-devtools/vite.config.ts b/packages/preact-query-devtools/vite.config.ts index fdd8f053cf9..1532d2458bf 100644 --- a/packages/preact-query-devtools/vite.config.ts +++ b/packages/preact-query-devtools/vite.config.ts @@ -1,7 +1,7 @@ import preact from '@preact/preset-vite' import { defineConfig } from 'vitest/config' -import type { UserConfig as ViteUserConfig } from 'vite' import packageJson from './package.json' +import type { UserConfig as ViteUserConfig } from 'vite' export default defineConfig({ plugins: [preact() as ViteUserConfig['plugins']], @@ -16,7 +16,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['test-setup.ts'], coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/preact-query-persist-client/CHANGELOG.md b/packages/preact-query-persist-client/CHANGELOG.md index 2ed34018c95..558fd2d15a1 100644 --- a/packages/preact-query-persist-client/CHANGELOG.md +++ b/packages/preact-query-persist-client/CHANGELOG.md @@ -1,5 +1,241 @@ # @tanstack/preact-query-persist-client +## 5.101.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.101.0 + - @tanstack/query-persist-client-core@5.101.0 + +## 5.100.14 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.14 + - @tanstack/query-persist-client-core@5.100.14 + +## 5.100.13 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.13 + - @tanstack/query-persist-client-core@5.100.13 + +## 5.100.12 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.12 + - @tanstack/query-persist-client-core@5.100.12 + +## 5.100.11 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.11 + - @tanstack/query-persist-client-core@5.100.11 + +## 5.100.10 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.10 + - @tanstack/query-persist-client-core@5.100.10 + +## 5.100.9 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.9 + - @tanstack/query-persist-client-core@5.100.9 + +## 5.100.8 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.8 + - @tanstack/query-persist-client-core@5.100.8 + +## 5.100.7 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.7 + - @tanstack/query-persist-client-core@5.100.7 + +## 5.100.6 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.6 + - @tanstack/query-persist-client-core@5.100.6 + +## 5.100.5 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.5 + - @tanstack/query-persist-client-core@5.100.5 + +## 5.100.4 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.4 + - @tanstack/query-persist-client-core@5.100.4 + +## 5.100.3 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.3 + - @tanstack/query-persist-client-core@5.100.3 + +## 5.100.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.2 + - @tanstack/query-persist-client-core@5.100.2 + +## 5.100.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.1 + - @tanstack/query-persist-client-core@5.100.1 + +## 5.100.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.100.0 + - @tanstack/query-persist-client-core@5.100.0 + +## 5.99.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.99.2 + - @tanstack/query-persist-client-core@5.99.2 + +## 5.99.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.99.1 + - @tanstack/query-persist-client-core@5.99.1 + +## 5.99.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.99.0 + - @tanstack/query-persist-client-core@5.99.0 + +## 5.98.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.98.0 + - @tanstack/query-persist-client-core@5.98.0 + +## 5.97.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.97.0 + - @tanstack/query-persist-client-core@5.97.0 + +## 5.96.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.96.2 + - @tanstack/query-persist-client-core@5.96.2 + +## 5.96.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.96.1 + - @tanstack/query-persist-client-core@5.96.1 + +## 5.96.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.96.0 + - @tanstack/query-persist-client-core@5.96.0 + +## 5.95.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.95.2 + - @tanstack/query-persist-client-core@5.95.2 + +## 5.95.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.95.1 + - @tanstack/query-persist-client-core@5.95.1 + +## 5.95.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/preact-query@5.95.0 + - @tanstack/query-persist-client-core@5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]: + - @tanstack/preact-query@5.94.5 + - @tanstack/query-persist-client-core@5.94.5 + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + +- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]: + - @tanstack/query-persist-client-core@5.94.4 + - @tanstack/preact-query@5.94.4 + ## 5.92.3 ### Patch Changes diff --git a/packages/preact-query-persist-client/package.json b/packages/preact-query-persist-client/package.json index b5ebb67887c..1a3a980080d 100644 --- a/packages/preact-query-persist-client/package.json +++ b/packages/preact-query-persist-client/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/preact-query-persist-client", - "version": "5.92.3", + "version": "5.101.0", "description": "Preact bindings to work with persisters in TanStack/preact-query", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/preact-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx b/packages/preact-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx index 25a68912b79..15c88be7275 100644 --- a/packages/preact-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx +++ b/packages/preact-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx @@ -11,7 +11,7 @@ import { notifyManager } from '../../../query-core/src' import { act, cleanup, render } from '@testing-library/preact' import type { UseQueryResult } from '../../../preact-query/src' import { QueryClient, useQuery } from '../../../preact-query/src' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi, it } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { PersistQueryClientProvider } from './testPersistProvider' @@ -48,7 +48,7 @@ describe('PersistQueryClientProvider (preact)', () => { vi.useRealTimers() }) - test('restores cache from persister and refetches', async () => { + it('restores cache from persister and refetches', async () => { const key = queryKey() const states: Array> = [] diff --git a/packages/preact-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx b/packages/preact-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx index 88dbef6d773..10c3072f780 100644 --- a/packages/preact-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx +++ b/packages/preact-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx @@ -44,8 +44,8 @@ describe('useQueries with persist and memoized combine (preact)', () => { afterEach(() => { cleanup() - vi.useRealTimers() Object.keys(storage).forEach((key) => delete storage[key]) + vi.useRealTimers() }) it('updates UI when combine is memoized with persisted results', async () => { diff --git a/packages/preact-query-persist-client/tsconfig.json b/packages/preact-query-persist-client/tsconfig.json index 7551d3b9cf4..6ed1d7a193c 100644 --- a/packages/preact-query-persist-client/tsconfig.json +++ b/packages/preact-query-persist-client/tsconfig.json @@ -6,7 +6,13 @@ "jsx": "react-jsx", "jsxImportSource": "preact" }, - "include": ["src", "test-setup.ts", "*.config.*", "package.json"], + "include": [ + "src", + "test-setup.ts", + "*.config.ts", + "*.config.js", + "package.json" + ], "references": [ { "path": "../query-persist-client-core" }, { "path": "../preact-query" } diff --git a/packages/preact-query-persist-client/tsconfig.prod.json b/packages/preact-query-persist-client/tsconfig.prod.json index 0f4c92da065..2bb29fdf02a 100644 --- a/packages/preact-query-persist-client/tsconfig.prod.json +++ b/packages/preact-query-persist-client/tsconfig.prod.json @@ -4,5 +4,7 @@ "incremental": false, "composite": false, "rootDir": "../../" - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/preact-query-persist-client/vite.config.ts b/packages/preact-query-persist-client/vite.config.ts index f18f9c82684..2143431d548 100644 --- a/packages/preact-query-persist-client/vite.config.ts +++ b/packages/preact-query-persist-client/vite.config.ts @@ -1,8 +1,7 @@ import preact from '@preact/preset-vite' import { defineConfig } from 'vitest/config' -import type { UserConfig as ViteUserConfig } from 'vite' - import packageJson from './package.json' +import type { UserConfig as ViteUserConfig } from 'vite' export default defineConfig({ plugins: [preact() as ViteUserConfig['plugins']], @@ -24,7 +23,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['test-setup.ts'], coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/preact-query/CHANGELOG.md b/packages/preact-query/CHANGELOG.md index 80d5c8d0fea..5190be09fcc 100644 --- a/packages/preact-query/CHANGELOG.md +++ b/packages/preact-query/CHANGELOG.md @@ -1,5 +1,218 @@ # @tanstack/preact-query +## 5.101.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.101.0 + +## 5.100.14 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.14 + +## 5.100.13 + +### Patch Changes + +- Updated dependencies [[`d423168`](https://github.com/TanStack/query/commit/d423168f6261a5cb3d353e53b27c8150cc271151)]: + - @tanstack/query-core@5.100.13 + +## 5.100.12 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.12 + +## 5.100.11 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.11 + +## 5.100.10 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.10 + +## 5.100.9 + +### Patch Changes + +- Updated dependencies [[`fcee7bd`](https://github.com/TanStack/query/commit/fcee7bdc429385ae8ffa224fa8a7a9ec7b8ee380)]: + - @tanstack/query-core@5.100.9 + +## 5.100.8 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.8 + +## 5.100.7 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.7 + +## 5.100.6 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.6 + +## 5.100.5 + +### Patch Changes + +- Updated dependencies [[`a53ef97`](https://github.com/TanStack/query/commit/a53ef97f87decb8ea2431710f5199431d3c94c8d)]: + - @tanstack/query-core@5.100.5 + +## 5.100.4 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.4 + +## 5.100.3 + +### Patch Changes + +- Updated dependencies [[`f85d825`](https://github.com/TanStack/query/commit/f85d825e02efbbff02e2081528ed28f5e5382f7a)]: + - @tanstack/query-core@5.100.3 + +## 5.100.2 + +### Patch Changes + +- Updated dependencies [[`ea4497e`](https://github.com/TanStack/query/commit/ea4497e8aa00d8c1c3a36fb1e17563a889d6ab31), [`d6a7bf3`](https://github.com/TanStack/query/commit/d6a7bf3e3e024c1a77d0536813238cc8007a5fa7), [`645d5d1`](https://github.com/TanStack/query/commit/645d5d130f5e8017cb1bf1a37987f7b980aed705)]: + - @tanstack/query-core@5.100.2 + +## 5.100.1 + +### Patch Changes + +- Updated dependencies [[`1bb0d23`](https://github.com/TanStack/query/commit/1bb0d234280fd4ae1725c439088426a20593a8df)]: + - @tanstack/query-core@5.100.1 + +## 5.100.0 + +### Patch Changes + +- Updated dependencies [[`6540a41`](https://github.com/TanStack/query/commit/6540a4126b1c087d86d64525e78f32d9920dcd31)]: + - @tanstack/query-core@5.100.0 + +## 5.99.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.2 + +## 5.99.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.1 + +## 5.99.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.0 + +## 5.98.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.98.0 + +## 5.97.0 + +### Patch Changes + +- Updated dependencies [[`2bfb12c`](https://github.com/TanStack/query/commit/2bfb12cc44f1d8495106136e4ddacb817135f8f9)]: + - @tanstack/query-core@5.97.0 + +## 5.96.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.2 + +## 5.96.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.1 + +## 5.96.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.0 + +## 5.95.2 + +### Patch Changes + +- Updated dependencies [[`cd5a35b`](https://github.com/TanStack/query/commit/cd5a35b328837781aa4f9305bb2bd7877ca934e9)]: + - @tanstack/query-core@5.95.2 + +## 5.95.1 + +### Patch Changes + +- Updated dependencies [[`1f1775c`](https://github.com/TanStack/query/commit/1f1775ca92f2b6c035682947ff3b3424804ff31a)]: + - @tanstack/query-core@5.95.1 + +## 5.95.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]: + - @tanstack/query-core@5.94.5 + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + +- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]: + - @tanstack/query-core@5.94.4 + +## 5.94.3 + +### Patch Changes + +- fix: stop node types from leaking into browser ([#10302](https://github.com/TanStack/query/pull/10302)) + ## 5.94.2 ### Patch Changes diff --git a/packages/preact-query/package.json b/packages/preact-query/package.json index ebc9e6821b4..f7cdfc972af 100644 --- a/packages/preact-query/package.json +++ b/packages/preact-query/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/preact-query", - "version": "5.94.2", + "version": "5.101.0", "description": "Hooks for managing, caching and syncing asynchronous and remote data in preact", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundary.ts b/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundary.ts index c4465e214cc..fb1d0bcb8cb 100644 --- a/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundary.ts +++ b/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundary.ts @@ -49,7 +49,7 @@ export class ErrorBoundary extends Component< componentDidCatch(error: Error, info: ErrorInfo) { /** - * To emulate the react behaviour of console.error + * To emulate the react behavior of console.error * we add one here to show that the errors bubble up * to the system and can be seen in the console */ diff --git a/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx b/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx index a15173ce03a..f0794fc7e88 100644 --- a/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx @@ -1,9 +1,9 @@ import * as coreModule from '@tanstack/query-core' import type { hydrate } from '@tanstack/query-core' -import { sleep } from '@tanstack/query-test-utils' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { render } from '@testing-library/preact' import { Suspense, startTransition } from 'preact/compat' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi, it } from 'vitest' import { HydrationBoundary, @@ -14,13 +14,16 @@ import { } from '..' describe('Preact hydration', () => { + const stringKey = queryKey() + const addedKey = queryKey() + const promiseKey = queryKey() let stringifiedState: string beforeEach(async () => { vi.useFakeTimers() const queryClient = new QueryClient() queryClient.prefetchQuery({ - queryKey: ['string'], + queryKey: stringKey, queryFn: () => sleep(10).then(() => ['stringCached']), }) await vi.advanceTimersByTimeAsync(10) @@ -32,13 +35,13 @@ describe('Preact hydration', () => { vi.useRealTimers() }) - test('should hydrate queries to the cache on context', async () => { + it('should hydrate queries to the cache on context', async () => { const dehydratedState = JSON.parse(stringifiedState) const queryClient = new QueryClient() function Page() { const { data } = useQuery({ - queryKey: ['string'], + queryKey: stringKey, queryFn: () => sleep(20).then(() => ['string']), }) return ( @@ -62,7 +65,7 @@ describe('Preact hydration', () => { queryClient.clear() }) - test('should hydrate queries to the cache on custom context', async () => { + it('should hydrate queries to the cache on custom context', async () => { const queryClientInner = new QueryClient() const queryClientOuter = new QueryClient() @@ -70,7 +73,7 @@ describe('Preact hydration', () => { function Page() { const { data } = useQuery({ - queryKey: ['string'], + queryKey: stringKey, queryFn: () => sleep(20).then(() => ['string']), }) return ( @@ -99,14 +102,14 @@ describe('Preact hydration', () => { }) describe('PreactQueryCacheProvider with hydration support', () => { - test('should hydrate new queries if queries change', async () => { + it('should hydrate new queries if queries change', async () => { const dehydratedState = JSON.parse(stringifiedState) const queryClient = new QueryClient() - function Page({ queryKey }: { queryKey: [string] }) { + function Page({ queryKey: pageKey }: { queryKey: Array }) { const { data } = useQuery({ - queryKey, - queryFn: () => sleep(20).then(() => queryKey), + queryKey: pageKey, + queryFn: () => sleep(20).then(() => pageKey), }) return (
@@ -118,24 +121,24 @@ describe('Preact hydration', () => { const rendered = render( - + , ) expect(rendered.getByText('stringCached')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(21) - expect(rendered.getByText('string')).toBeInTheDocument() + expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument() const intermediateClient = new QueryClient() intermediateClient.prefetchQuery({ - queryKey: ['string'], + queryKey: stringKey, queryFn: () => sleep(20).then(() => ['should change']), }) intermediateClient.prefetchQuery({ - queryKey: ['added'], - queryFn: () => sleep(20).then(() => ['added']), + queryKey: addedKey, + queryFn: () => sleep(20).then(() => [addedKey[0]]), }) await vi.advanceTimersByTimeAsync(20) const dehydrated = dehydrate(intermediateClient) @@ -144,21 +147,21 @@ describe('Preact hydration', () => { rendered.rerender( - - + + , ) // Existing observer should not have updated at this point, // as that would indicate a side effect in the render phase - expect(rendered.getByText('string')).toBeInTheDocument() + expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument() // New query data should be available immediately - expect(rendered.getByText('added')).toBeInTheDocument() + expect(rendered.getByText(addedKey[0]!)).toBeInTheDocument() await vi.advanceTimersByTimeAsync(0) // After effects phase has had time to run, the observer should have updated - expect(rendered.queryByText('string')).not.toBeInTheDocument() + expect(rendered.queryByText(stringKey[0]!)).not.toBeInTheDocument() expect(rendered.getByText('should change')).toBeInTheDocument() queryClient.clear() @@ -170,14 +173,14 @@ describe('Preact hydration', () => { // remounted, I didn't change tabs etc?). // Any queries that does not exist in the cache yet can still be hydrated // since they don't have any observers on the current page that would update. - test('should hydrate new but not existing queries if transition is aborted', async () => { + it('should hydrate new but not existing queries if transition is aborted', async () => { const initialDehydratedState = JSON.parse(stringifiedState) const queryClient = new QueryClient() - function Page({ queryKey }: { queryKey: [string] }) { + function Page({ queryKey: pageKey }: { queryKey: Array }) { const { data } = useQuery({ - queryKey, - queryFn: () => sleep(20).then(() => queryKey), + queryKey: pageKey, + queryFn: () => sleep(20).then(() => pageKey), }) return (
@@ -189,23 +192,23 @@ describe('Preact hydration', () => { const rendered = render( - + , ) expect(rendered.getByText('stringCached')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(21) - expect(rendered.getByText('string')).toBeInTheDocument() + expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument() const intermediateClient = new QueryClient() intermediateClient.prefetchQuery({ - queryKey: ['string'], + queryKey: stringKey, queryFn: () => sleep(20).then(() => ['should not change']), }) intermediateClient.prefetchQuery({ - queryKey: ['added'], - queryFn: () => sleep(20).then(() => ['added']), + queryKey: addedKey, + queryFn: () => sleep(20).then(() => [addedKey[0]]), }) await vi.advanceTimersByTimeAsync(20) @@ -223,8 +226,8 @@ describe('Preact hydration', () => { - - + + @@ -238,38 +241,38 @@ describe('Preact hydration', () => { rendered.rerender( - - + + , ) // This query existed before the transition so it should stay the same - expect(rendered.getByText('string')).toBeInTheDocument() + expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument() expect( rendered.queryByText('should not change'), ).not.toBeInTheDocument() // New query data should be available immediately because it was // hydrated in the previous transition, even though the new dehydrated // state did not contain it - expect(rendered.getByText('added')).toBeInTheDocument() + expect(rendered.getByText(addedKey[0]!)).toBeInTheDocument() }) await vi.advanceTimersByTimeAsync(20) // It should stay the same even after effects have had a chance to run - expect(rendered.getByText('string')).toBeInTheDocument() + expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument() expect(rendered.queryByText('should not change')).not.toBeInTheDocument() queryClient.clear() }) - test('should hydrate queries to new cache if cache changes', async () => { + it('should hydrate queries to new cache if cache changes', async () => { const dehydratedState = JSON.parse(stringifiedState) const queryClient = new QueryClient() function Page() { const { data } = useQuery({ - queryKey: ['string'], + queryKey: stringKey, queryFn: () => sleep(20).then(() => ['string']), }) return ( @@ -308,7 +311,7 @@ describe('Preact hydration', () => { }) }) - test('should not hydrate queries if state is null', async () => { + it('should not hydrate queries if state is null', async () => { const queryClient = new QueryClient() const hydrateSpy = vi.spyOn(coreModule, 'hydrate') @@ -336,7 +339,7 @@ describe('Preact hydration', () => { queryClient.clear() }) - test('should not hydrate queries if state is undefined', async () => { + it('should not hydrate queries if state is undefined', async () => { const queryClient = new QueryClient() const hydrateSpy = vi.spyOn(coreModule, 'hydrate') @@ -360,7 +363,7 @@ describe('Preact hydration', () => { queryClient.clear() }) - test('should not hydrate queries if state is not an object', async () => { + it('should not hydrate queries if state is not an object', async () => { const queryClient = new QueryClient() const hydrateSpy = vi.spyOn(coreModule, 'hydrate') @@ -384,7 +387,7 @@ describe('Preact hydration', () => { queryClient.clear() }) - test('should handle state without queries property gracefully', async () => { + it('should handle state without queries property gracefully', async () => { const queryClient = new QueryClient() const hydrateSpy = vi.spyOn(coreModule, 'hydrate') @@ -409,7 +412,7 @@ describe('Preact hydration', () => { }) // https://github.com/TanStack/query/issues/8677 - test('should not infinite loop when hydrating promises that resolve to errors', async () => { + it('should not infinite loop when hydrating promises that resolve to errors', async () => { const originalHydrate = coreModule.hydrate const hydrateSpy = vi.spyOn(coreModule, 'hydrate') let hydrationCount = 0 @@ -429,7 +432,7 @@ describe('Preact hydration', () => { // with a dataUpdatedAt earlier than the dehydratedAt of the next query const clientQueryClient = new QueryClient() clientQueryClient.prefetchQuery({ - queryKey: ['promise'], + queryKey: promiseKey, queryFn: () => sleep(20).then(() => 'existing'), }) await vi.advanceTimersByTimeAsync(20) @@ -442,7 +445,7 @@ describe('Preact hydration', () => { }, }) prefetchQueryClient.prefetchQuery({ - queryKey: ['promise'], + queryKey: promiseKey, queryFn: () => sleep(10).then(() => Promise.reject(new Error('Query failed'))), }) @@ -455,7 +458,7 @@ describe('Preact hydration', () => { function Page() { const { data } = useQuery({ - queryKey: ['promise'], + queryKey: promiseKey, queryFn: () => sleep(20).then(() => ['new']), }) return ( diff --git a/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx b/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx index a29a0a409e1..c6ba18d989a 100644 --- a/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx +++ b/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx @@ -1,6 +1,6 @@ import { queryKey, sleep } from '@tanstack/query-test-utils' import { render } from '@testing-library/preact' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi, it } from 'vitest' import { QueryCache, @@ -19,7 +19,7 @@ describe('QueryClientProvider', () => { vi.useRealTimers() }) - test('sets a specific cache for all queries to use', async () => { + it('sets a specific cache for all queries to use', async () => { const key = queryKey() const queryCache = new QueryCache() @@ -50,7 +50,7 @@ describe('QueryClientProvider', () => { expect(queryCache.find({ queryKey: key })).toBeDefined() }) - test('allows multiple caches to be partitioned', async () => { + it('allows multiple caches to be partitioned', async () => { const key1 = queryKey() const key2 = queryKey() @@ -106,7 +106,7 @@ describe('QueryClientProvider', () => { expect(queryCache2.find({ queryKey: key2 })).toBeDefined() }) - test("uses defaultOptions for queries when they don't provide their own config", async () => { + it("uses defaultOptions for queries when they don't provide their own config", async () => { const key = queryKey() const queryCache = new QueryCache() @@ -146,7 +146,7 @@ describe('QueryClientProvider', () => { }) describe('useQueryClient', () => { - test('should throw an error if no query client has been set', () => { + it('should throw an error if no query client has been set', () => { const consoleMock = vi .spyOn(console, 'error') .mockImplementation(() => undefined) diff --git a/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx index 1f1bb00b8d5..f77710c1aa8 100644 --- a/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx +++ b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -17,17 +17,20 @@ import { ErrorBoundary } from './ErrorBoundary' import { renderWithClient } from './utils' describe('QueryErrorResetBoundary', () => { + let queryCache: QueryCache + let queryClient: QueryClient + beforeEach(() => { vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ queryCache }) }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) - const queryCache = new QueryCache() - const queryClient = new QueryClient({ queryCache }) - describe('useQuery', () => { it('should retry fetch if the reset error boundary has been reset', async () => { const consoleMock = vi @@ -750,7 +753,7 @@ describe('QueryErrorResetBoundary', () => { }), retry: false, throwOnError: true, - retryOnMount: true, + retryOnMount: () => true, }, ], }) @@ -815,7 +818,7 @@ describe('QueryErrorResetBoundary', () => { return 'data' }), retry: false, - retryOnMount: true, + retryOnMount: () => true, }, ], }) diff --git a/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx index 87bd366253a..2e896af7e75 100644 --- a/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx +++ b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx @@ -10,17 +10,20 @@ import { QueryCache, QueryClient, hashKey, useQuery } from '..' import { renderWithClient } from './utils' describe('fine grained persister', () => { + let queryCache: QueryCache + let queryClient: QueryClient + beforeEach(() => { vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ queryCache }) }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) - const queryCache = new QueryCache() - const queryClient = new QueryClient({ queryCache }) - it('should restore query state from persister and not refetch', async () => { const key = queryKey() const hash = hashKey(key) @@ -77,11 +80,7 @@ describe('fine grained persister', () => { it('should restore query state from persister and refetch', async () => { const key = queryKey() const hash = hashKey(key) - const spy = vi.fn(async () => { - await sleep(5) - - return 'Works from queryFn' - }) + const spy = vi.fn(() => sleep(5).then(() => 'Works from queryFn')) const mapStorage = new Map() const storage = { diff --git a/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx b/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx index 9e0751f13cd..6c1cd389249 100644 --- a/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx +++ b/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx @@ -4,7 +4,8 @@ import type { InfiniteData, InitialDataFunction, } from '@tanstack/query-core' -import { assertType, describe, expectTypeOf, it, test } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' +import { assertType, describe, expectTypeOf, it } from 'vitest' import { infiniteQueryOptions } from '../infiniteQueryOptions' import { useInfiniteQuery } from '../useInfiniteQuery' @@ -15,7 +16,7 @@ describe('infiniteQueryOptions', () => { it('should not allow excess properties', () => { assertType( infiniteQueryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('data'), getNextPageParam: () => 1, initialPageParam: 1, @@ -26,7 +27,7 @@ describe('infiniteQueryOptions', () => { }) it('should infer types for callbacks', () => { infiniteQueryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('data'), staleTime: 1000, getNextPageParam: () => 1, @@ -38,7 +39,7 @@ describe('infiniteQueryOptions', () => { }) it('should work when passed to useInfiniteQuery', () => { const options = infiniteQueryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, @@ -53,7 +54,7 @@ describe('infiniteQueryOptions', () => { }) it('should work when passed to useSuspenseInfiniteQuery', () => { const options = infiniteQueryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, @@ -65,7 +66,7 @@ describe('infiniteQueryOptions', () => { }) it('should work when passed to fetchInfiniteQuery', async () => { const options = infiniteQueryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, @@ -76,61 +77,61 @@ describe('infiniteQueryOptions', () => { expectTypeOf(data).toEqualTypeOf>() }) it('should tag the queryKey with the result type of the QueryFn', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf>() }) it('should tag the queryKey even if no promise is returned', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: queryKey(), queryFn: () => 'string', getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf>() }) it('should tag the queryKey with the result type of the QueryFn if select is used', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), select: (data) => data.pages, getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf>() }) it('should return the proper type when passed to getQueryData', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, }) const queryClient = new QueryClient() - const data = queryClient.getQueryData(queryKey) + const data = queryClient.getQueryData(tagged) expectTypeOf(data).toEqualTypeOf< InfiniteData | undefined >() }) it('should properly type when passed to setQueryData', () => { - const { queryKey } = infiniteQueryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = infiniteQueryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, }) const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, (prev) => { + const data = queryClient.setQueryData(tagged, (prev) => { expectTypeOf(prev).toEqualTypeOf< InfiniteData | undefined >() @@ -143,7 +144,7 @@ describe('infiniteQueryOptions', () => { }) it('should throw a type error when using queryFn with skipToken in a suspense query', () => { const options = infiniteQueryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve('string'), getNextPageParam: () => 1, @@ -154,10 +155,10 @@ describe('infiniteQueryOptions', () => { expectTypeOf(data).toEqualTypeOf>() }) - test('should not be allowed to be passed to non-infinite query functions', () => { + it('should not be allowed to be passed to non-infinite query functions', () => { const queryClient = new QueryClient() const options = infiniteQueryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, @@ -180,10 +181,10 @@ describe('infiniteQueryOptions', () => { ) }) - test('allow optional initialData function', () => { + it('allow optional initialData function', () => { const initialData: { example: boolean } | undefined = { example: true } const queryOptions = infiniteQueryOptions({ - queryKey: ['example'], + queryKey: queryKey(), queryFn: () => initialData, initialData: initialData ? () => ({ pages: [initialData], pageParams: [] }) @@ -191,17 +192,17 @@ describe('infiniteQueryOptions', () => { getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryOptions.initialData).toMatchTypeOf< + expectTypeOf(queryOptions.initialData).toExtend< | InitialDataFunction> | InfiniteData<{ example: boolean }, number> | undefined >() }) - test('allow optional initialData object', () => { + it('allow optional initialData object', () => { const initialData: { example: boolean } | undefined = { example: true } const queryOptions = infiniteQueryOptions({ - queryKey: ['example'], + queryKey: queryKey(), queryFn: () => initialData, initialData: initialData ? { pages: [initialData], pageParams: [] } @@ -209,7 +210,7 @@ describe('infiniteQueryOptions', () => { getNextPageParam: () => 1, initialPageParam: 1, }) - expectTypeOf(queryOptions.initialData).toMatchTypeOf< + expectTypeOf(queryOptions.initialData).toExtend< | InitialDataFunction> | InfiniteData<{ example: boolean }, number> | undefined diff --git a/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx b/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx index 88017ced53b..4ba6cddbcb0 100644 --- a/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx +++ b/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx @@ -1,3 +1,4 @@ +import { queryKey } from '@tanstack/query-test-utils' import { describe, expect, it } from 'vitest' import { infiniteQueryOptions } from '../infiniteQueryOptions' @@ -5,8 +6,9 @@ import type { UseInfiniteQueryOptions } from '../types' describe('infiniteQueryOptions', () => { it('should return the object received as a parameter without any modification.', () => { + const key = queryKey() const object: UseInfiniteQueryOptions = { - queryKey: ['key'], + queryKey: key, queryFn: () => Promise.resolve(5), getNextPageParam: () => null, initialPageParam: null, diff --git a/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx b/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx index b945477f8dc..86f48506a53 100644 --- a/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx +++ b/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx @@ -5,6 +5,7 @@ import type { MutationState, WithRequired, } from '@tanstack/query-core' +import { queryKey } from '@tanstack/query-test-utils' import { assertType, describe, expectTypeOf, it } from 'vitest' import { useIsMutating, useMutation, useMutationState } from '..' @@ -16,7 +17,7 @@ describe('mutationOptions', () => { // @ts-expect-error this is a good error, because onMutates does not exist! mutationOptions({ mutationFn: () => Promise.resolve(5), - mutationKey: ['key'], + mutationKey: queryKey(), onMutates: 1000, onSuccess: (data) => { expectTypeOf(data).toEqualTypeOf() @@ -27,7 +28,7 @@ describe('mutationOptions', () => { it('should infer types for callbacks', () => { mutationOptions({ mutationFn: () => Promise.resolve(5), - mutationKey: ['key'], + mutationKey: queryKey(), onSuccess: (data) => { expectTypeOf(data).toEqualTypeOf() }, @@ -39,7 +40,7 @@ describe('mutationOptions', () => { mutationFn: () => { throw new Error('fail') }, - mutationKey: ['key'], + mutationKey: queryKey(), onError: (error) => { expectTypeOf(error).toEqualTypeOf() }, @@ -52,14 +53,14 @@ describe('mutationOptions', () => { expectTypeOf(vars).toEqualTypeOf<{ id: string }>() return Promise.resolve(5) }, - mutationKey: ['with-vars'], + mutationKey: queryKey(), }) }) it('should infer result type correctly', () => { mutationOptions({ mutationFn: () => Promise.resolve(5), - mutationKey: ['key'], + mutationKey: queryKey(), onMutate: () => { return { name: 'onMutateResult' } }, @@ -75,7 +76,7 @@ describe('mutationOptions', () => { expectTypeOf(context).toEqualTypeOf() return Promise.resolve(5) }, - mutationKey: ['key'], + mutationKey: queryKey(), onMutate: (_variables, context) => { expectTypeOf(context).toEqualTypeOf() }, @@ -113,7 +114,7 @@ describe('mutationOptions', () => { expectTypeOf( mutationOptions({ mutationFn: (id: string) => Promise.resolve(id.length), - mutationKey: ['key'], + mutationKey: queryKey(), onSuccess: (data) => { expectTypeOf(data).toEqualTypeOf() }, @@ -139,7 +140,7 @@ describe('mutationOptions', () => { it('should infer types when used with useMutation', () => { const mutation = useMutation( mutationOptions({ - mutationKey: ['key'], + mutationKey: queryKey(), mutationFn: () => Promise.resolve('data'), onSuccess: (data) => { expectTypeOf(data).toEqualTypeOf() @@ -164,7 +165,7 @@ describe('mutationOptions', () => { it('should infer types when used with useIsMutating', () => { const isMutating = useIsMutating( mutationOptions({ - mutationKey: ['key'], + mutationKey: queryKey(), mutationFn: () => Promise.resolve(5), }), ) @@ -183,7 +184,7 @@ describe('mutationOptions', () => { const isMutating = queryClient.isMutating( mutationOptions({ - mutationKey: ['key'], + mutationKey: queryKey(), mutationFn: () => Promise.resolve(5), }), ) @@ -200,7 +201,7 @@ describe('mutationOptions', () => { it('should infer types when used with useMutationState', () => { const mutationState = useMutationState({ filters: mutationOptions({ - mutationKey: ['key'], + mutationKey: queryKey(), mutationFn: () => Promise.resolve(5), }), }) diff --git a/packages/preact-query/src/__tests__/mutationOptions.test.tsx b/packages/preact-query/src/__tests__/mutationOptions.test.tsx index 359919d121d..8b3864b4688 100644 --- a/packages/preact-query/src/__tests__/mutationOptions.test.tsx +++ b/packages/preact-query/src/__tests__/mutationOptions.test.tsx @@ -1,6 +1,6 @@ import { QueryClient } from '@tanstack/query-core' import type { MutationState } from '@tanstack/query-core' -import { sleep } from '@tanstack/query-test-utils' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { fireEvent } from '@testing-library/preact' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -38,8 +38,9 @@ describe('mutationOptions', () => { it('should return the number of fetching mutations when used with useIsMutating (with mutationKey in mutationOptions)', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts = mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => sleep(50).then(() => 'data'), }) @@ -129,8 +130,9 @@ describe('mutationOptions', () => { it('should return the number of fetching mutations when used with useIsMutating', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => sleep(50).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -181,8 +183,9 @@ describe('mutationOptions', () => { it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['key'], + mutationKey: key, mutationFn: () => sleep(50).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -235,8 +238,9 @@ describe('mutationOptions', () => { it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(500).then(() => 'data'), }) @@ -298,8 +302,9 @@ describe('mutationOptions', () => { it('should return the number of fetching mutations when used with queryClient.isMutating', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(500).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -336,8 +341,9 @@ describe('mutationOptions', () => { it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpt1.mutationKey)', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(500).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -378,8 +384,9 @@ describe('mutationOptions', () => { MutationState > = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(10).then(() => 'data'), }) @@ -447,8 +454,9 @@ describe('mutationOptions', () => { MutationState > = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(10).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ @@ -489,8 +497,9 @@ describe('mutationOptions', () => { MutationState > = [] const queryClient = new QueryClient() + const key = queryKey() const mutationOpts1 = mutationOptions({ - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => sleep(10).then(() => 'data1'), }) const mutationOpts2 = mutationOptions({ diff --git a/packages/preact-query/src/__tests__/queryOptions.test-d.tsx b/packages/preact-query/src/__tests__/queryOptions.test-d.tsx index 199400b6b32..6f0d34c6bac 100644 --- a/packages/preact-query/src/__tests__/queryOptions.test-d.tsx +++ b/packages/preact-query/src/__tests__/queryOptions.test-d.tsx @@ -9,6 +9,7 @@ import type { InitialDataFunction, QueryObserverResult, } from '@tanstack/query-core' +import { queryKey } from '@tanstack/query-test-utils' import { assertType, describe, expectTypeOf, it } from 'vitest' import { queryOptions } from '../queryOptions' @@ -21,7 +22,7 @@ describe('queryOptions', () => { it('should not allow excess properties', () => { assertType( queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), // @ts-expect-error this is a good error, because stallTime does not exist! stallTime: 1000, @@ -30,7 +31,7 @@ describe('queryOptions', () => { }) it('should infer types for callbacks', () => { queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), staleTime: 1000, select: (data) => { @@ -40,7 +41,7 @@ describe('queryOptions', () => { }) it('should work when passed to useQuery', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) @@ -49,7 +50,7 @@ describe('queryOptions', () => { }) it('should work when passed to useSuspenseQuery', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) @@ -59,7 +60,7 @@ describe('queryOptions', () => { it('should work when passed to fetchQuery', async () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) @@ -68,7 +69,7 @@ describe('queryOptions', () => { }) it('should work when passed to useQueries', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) @@ -79,90 +80,90 @@ describe('queryOptions', () => { expectTypeOf(data).toEqualTypeOf() }) it('should tag the queryKey with the result type of the QueryFn', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = queryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf() }) it('should tag the queryKey even if no promise is returned', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = queryOptions({ + queryKey: queryKey(), queryFn: () => 5, }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf() }) it('should tag the queryKey with unknown if there is no queryFn', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = queryOptions({ + queryKey: queryKey(), }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf() }) it('should tag the queryKey with the result type of the QueryFn if select is used', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = queryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve(5), select: (data) => data.toString(), }) - expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf() }) it('should return the proper type when passed to getQueryData', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = queryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) const queryClient = new QueryClient() - const data = queryClient.getQueryData(queryKey) + const data = queryClient.getQueryData(tagged) expectTypeOf(data).toEqualTypeOf() }) it('should return the proper type when passed to getQueryState', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = queryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) const queryClient = new QueryClient() - const state = queryClient.getQueryState(queryKey) + const state = queryClient.getQueryState(tagged) expectTypeOf(state?.data).toEqualTypeOf() }) it('should properly type updaterFn when passed to setQueryData', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = queryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, (prev) => { + const data = queryClient.setQueryData(tagged, (prev) => { expectTypeOf(prev).toEqualTypeOf() return prev }) expectTypeOf(data).toEqualTypeOf() }) it('should properly type value when passed to setQueryData', () => { - const { queryKey } = queryOptions({ - queryKey: ['key'], + const { queryKey: tagged } = queryOptions({ + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) const queryClient = new QueryClient() // @ts-expect-error value should be a number - queryClient.setQueryData(queryKey, '5') + queryClient.setQueryData(tagged, '5') // @ts-expect-error value should be a number - queryClient.setQueryData(queryKey, () => '5') + queryClient.setQueryData(tagged, () => '5') - const data = queryClient.setQueryData(queryKey, 5) + const data = queryClient.setQueryData(tagged, 5) expectTypeOf(data).toEqualTypeOf() }) it('should infer even if there is a conditional skipToken', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), }) @@ -173,7 +174,7 @@ describe('queryOptions', () => { it('should infer to unknown if we disable a query with just a skipToken', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: skipToken, }) @@ -184,7 +185,7 @@ describe('queryOptions', () => { it('should throw a type error when using queryFn with skipToken in a suspense query', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), }) // @ts-expect-error TS2345 @@ -194,7 +195,7 @@ describe('queryOptions', () => { it('should return the proper type when passed to QueriesObserver', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) @@ -208,7 +209,7 @@ describe('queryOptions', () => { it('should allow undefined response in initialData', () => { assertType((id: string | null) => queryOptions({ - queryKey: ['todo', id], + queryKey: [...queryKey(), id], queryFn: () => Promise.resolve({ id: '1', @@ -228,11 +229,11 @@ describe('queryOptions', () => { it('should allow optional initialData object', () => { const testFn = (id?: string) => { const options = queryOptions({ - queryKey: ['test'], + queryKey: queryKey(), queryFn: () => Promise.resolve('something string'), initialData: id ? 'initial string' : undefined, }) - expectTypeOf(options.initialData).toMatchTypeOf< + expectTypeOf(options.initialData).toExtend< InitialDataFunction | string | undefined >() } @@ -248,7 +249,7 @@ describe('queryOptions', () => { } const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(1), }) diff --git a/packages/preact-query/src/__tests__/ssr-hydration.test.tsx b/packages/preact-query/src/__tests__/ssr-hydration.test.tsx index 10154d01890..c090696811e 100644 --- a/packages/preact-query/src/__tests__/ssr-hydration.test.tsx +++ b/packages/preact-query/src/__tests__/ssr-hydration.test.tsx @@ -1,3 +1,4 @@ +import { queryKey } from '@tanstack/query-test-utils' import { act } from '@testing-library/preact' import { hydrate as preactHydrate, render } from 'preact' import type { VNode } from 'preact' @@ -36,6 +37,9 @@ function PrintStateComponent({ componentName, result }: any): any { } describe('Server side rendering with de/rehydration', () => { + const successKey = queryKey() + const errorKey = queryKey() + beforeAll(() => { vi.useFakeTimers() }) @@ -53,7 +57,7 @@ describe('Server side rendering with de/rehydration', () => { // -- Shared part -- function SuccessComponent() { const result = useQuery({ - queryKey: ['success'], + queryKey: successKey, queryFn: () => fetchDataSuccess('success!'), }) return ( @@ -69,7 +73,7 @@ describe('Server side rendering with de/rehydration', () => { queryCache: prefetchCache, }) await prefetchClient.prefetchQuery({ - queryKey: ['success'], + queryKey: successKey, queryFn: () => fetchDataSuccess('success'), }) const dehydratedStateServer = dehydrate(prefetchClient) @@ -130,7 +134,7 @@ describe('Server side rendering with de/rehydration', () => { // -- Shared part -- function ErrorComponent() { const result = useQuery({ - queryKey: ['error'], + queryKey: errorKey, queryFn: () => fetchDataError(), retry: false, }) @@ -146,7 +150,7 @@ describe('Server side rendering with de/rehydration', () => { queryCache: prefetchCache, }) await prefetchClient.prefetchQuery({ - queryKey: ['error'], + queryKey: errorKey, queryFn: () => fetchDataError(), }) const dehydratedStateServer = dehydrate(prefetchClient) @@ -207,7 +211,7 @@ describe('Server side rendering with de/rehydration', () => { // -- Shared part -- function SuccessComponent() { const result = useQuery({ - queryKey: ['success'], + queryKey: successKey, queryFn: () => fetchDataSuccess('success!'), }) return ( diff --git a/packages/preact-query/src/__tests__/ssr.test.tsx b/packages/preact-query/src/__tests__/ssr.test.tsx index d543a57501f..608ec4e6943 100644 --- a/packages/preact-query/src/__tests__/ssr.test.tsx +++ b/packages/preact-query/src/__tests__/ssr.test.tsx @@ -26,6 +26,7 @@ describe('Server Side Rendering', () => { }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) @@ -53,8 +54,6 @@ describe('Server Side Rendering', () => { expect(markup).toContain('status pending') expect(queryFn).toHaveBeenCalledTimes(0) - - queryCache.clear() }) it('should add prefetched data to cache', async () => { @@ -70,8 +69,6 @@ describe('Server Side Rendering', () => { expect(data).toBe('data') expect(queryCache.find({ queryKey: key })?.state.data).toBe('data') - - queryCache.clear() }) it('should return existing data from the cache', async () => { @@ -101,8 +98,6 @@ describe('Server Side Rendering', () => { expect(markup).toContain('status success') expect(queryFn).toHaveBeenCalledTimes(1) - - queryCache.clear() }) it('should add initialData to the cache', () => { @@ -133,8 +128,6 @@ describe('Server Side Rendering', () => { const keys = queryCache.getAll().map((query) => query.queryKey) expect(keys).toEqual([[key, 1]]) - - queryCache.clear() }) it('useMutationState should return empty array', () => { @@ -151,8 +144,6 @@ describe('Server Side Rendering', () => { ) expect(markup).toContain('mutationState: 0') - - queryCache.clear() }) it('useInfiniteQuery should return the correct state', async () => { @@ -190,7 +181,5 @@ describe('Server Side Rendering', () => { expect(markup).toContain('page 1') expect(queryFn).toHaveBeenCalledTimes(1) - - queryCache.clear() }) }) diff --git a/packages/preact-query/src/__tests__/suspense.test.tsx b/packages/preact-query/src/__tests__/suspense.test.tsx index e731f8121ab..6ddb64c9007 100644 --- a/packages/preact-query/src/__tests__/suspense.test.tsx +++ b/packages/preact-query/src/__tests__/suspense.test.tsx @@ -52,13 +52,14 @@ describe('Suspense Timer Tests', () => { }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) it('should enforce minimum staleTime of 1000ms when using suspense with number', async () => { const TestComponent = createTestQuery({ fetchCount, - queryKey: ['test'], + queryKey: queryKey(), staleTime: 10, }) @@ -84,7 +85,7 @@ describe('Suspense Timer Tests', () => { it('should enforce minimum staleTime of 1000ms when using suspense with function', async () => { const TestComponent = createTestQuery({ fetchCount, - queryKey: ['test-func'], + queryKey: queryKey(), staleTime: () => 10, }) diff --git a/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx index ec858b17038..b11002c9e2c 100644 --- a/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx +++ b/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx @@ -1,5 +1,6 @@ import { QueryClient } from '@tanstack/query-core' import type { InfiniteData } from '@tanstack/query-core' +import { queryKey } from '@tanstack/query-test-utils' import { describe, expectTypeOf, it } from 'vitest' import { useInfiniteQuery } from '../useInfiniteQuery' @@ -7,7 +8,7 @@ import { useInfiniteQuery } from '../useInfiniteQuery' describe('pageParam', () => { it('initialPageParam should define type of param passed to queryFunctionContext', () => { useInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: ({ pageParam }) => { expectTypeOf(pageParam).toEqualTypeOf() }, @@ -18,7 +19,7 @@ describe('pageParam', () => { it('direction should be passed to queryFn of useInfiniteQuery', () => { useInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: ({ direction }) => { expectTypeOf(direction).toEqualTypeOf<'forward' | 'backward'>() }, @@ -30,7 +31,7 @@ describe('pageParam', () => { it('initialPageParam should define type of param passed to queryFunctionContext for fetchInfiniteQuery', () => { const queryClient = new QueryClient() queryClient.fetchInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: ({ pageParam }) => { expectTypeOf(pageParam).toEqualTypeOf() }, @@ -41,7 +42,7 @@ describe('pageParam', () => { it('initialPageParam should define type of param passed to queryFunctionContext for prefetchInfiniteQuery', () => { const queryClient = new QueryClient() queryClient.prefetchInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: ({ pageParam }) => { expectTypeOf(pageParam).toEqualTypeOf() }, @@ -52,7 +53,7 @@ describe('pageParam', () => { describe('select', () => { it('should still return paginated data if no select result', () => { const infiniteQuery = useInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: ({ pageParam }) => { return pageParam * 5 }, @@ -68,7 +69,7 @@ describe('select', () => { it('should be able to transform data to arbitrary result', () => { const infiniteQuery = useInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: ({ pageParam }) => { return pageParam * 5 }, @@ -86,7 +87,7 @@ describe('select', () => { describe('getNextPageParam / getPreviousPageParam', () => { it('should get typed params', () => { const infiniteQuery = useInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: ({ pageParam }) => { return String(pageParam) }, @@ -127,7 +128,7 @@ describe('error booleans', () => { isLoadingError, isRefetchError, } = useInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: ({ pageParam }) => { return pageParam * 5 }, diff --git a/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx b/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx index cfbf15e6952..5a96f0dbf00 100644 --- a/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx +++ b/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx @@ -45,24 +45,27 @@ const fetchItems = async ( } describe('useInfiniteQuery', () => { + let queryCache: QueryCache + let queryClient: QueryClient + beforeEach(() => { vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ + queryCache, + defaultOptions: { + queries: { + experimental_prefetchInRender: true, + }, + }, + }) }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) - const queryCache = new QueryCache() - const queryClient = new QueryClient({ - queryCache, - defaultOptions: { - queries: { - experimental_prefetchInRender: true, - }, - }, - }) - it('should return the correct states for a successful query', async () => { const key = queryKey() const states: Array>> = [] @@ -973,7 +976,7 @@ describe('useInfiniteQuery', () => { await vi.advanceTimersByTimeAsync(160) const expectedCallCount = 3 - expect(fetchPage).toBeCalledTimes(expectedCallCount) + expect(fetchPage).toHaveBeenCalledTimes(expectedCallCount) expect(onAborts).toHaveLength(expectedCallCount) expect(abortListeners).toHaveLength(expectedCallCount) @@ -1048,7 +1051,7 @@ describe('useInfiniteQuery', () => { await vi.advanceTimersByTimeAsync(160) const expectedCallCount = 2 - expect(fetchPage).toBeCalledTimes(expectedCallCount) + expect(fetchPage).toHaveBeenCalledTimes(expectedCallCount) expect(onAborts).toHaveLength(expectedCallCount) expect(abortListeners).toHaveLength(expectedCallCount) diff --git a/packages/preact-query/src/__tests__/useMutation.test-d.tsx b/packages/preact-query/src/__tests__/useMutation.test-d.tsx new file mode 100644 index 00000000000..7b7a5806058 --- /dev/null +++ b/packages/preact-query/src/__tests__/useMutation.test-d.tsx @@ -0,0 +1,138 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import { useMutation } from '../useMutation' +import type { DefaultError } from '@tanstack/query-core' +import type { UseMutationResult } from '../types' + +describe('useMutation', () => { + it('should infer TData from mutationFn return type', () => { + const mutation = useMutation({ + mutationFn: () => Promise.resolve('data'), + }) + + expectTypeOf(mutation.data).toEqualTypeOf() + expectTypeOf(mutation.error).toEqualTypeOf() + }) + + it('should infer TVariables from mutationFn parameter', () => { + const mutation = useMutation({ + mutationFn: (vars: { id: string }) => Promise.resolve(vars.id), + }) + + expectTypeOf(mutation.mutate).toBeCallableWith({ id: '1' }) + expectTypeOf(mutation.data).toEqualTypeOf() + }) + + it('should infer TOnMutateResult from onMutate return type', () => { + useMutation({ + mutationFn: () => Promise.resolve('data'), + onMutate: () => { + return { token: 'abc' } + }, + onSuccess: (_data, _variables, onMutateResult) => { + expectTypeOf(onMutateResult).toEqualTypeOf<{ token: string }>() + }, + onError: (_error, _variables, onMutateResult) => { + expectTypeOf(onMutateResult).toEqualTypeOf< + { token: string } | undefined + >() + }, + }) + }) + + it('should allow explicit generic types', () => { + const mutation = useMutation({ + mutationFn: (vars) => { + expectTypeOf(vars).toEqualTypeOf<{ id: number }>() + return Promise.resolve('result') + }, + }) + + expectTypeOf(mutation.data).toEqualTypeOf() + expectTypeOf(mutation.error).toEqualTypeOf() + }) + + it('should return correct UseMutationResult type', () => { + const mutation = useMutation({ + mutationFn: () => Promise.resolve(42), + }) + + expectTypeOf(mutation).toEqualTypeOf< + UseMutationResult + >() + }) + + it('should type mutateAsync with correct return type', () => { + const mutation = useMutation({ + mutationFn: (id: string) => Promise.resolve(id.length), + }) + + expectTypeOf(mutation.mutateAsync).toBeCallableWith('test') + expectTypeOf(mutation.mutateAsync('test')).toEqualTypeOf>() + }) + + it('should default TVariables to void when mutationFn has no parameters', () => { + const mutation = useMutation({ + mutationFn: () => Promise.resolve('data'), + }) + + expectTypeOf(mutation.mutate).toBeCallableWith() + }) + + it('should infer custom TError type', () => { + class CustomError extends Error { + code: number + constructor(code: number) { + super() + this.code = code + } + } + + const mutation = useMutation({ + mutationFn: () => Promise.resolve('data'), + }) + + expectTypeOf(mutation.error).toEqualTypeOf() + expectTypeOf(mutation.data).toEqualTypeOf() + }) + + it('should infer types for onSettled callback', () => { + useMutation({ + mutationFn: () => Promise.resolve(42), + onSettled: (data, error, _variables, _onMutateResult) => { + expectTypeOf(data).toEqualTypeOf() + expectTypeOf(error).toEqualTypeOf() + }, + }) + }) + + it('should infer custom TError in onError callback', () => { + class CustomError extends Error { + code: number + constructor(code: number) { + super() + this.code = code + } + } + + useMutation({ + mutationFn: () => Promise.resolve('data'), + onError: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }) + }) + + it('should accept queryClient as second argument', () => { + const queryClient = new QueryClient() + + const mutation = useMutation( + { + mutationFn: () => Promise.resolve('data'), + }, + queryClient, + ) + + expectTypeOf(mutation.data).toEqualTypeOf() + }) +}) diff --git a/packages/preact-query/src/__tests__/useMutation.test.tsx b/packages/preact-query/src/__tests__/useMutation.test.tsx index f6d75e8eaa7..4ed75ae8df2 100644 --- a/packages/preact-query/src/__tests__/useMutation.test.tsx +++ b/packages/preact-query/src/__tests__/useMutation.test.tsx @@ -28,6 +28,7 @@ describe('useMutation', () => { }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) @@ -99,6 +100,332 @@ describe('useMutation', () => { expect(queryByRole('heading')).toBeNull() }) + it('should call mutate callbacks when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutate.onSuccess', 'mutate.onSettled']) + }) + + it('should call mutateAsync callbacks when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + }) + + useEffect(() => { + setActTimeout(async () => { + await mutateAsync('todo', { + onSuccess: () => { + callbacks.push('mutateAsync.onSuccess') + }, + onSettled: () => { + callbacks.push('mutateAsync.onSettled') + }, + }) + }, 0) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual([ + 'mutateAsync.onSuccess', + 'mutateAsync.onSettled', + ]) + }) + + it('should call mutate error callbacks when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: (_text: string) => + sleep(10).then(() => { + throw new Error('oops') + }), + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutate.onError', 'mutate.onSettled']) + }) + + it('should call mutateAsync error callbacks when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: async (_text: string) => + sleep(10).then(() => { + throw new Error('oops') + }), + }) + + useEffect(() => { + setActTimeout(async () => { + try { + await mutateAsync('todo', { + onError: () => { + callbacks.push('mutateAsync.onError') + }, + onSettled: () => { + callbacks.push('mutateAsync.onSettled') + }, + }) + } catch {} + }, 0) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutateAsync.onError', 'mutateAsync.onSettled']) + }) + + it('should call only mutate onSuccess when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutate.onSuccess']) + }) + + it('should call only mutate onError when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: (_text: string) => + sleep(10).then(() => { + throw new Error('oops') + }), + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutate.onError']) + }) + + it('should call only mutate onSettled when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutate.onSettled']) + }) + + it('should call only mutateAsync onSuccess when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + }) + + useEffect(() => { + setActTimeout(async () => { + await mutateAsync('todo', { + onSuccess: () => { + callbacks.push('mutateAsync.onSuccess') + }, + }) + }, 0) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutateAsync.onSuccess']) + }) + + it('should call only mutateAsync onError when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: async (_text: string) => + sleep(10).then(() => { + throw new Error('oops') + }), + }) + + useEffect(() => { + setActTimeout(async () => { + try { + await mutateAsync('todo', { + onError: () => { + callbacks.push('mutateAsync.onError') + }, + }) + } catch {} + }, 0) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutateAsync.onError']) + }) + + it('should call only mutateAsync onSettled when useMutation has no callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + }) + + useEffect(() => { + setActTimeout(async () => { + await mutateAsync('todo', { + onSettled: () => { + callbacks.push('mutateAsync.onSettled') + }, + }) + }, 0) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['mutateAsync.onSettled']) + }) + it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => { let count = 0 const onSuccessMock = vi.fn() @@ -157,10 +484,9 @@ describe('useMutation', () => { return Promise.reject(new Error('Error test Jonas')) }) - mutateFn.mockImplementation(async (value) => { - await sleep(10) - return Promise.resolve(value) - }) + mutateFn.mockImplementation((value) => + sleep(10).then(() => Promise.resolve(value)), + ) function Page() { const { mutate, failureCount, failureReason, data, status } = useMutation( @@ -264,44 +590,253 @@ describe('useMutation', () => { ) }) - it('should be able to override the useMutation success callbacks', async () => { + it('should be able to call `onSuccess` callback after successful mutate', async () => { const callbacks: Array = [] function Page() { - const { mutateAsync } = useMutation({ - mutationFn: (text: string) => Promise.resolve(text), + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), onSuccess: () => { callbacks.push('useMutation.onSuccess') - return Promise.resolve() - }, - onSettled: () => { - callbacks.push('useMutation.onSettled') - return Promise.resolve() }, }) - useEffect(() => { - setActTimeout(async () => { - try { - const result = await mutateAsync('todo', { + return ( + + ) } - renderWithClient(queryClient, ) + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['useMutation.onSuccess', 'mutate.onSuccess']) + }) + + it('should be able to call `onError` callback after failed mutate', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: (_text: string) => + sleep(10).then(() => { + throw new Error('oops') + }), + onError: () => { + callbacks.push('useMutation.onError') + }, + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['useMutation.onError', 'mutate.onError']) + }) + + it('should be able to call `onSettled` callback after mutate', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + onSettled: () => { + callbacks.push('useMutation.onSettled') + }, + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['useMutation.onSettled', 'mutate.onSettled']) + }) + + it('should be able to call `onSuccess` callback after successful mutateAsync', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + onSuccess: () => { + callbacks.push('useMutation.onSuccess') + }, + }) + + useEffect(() => { + setActTimeout(async () => { + await mutateAsync('todo', { + onSuccess: () => { + callbacks.push('mutateAsync.onSuccess') + }, + }) + }, 0) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual([ + 'useMutation.onSuccess', + 'mutateAsync.onSuccess', + ]) + }) + + it('should be able to call `onError` callback after failed mutateAsync', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: async (_text: string) => + sleep(10).then(() => { + throw new Error('oops') + }), + onError: () => { + callbacks.push('useMutation.onError') + }, + }) + + useEffect(() => { + setActTimeout(async () => { + try { + await mutateAsync('todo', { + onError: () => { + callbacks.push('mutateAsync.onError') + }, + }) + } catch {} + }, 0) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual(['useMutation.onError', 'mutateAsync.onError']) + }) + + it('should be able to call `onSettled` callback after mutateAsync', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + onSettled: () => { + callbacks.push('useMutation.onSettled') + }, + }) + + useEffect(() => { + setActTimeout(async () => { + await mutateAsync('todo', { + onSettled: () => { + callbacks.push('mutateAsync.onSettled') + }, + }) + }, 0) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual([ + 'useMutation.onSettled', + 'mutateAsync.onSettled', + ]) + }) + + it('should be able to override the useMutation success callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: (text: string) => Promise.resolve(text), + onSuccess: () => { + callbacks.push('useMutation.onSuccess') + return Promise.resolve() + }, + onSettled: () => { + callbacks.push('useMutation.onSettled') + return Promise.resolve() + }, + }) + + useEffect(() => { + setActTimeout(async () => { + try { + const result = await mutateAsync('todo', { + onSuccess: () => { + callbacks.push('mutateAsync.onSuccess') + return Promise.resolve() + }, + onSettled: () => { + callbacks.push('mutateAsync.onSettled') + return Promise.resolve() + }, + }) + callbacks.push(`mutateAsync.result:${result}`) + } catch {} + }, 10) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) await vi.advanceTimersByTimeAsync(10) @@ -365,14 +900,102 @@ describe('useMutation', () => { ]) }) + it('should be able to override the error callbacks when using mutate', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: async (_text: string) => + sleep(10).then(() => Promise.reject(new Error('oops'))), + onError: () => { + callbacks.push('useMutation.onError') + }, + onSettled: () => { + callbacks.push('useMutation.onSettled') + }, + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual([ + 'useMutation.onError', + 'useMutation.onSettled', + 'mutate.onError', + 'mutate.onSettled', + ]) + }) + + it('should be able to override the settled callbacks when using mutate', async () => { + const callbacks: Array = [] + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + onSuccess: () => { + callbacks.push('useMutation.onSuccess') + }, + onSettled: () => { + callbacks.push('useMutation.onSettled') + }, + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual([ + 'useMutation.onSuccess', + 'useMutation.onSettled', + 'mutate.onSuccess', + 'mutate.onSettled', + ]) + }) + it('should be able to use mutation defaults', async () => { const key = queryKey() queryClient.setMutationDefaults(key, { - mutationFn: async (text: string) => { - await sleep(10) - return text - }, + mutationFn: (text: string) => sleep(10).then(() => text), }) const states: Array> = [] @@ -786,29 +1409,138 @@ describe('useMutation', () => { consoleMock.mockRestore() }) - it('should pass meta to mutation', async () => { - const errorMock = vi.fn() - const successMock = vi.fn() - - const queryClientMutationMeta = new QueryClient({ - mutationCache: new MutationCache({ - onSuccess: (_, __, ___, mutation) => { - successMock(mutation.meta?.metaSuccessMessage) - }, - onError: (_, __, ___, mutation) => { - errorMock(mutation.meta?.metaErrorMessage) - }, - }), - }) + it('should not throw an error when throwOnError is set to false', async () => { + function Page() { + const { mutate, error } = useMutation({ + mutationFn: () => + sleep(10).then(() => { + throw new Error('Expected mock error') + }), + throwOnError: false, + }) + + return ( +
+ +
error: {error?.message ?? 'null'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + + expect(rendered.getByText('error: Expected mock error')).toBeInTheDocument() + }) + + it('should not throw an error when throwOnError is a function that returns false', async () => { + function Page() { + const { mutate, error } = useMutation({ + mutationFn: () => + sleep(10).then(() => { + throw new Error('Expected mock error') + }), + throwOnError: () => false, + }) + + return ( +
+ +
error: {error?.message ?? 'null'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + + expect(rendered.getByText('error: Expected mock error')).toBeInTheDocument() + }) + + it('should not throw an error when throwOnError is not set', async () => { + function Page() { + const { mutate, error } = useMutation({ + mutationFn: () => + sleep(10).then(() => { + throw new Error('Expected mock error') + }), + }) + + return ( +
+ +
error: {error?.message ?? 'null'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + + expect(rendered.getByText('error: Expected mock error')).toBeInTheDocument() + }) + + it('should pass meta to mutation on success', async () => { + const successMock = vi.fn() + + const queryClientMutationMeta = new QueryClient({ + mutationCache: new MutationCache({ + onSuccess: (_, __, ___, mutation) => { + successMock(mutation.meta?.metaSuccessMessage) + }, + }), + }) const metaSuccessMessage = 'mutation succeeded' - const metaErrorMessage = 'mutation failed' function Page() { const { mutate: succeed, isSuccess } = useMutation({ mutationFn: () => Promise.resolve(''), meta: { metaSuccessMessage }, }) + + return ( +
+ + {isSuccess &&
successTest
} +
+ ) + } + + const { getByText, queryByText } = renderWithClient( + queryClientMutationMeta, + , + ) + + fireEvent.click(getByText('succeed')) + + await vi.advanceTimersByTimeAsync(0) + expect(queryByText('successTest')).not.toBeNull() + + expect(successMock).toHaveBeenCalledTimes(1) + expect(successMock).toHaveBeenCalledWith(metaSuccessMessage) + }) + + it('should pass meta to mutation on error', async () => { + const errorMock = vi.fn() + + const queryClientMutationMeta = new QueryClient({ + mutationCache: new MutationCache({ + onError: (_, __, ___, mutation) => { + errorMock(mutation.meta?.metaErrorMessage) + }, + }), + }) + + const metaErrorMessage = 'mutation failed' + + function Page() { const { mutate: error, isError } = useMutation({ mutationFn: () => { return Promise.reject(new Error('')) @@ -818,9 +1550,7 @@ describe('useMutation', () => { return (
- - {isSuccess &&
successTest
} {isError &&
errorTest
}
) @@ -831,15 +1561,11 @@ describe('useMutation', () => { , ) - fireEvent.click(getByText('succeed')) fireEvent.click(getByText('error')) await vi.advanceTimersByTimeAsync(0) - expect(queryByText('successTest')).not.toBeNull() expect(queryByText('errorTest')).not.toBeNull() - expect(successMock).toHaveBeenCalledTimes(1) - expect(successMock).toHaveBeenCalledWith(metaSuccessMessage) expect(errorMock).toHaveBeenCalledTimes(1) expect(errorMock).toHaveBeenCalledWith(metaErrorMessage) }) @@ -1033,10 +1759,7 @@ describe('useMutation', () => { function Page() { const mutation = useMutation({ - mutationFn: async (_text: string) => { - await sleep(10) - return 'result' - }, + mutationFn: (_text: string) => sleep(10).then(() => 'result'), onSuccess: () => Promise.reject(error), onError, }) @@ -1196,4 +1919,483 @@ describe('useMutation', () => { rendered.getByText('data: custom client, status: success'), ).toBeInTheDocument() }) + + it('should be able to chain mutateAsync calls sequentially', async () => { + function Page() { + const [result, setResult] = useState('idle') + + const { mutateAsync: createUserAsync } = useMutation({ + mutationFn: (name: string) => sleep(10).then(() => ({ id: '1', name })), + }) + + const { mutateAsync: updateProfileAsync } = useMutation({ + mutationFn: (userId: string) => + sleep(10).then(() => `profile updated for ${userId}`), + }) + + return ( +
+ +
result: {result}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /chain/i })) + await vi.advanceTimersByTimeAsync(10) + await vi.advanceTimersByTimeAsync(11) + + expect( + rendered.getByText('result: profile updated for 1'), + ).toBeInTheDocument() + }) + + it('should handle error in chained mutateAsync calls', async () => { + function Page() { + const [result, setResult] = useState('idle') + + const { mutateAsync: createUserAsync } = useMutation({ + mutationFn: (_name: string) => + sleep(10).then<{ id: string }>(() => { + throw new Error('create failed') + }), + }) + + const { mutateAsync: updateProfileAsync } = useMutation({ + mutationFn: (userId: string) => + sleep(10).then(() => `profile updated for ${userId}`), + }) + + return ( +
+ +
result: {result}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /chain/i })) + await vi.advanceTimersByTimeAsync(11) + + expect( + rendered.getByText('result: error: create failed'), + ).toBeInTheDocument() + }) + + it('should set success message from mutate callbacks when mutation succeeds', async () => { + function Page() { + const [message, setMessage] = useState('idle') + + const { mutate } = useMutation({ + mutationFn: async (shouldFail: boolean) => { + await sleep(10) + if (shouldFail) { + throw new Error('submission failed') + } + return 'submitted successfully' + }, + retry: false, + }) + + return ( +
+ +
message: {message}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /^submit$/i })) + await vi.advanceTimersByTimeAsync(11) + + expect( + rendered.getByText('message: success: submitted successfully'), + ).toBeInTheDocument() + }) + + it('should set error message from mutate callbacks when mutation fails', async () => { + function Page() { + const [message, setMessage] = useState('idle') + + const { mutate } = useMutation({ + mutationFn: async (shouldFail: boolean) => { + await sleep(10) + if (shouldFail) { + throw new Error('submission failed') + } + return 'submitted successfully' + }, + retry: false, + }) + + return ( +
+ +
message: {message}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /submit fail/i })) + await vi.advanceTimersByTimeAsync(11) + + expect( + rendered.getByText('message: error: submission failed'), + ).toBeInTheDocument() + }) + + it('should handle conditional error with retry using mutate', async () => { + let attempt = 0 + + function Page() { + const [message, setMessage] = useState('idle') + + const { mutate } = useMutation({ + mutationFn: async () => { + await sleep(10) + attempt++ + if (attempt < 2) { + throw new Error('temporary failure') + } + return 'success' + }, + retry: false, + }) + + return ( +
+ +
message: {message}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /submit/i })) + await vi.advanceTimersByTimeAsync(11) + + expect( + rendered.getByText('message: failed, retrying...'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /submit/i })) + await vi.advanceTimersByTimeAsync(11) + + expect(rendered.getByText('message: result: success')).toBeInTheDocument() + }) + + it('should support optimistic update on success', async () => { + function Page() { + const [items, setItems] = useState>([ + 'item1', + 'item2', + 'item3', + ]) + + const [successMessage, setSuccessMessage] = useState('') + + const { mutate } = useMutation({ + mutationFn: (item: string) => sleep(10).then(() => item), + onMutate: (item) => { + const previousItems = [...items] + setItems((prev) => prev.filter((i) => i !== item)) + return { previousItems } + }, + onSuccess: (deletedItem) => { + setSuccessMessage(`deleted: ${deletedItem}`) + }, + onError: (_error, _item, context) => { + if (context?.previousItems) { + setItems(context.previousItems) + } + }, + }) + + return ( +
+ {items.map((item) => ( + + ))} +
items: {items.join(', ')}
+
success: {successMessage || 'none'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('success: none')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /delete item2/i })) + + // optimistic update: item2 removed immediately + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + + // success: item2 stays removed and onSuccess called + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + expect(rendered.getByText('success: deleted: item2')).toBeInTheDocument() + }) + + it('should support optimistic update and rollback on error', async () => { + function Page() { + const [items, setItems] = useState>([ + 'item1', + 'item2', + 'item3', + ]) + + const [message, setMessage] = useState('') + + const { mutate } = useMutation({ + mutationFn: (item: string) => + sleep(10).then(() => { + throw new Error(`Failed to delete ${item}`) + }), + onMutate: (item) => { + const previousItems = [...items] + setItems((prev) => prev.filter((i) => i !== item)) + return { previousItems } + }, + onSuccess: (deletedItem) => { + setMessage(`deleted: ${deletedItem}`) + }, + onError: (_error, _item, context) => { + setMessage('rollback') + if (context?.previousItems) { + setItems(context.previousItems) + } + }, + retry: false, + }) + + return ( +
+ {items.map((item) => ( + + ))} +
items: {items.join(', ')}
+
message: {message || 'none'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('message: none')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /delete item2/i })) + + // optimistic update: item2 removed immediately + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + + // rollback: item2 restored after error, onSuccess not called + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('message: rollback')).toBeInTheDocument() + }) + + it('should be able to run multiple mutateAsync calls in parallel with Promise.all', async () => { + function Page() { + const [result, setResult] = useState('idle') + + const { mutateAsync } = useMutation({ + mutationFn: (file: string) => sleep(10).then(() => `uploaded: ${file}`), + }) + + return ( +
+ +
result: {result}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /upload all/i })) + await vi.advanceTimersByTimeAsync(11) + + expect( + rendered.getByText( + 'result: uploaded: file1, uploaded: file2, uploaded: file3', + ), + ).toBeInTheDocument() + }) + + it('should handle Promise.all rejection when one parallel mutateAsync call fails', async () => { + function Page() { + const [result, setResult] = useState('idle') + + const { mutateAsync } = useMutation({ + mutationFn: async (file: string) => { + await sleep(10) + if (file === 'file2') { + throw new Error('upload failed') + } + return `uploaded: ${file}` + }, + retry: false, + }) + + return ( +
+ +
result: {result}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /upload all/i })) + await vi.advanceTimersByTimeAsync(11) + + expect( + rendered.getByText('result: error: upload failed'), + ).toBeInTheDocument() + }) + + it('should handle partial failure in parallel mutateAsync calls with Promise.allSettled', async () => { + function Page() { + const [result, setResult] = useState('idle') + + const { mutateAsync } = useMutation({ + mutationFn: async (file: string) => { + await sleep(10) + if (file === 'file2') { + throw new Error('upload failed') + } + return `uploaded: ${file}` + }, + retry: false, + }) + + return ( +
+ +
result: {result}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /upload all/i })) + await vi.advanceTimersByTimeAsync(11) + + expect( + rendered.getByText( + 'result: uploaded: file1, error: upload failed, uploaded: file3', + ), + ).toBeInTheDocument() + }) }) diff --git a/packages/preact-query/src/__tests__/useMutationState.test.tsx b/packages/preact-query/src/__tests__/useMutationState.test.tsx index c8097326486..33ff004f325 100644 --- a/packages/preact-query/src/__tests__/useMutationState.test.tsx +++ b/packages/preact-query/src/__tests__/useMutationState.test.tsx @@ -1,4 +1,4 @@ -import { sleep } from '@tanstack/query-test-utils' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { fireEvent, render } from '@testing-library/preact' import { useEffect } from 'preact/hooks' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,6 +18,8 @@ describe('useIsMutating', () => { it('should return the number of fetching mutations', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const mutationKey1 = queryKey() + const mutationKey2 = queryKey() function IsMutating() { const isMutating = useIsMutating() @@ -29,11 +31,11 @@ describe('useIsMutating', () => { function Mutations() { const { mutate: mutate1 } = useMutation({ - mutationKey: ['mutation1'], + mutationKey: mutationKey1, mutationFn: () => sleep(50).then(() => 'data'), }) const { mutate: mutate2 } = useMutation({ - mutationKey: ['mutation2'], + mutationKey: mutationKey2, mutationFn: () => sleep(10).then(() => 'data'), }) @@ -79,20 +81,22 @@ describe('useIsMutating', () => { it('should filter correctly by mutationKey', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const mutationKey1 = queryKey() + const mutationKey2 = queryKey() function IsMutating() { - const isMutating = useIsMutating({ mutationKey: ['mutation1'] }) + const isMutating = useIsMutating({ mutationKey: mutationKey1 }) isMutatingArray.push(isMutating) return null } function Page() { const { mutate: mutate1 } = useMutation({ - mutationKey: ['mutation1'], + mutationKey: mutationKey1, mutationFn: () => sleep(100).then(() => 'data'), }) const { mutate: mutate2 } = useMutation({ - mutationKey: ['mutation2'], + mutationKey: mutationKey2, mutationFn: () => sleep(100).then(() => 'data'), }) @@ -113,11 +117,13 @@ describe('useIsMutating', () => { it('should filter correctly by predicate', async () => { const isMutatingArray: Array = [] const queryClient = new QueryClient() + const mutationKey1 = queryKey() + const mutationKey2 = queryKey() function IsMutating() { const isMutating = useIsMutating({ predicate: (mutation) => - mutation.options.mutationKey?.[0] === 'mutation1', + mutation.options.mutationKey?.[0] === mutationKey1[0], }) isMutatingArray.push(isMutating) return null @@ -125,11 +131,11 @@ describe('useIsMutating', () => { function Page() { const { mutate: mutate1 } = useMutation({ - mutationKey: ['mutation1'], + mutationKey: mutationKey1, mutationFn: () => sleep(100).then(() => 'data'), }) const { mutate: mutate2 } = useMutation({ - mutationKey: ['mutation2'], + mutationKey: mutationKey2, mutationFn: () => sleep(100).then(() => 'data'), }) @@ -149,12 +155,13 @@ describe('useIsMutating', () => { it('should use provided custom queryClient', async () => { const queryClient = new QueryClient() + const key = queryKey() function Page() { const isMutating = useIsMutating({}, queryClient) const { mutate } = useMutation( { - mutationKey: ['mutation1'], + mutationKey: key, mutationFn: () => sleep(10).then(() => 'data'), }, queryClient, @@ -190,7 +197,7 @@ describe('useMutationState', () => { it('should return variables after calling mutate', async () => { const queryClient = new QueryClient() const variables: Array> = [] - const mutationKey = ['mutation'] + const mutationKey = queryKey() function Variables() { variables.push( @@ -206,7 +213,7 @@ describe('useMutationState', () => { function Mutate() { const { mutate, data } = useMutation({ mutationKey, - mutationFn: (input: number) => sleep(150).then(() => 'data' + input), + mutationFn: (input: number) => sleep(150).then(() => `data${input}`), }) return ( diff --git a/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx index 83f2a537c75..048f340991f 100644 --- a/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx +++ b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx @@ -1,3 +1,4 @@ +import { queryKey } from '@tanstack/query-test-utils' import { assertType, describe, expectTypeOf, it } from 'vitest' import { usePrefetchInfiniteQuery } from '..' @@ -5,7 +6,7 @@ import { usePrefetchInfiniteQuery } from '..' describe('usePrefetchInfiniteQuery', () => { it('should return nothing', () => { const result = usePrefetchInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), initialPageParam: 1, getNextPageParam: () => 1, @@ -18,7 +19,7 @@ describe('usePrefetchInfiniteQuery', () => { assertType( // @ts-expect-error TS2345 usePrefetchInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }), ) @@ -27,7 +28,7 @@ describe('usePrefetchInfiniteQuery', () => { it('should not allow refetchInterval, enabled or throwOnError options', () => { assertType( usePrefetchInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), initialPageParam: 1, getNextPageParam: () => 1, @@ -38,7 +39,7 @@ describe('usePrefetchInfiniteQuery', () => { assertType( usePrefetchInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), initialPageParam: 1, getNextPageParam: () => 1, @@ -49,7 +50,7 @@ describe('usePrefetchInfiniteQuery', () => { assertType( usePrefetchInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), initialPageParam: 1, getNextPageParam: () => 1, diff --git a/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx index a62c2ff16cf..f7fdc3fd73c 100644 --- a/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx +++ b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx @@ -25,7 +25,7 @@ const generateInfiniteQueryOptions = ( .mockImplementation(async () => { const currentPageData = data[currentPage] if (!currentPageData) { - throw new Error('No data defined for page ' + currentPage) + throw new Error(`No data defined for page ${currentPage}`) } await sleep(10) @@ -42,8 +42,13 @@ const generateInfiniteQueryOptions = ( } describe('usePrefetchInfiniteQuery', () => { + let queryCache: QueryCache + let queryClient: QueryClient + beforeEach(() => { vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ queryCache }) }) afterEach(() => { @@ -52,9 +57,6 @@ describe('usePrefetchInfiniteQuery', () => { vi.useRealTimers() }) - const queryCache = new QueryCache() - const queryClient = new QueryClient({ queryCache }) - const Fallback = vi.fn().mockImplementation(() =>
Loading...
) function Suspended(props: { diff --git a/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx b/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx index 17f0dc5437d..6f5d5102514 100644 --- a/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx +++ b/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx @@ -1,3 +1,4 @@ +import { queryKey } from '@tanstack/query-test-utils' import { assertType, describe, expectTypeOf, it } from 'vitest' import { skipToken, usePrefetchQuery } from '..' @@ -5,7 +6,7 @@ import { skipToken, usePrefetchQuery } from '..' describe('usePrefetchQuery', () => { it('should return nothing', () => { const result = usePrefetchQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) @@ -15,7 +16,7 @@ describe('usePrefetchQuery', () => { it('should not allow refetchInterval, enabled or throwOnError options', () => { assertType( usePrefetchQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), // @ts-expect-error TS2345 refetchInterval: 1000, @@ -24,7 +25,7 @@ describe('usePrefetchQuery', () => { assertType( usePrefetchQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), // @ts-expect-error TS2345 enabled: true, @@ -33,7 +34,7 @@ describe('usePrefetchQuery', () => { assertType( usePrefetchQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), // @ts-expect-error TS2345 throwOnError: true, @@ -44,14 +45,14 @@ describe('usePrefetchQuery', () => { it('should not allow skipToken in queryFn', () => { assertType( usePrefetchQuery({ - queryKey: ['key'], + queryKey: queryKey(), // @ts-expect-error queryFn: skipToken, }), ) assertType( usePrefetchQuery({ - queryKey: ['key'], + queryKey: queryKey(), // @ts-expect-error queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), }), diff --git a/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx b/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx index c8fa85339e0..49fd0e682e1 100644 --- a/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx +++ b/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx @@ -18,21 +18,20 @@ import { renderWithClient } from './utils' const generateQueryFn = (data: string) => vi .fn<(...args: Array) => Promise>() - .mockImplementation(async () => { - await sleep(10) - - return data - }) + .mockImplementation(() => sleep(10).then(() => data)) describe('usePrefetchQuery', () => { - const queryCache = new QueryCache() - const queryClient = new QueryClient({ queryCache }) + let queryCache: QueryCache + let queryClient: QueryClient beforeEach(() => { vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ queryCache }) }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) diff --git a/packages/preact-query/src/__tests__/useQueries.test-d.tsx b/packages/preact-query/src/__tests__/useQueries.test-d.tsx index ca6cd0cb451..85816953ae6 100644 --- a/packages/preact-query/src/__tests__/useQueries.test-d.tsx +++ b/packages/preact-query/src/__tests__/useQueries.test-d.tsx @@ -1,3 +1,4 @@ +import { queryKey } from '@tanstack/query-test-utils' import { describe, expectTypeOf, it } from 'vitest' import { skipToken } from '..' @@ -9,7 +10,7 @@ import { useQueries } from '../useQueries' describe('UseQueries config object overload', () => { it('TData should always be defined when initialData is provided as an object', () => { const query1 = { - queryKey: ['key1'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -21,13 +22,13 @@ describe('UseQueries config object overload', () => { } const query2 = { - queryKey: ['key2'], + queryKey: queryKey(), queryFn: () => 'Query Data', initialData: 'initial data', } const query3 = { - queryKey: ['key2'], + queryKey: queryKey(), queryFn: () => 'Query Data', } @@ -44,7 +45,7 @@ describe('UseQueries config object overload', () => { it('TData should be defined when passed through queryOptions', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -61,15 +62,15 @@ describe('UseQueries config object overload', () => { expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() }) - it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQueries', () => { const query1 = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(1), select: (data) => data > 1, }) const query2 = { - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(1), select: (data: number) => data > 1, } @@ -86,7 +87,7 @@ describe('UseQueries config object overload', () => { const queryResults = useQueries({ queries: [ { - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -113,7 +114,7 @@ describe('UseQueries config object overload', () => { queries: [ { ...options, - queryKey: ['todos-key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('data'), }, ], @@ -131,7 +132,7 @@ describe('UseQueries config object overload', () => { const queryResults = useQueries({ queries: [ { - queryKey: ['withSkipToken'], + queryKey: queryKey(), queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), }, ], @@ -147,14 +148,14 @@ describe('UseQueries config object overload', () => { const Queries1 = { get: () => queryOptions({ - queryKey: ['key1'], + queryKey: queryKey(), queryFn: () => Promise.resolve(1), }), } const Queries2 = { get: () => queryOptions({ - queryKey: ['key2'], + queryKey: queryKey(), queryFn: () => Promise.resolve(true), }), } diff --git a/packages/preact-query/src/__tests__/useQueries.test.tsx b/packages/preact-query/src/__tests__/useQueries.test.tsx index d0a77bca025..9eaab9fa811 100644 --- a/packages/preact-query/src/__tests__/useQueries.test.tsx +++ b/packages/preact-query/src/__tests__/useQueries.test.tsx @@ -31,17 +31,20 @@ import { ErrorBoundary } from './ErrorBoundary' import { renderWithClient } from './utils' describe('useQueries', () => { + let queryCache: QueryCache + let queryClient: QueryClient + beforeEach(() => { vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ queryCache }) }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) - const queryCache = new QueryCache() - const queryClient = new QueryClient({ queryCache }) - it('should return the correct states', async () => { const key1 = queryKey() const key2 = queryKey() @@ -52,17 +55,11 @@ describe('useQueries', () => { queries: [ { queryKey: key1, - queryFn: async () => { - await sleep(10) - return 1 - }, + queryFn: () => sleep(10).then(() => 1), }, { queryKey: key2, - queryFn: async () => { - await sleep(200) - return 2 - }, + queryFn: () => sleep(200).then(() => 2), }, ], }) @@ -190,7 +187,7 @@ describe('useQueries', () => { queryFn: () => 'string', select: (a) => { expectTypeOf(a).toEqualTypeOf() - return parseInt(a) + return parseInt(a, 10) }, }, ], @@ -219,7 +216,7 @@ describe('useQueries', () => { queryFn: () => 'string', select: (a) => { expectTypeOf(a).toEqualTypeOf() - return parseInt(a) + return parseInt(a, 10) }, placeholderData: 'string', // @ts-expect-error (initialData: string) @@ -302,7 +299,7 @@ describe('useQueries', () => { queryFn: () => 'string', select: (a) => { expectTypeOf(a).toEqualTypeOf() - return parseInt(a) + return parseInt(a, 10) }, }, ], @@ -362,7 +359,7 @@ describe('useQueries', () => { queryFn: () => 'string', select: (a) => { expectTypeOf(a).toEqualTypeOf() - return parseInt(a) + return parseInt(a, 10) }, placeholderData: 'string', // @ts-expect-error (initialData: string) @@ -392,7 +389,7 @@ describe('useQueries', () => { const result4 = useQueries({ queries: [ queryOptions({ - queryKey: ['key1'], + queryKey: queryKey(), queryFn: () => 'string', select: (a) => { expectTypeOf(a).toEqualTypeOf() @@ -400,11 +397,11 @@ describe('useQueries', () => { }, }), queryOptions({ - queryKey: ['key2'], + queryKey: queryKey(), queryFn: () => 'string', select: (a) => { expectTypeOf(a).toEqualTypeOf() - return parseInt(a) + return parseInt(a, 10) }, }), ], @@ -556,7 +553,7 @@ describe('useQueries', () => { { queryKey: key4, queryFn: () => 'string', - select: (a: string) => parseInt(a), + select: (a: string) => parseInt(a, 10), }, { queryKey: key5, @@ -596,12 +593,12 @@ describe('useQueries', () => { { queryKey: key4, queryFn: () => 'string', - select: (a: string) => parseInt(a), + select: (a: string) => parseInt(a, 10), }, { queryKey: key5, queryFn: () => 'string', - select: (a: string) => parseInt(a), + select: (a: string) => parseInt(a, 10), throwOnError, }, ], @@ -1091,17 +1088,11 @@ describe('useQueries', () => { queries: [ { queryKey: key1, - queryFn: async () => { - await sleep(5) - return Promise.resolve('query1') - }, + queryFn: () => sleep(5).then(() => Promise.resolve('query1')), }, { queryKey: key2, - queryFn: async () => { - await sleep(20) - return Promise.resolve('query2') - }, + queryFn: () => sleep(20).then(() => Promise.resolve('query2')), }, ], combine: ([query1, query2]) => { @@ -1136,17 +1127,13 @@ describe('useQueries', () => { queries: [ { queryKey: key1, - queryFn: async () => { - await sleep(5) - return Promise.resolve('first result ' + count) - }, + queryFn: () => + sleep(5).then(() => Promise.resolve(`first result ${count}`)), }, { queryKey: key2, - queryFn: async () => { - await sleep(50) - return Promise.resolve('second result ' + count) - }, + queryFn: () => + sleep(50).then(() => Promise.resolve(`second result ${count}`)), }, ], combine: (queryResults) => { @@ -1328,17 +1315,11 @@ describe('useQueries', () => { queries: [ { queryKey: key1, - queryFn: async () => { - await sleep(10) - return 'first result:' + value - }, + queryFn: () => sleep(10).then(() => `first result:${value}`), }, { queryKey: key2, - queryFn: async () => { - await sleep(20) - return 'second result:' + value - }, + queryFn: () => sleep(20).then(() => `second result:${value}`), }, ], combine: useCallback((results: Array) => { @@ -1412,17 +1393,11 @@ describe('useQueries', () => { queries: [ { queryKey: [key1], - queryFn: async () => { - await sleep(10) - return 'first result' - }, + queryFn: () => sleep(10).then(() => 'first result'), }, { queryKey: [key2], - queryFn: async () => { - await sleep(20) - return 'second result' - }, + queryFn: () => sleep(20).then(() => 'second result'), }, ], combine: useCallback( @@ -1536,24 +1511,15 @@ describe('useQueries', () => { queries: [ { queryKey: [key1], - queryFn: async () => { - await sleep(10) - return 'first result' - }, + queryFn: () => sleep(10).then(() => 'first result'), }, { queryKey: [key2], - queryFn: async () => { - await sleep(15) - return 'second result' - }, + queryFn: () => sleep(15).then(() => 'second result'), }, { queryKey: [key3], - queryFn: async () => { - await sleep(20) - return 'third result' - }, + queryFn: () => sleep(20).then(() => 'third result'), }, ], combine: (results) => { @@ -1598,24 +1564,15 @@ describe('useQueries', () => { queries: [ { queryKey: [key1], - queryFn: async () => { - await sleep(10) - return 'first result' - }, + queryFn: () => sleep(10).then(() => 'first result'), }, { queryKey: [key2], - queryFn: async () => { - await sleep(15) - return 'second result' - }, + queryFn: () => sleep(15).then(() => 'second result'), }, { queryKey: [key3], - queryFn: async () => { - await sleep(20) - return 'third result' - }, + queryFn: () => sleep(20).then(() => 'third result'), }, ], combine: (results) => { @@ -1677,17 +1634,11 @@ describe('useQueries', () => { queries: [ { queryKey: key1, - queryFn: async () => { - await sleep(10) - return 'first result' - }, + queryFn: () => sleep(10).then(() => 'first result'), }, { queryKey: key2, - queryFn: async () => { - await sleep(20) - return 'second result' - }, + queryFn: () => sleep(20).then(() => 'second result'), }, ], combine: useCallback((results: Array) => { @@ -1746,15 +1697,17 @@ describe('useQueries', () => { it('should not cause infinite re-renders when removing last query', async () => { let renderCount = 0 + const key1 = queryKey() + const key2 = queryKey() function Page() { const [queries, setQueries] = useState([ { - queryKey: ['query1'], + queryKey: key1, queryFn: () => 'data1', }, { - queryKey: ['query2'], + queryKey: key2, queryFn: () => 'data2', }, ]) @@ -1770,7 +1723,7 @@ describe('useQueries', () => { onClick={() => { setQueries([ { - queryKey: ['query1'], + queryKey: key1, queryFn: () => 'data1', }, ]) @@ -1782,7 +1735,7 @@ describe('useQueries', () => { onClick={() => { setQueries([ { - queryKey: ['query2'], + queryKey: key2, queryFn: () => 'data2', }, ]) diff --git a/packages/preact-query/src/__tests__/useQuery.test-d.tsx b/packages/preact-query/src/__tests__/useQuery.test-d.tsx index d304b90d69d..55461084db7 100644 --- a/packages/preact-query/src/__tests__/useQuery.test-d.tsx +++ b/packages/preact-query/src/__tests__/useQuery.test-d.tsx @@ -180,7 +180,7 @@ describe('useQuery', () => { describe('Config object overload', () => { it('TData should always be defined when initialData is provided as an object', () => { const { data } = useQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => ({ wow: true }), initialData: { wow: true }, }) @@ -190,7 +190,7 @@ describe('useQuery', () => { it('TData should be defined when passed through queryOptions', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -207,7 +207,7 @@ describe('useQuery', () => { it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { const options = queryOptions({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(1), }) @@ -221,7 +221,7 @@ describe('useQuery', () => { it('TData should always be defined when initialData is provided as a function which ALWAYS returns the data', () => { const { data } = useQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -237,7 +237,7 @@ describe('useQuery', () => { it('TData should have undefined in the union when initialData is NOT provided', () => { const { data } = useQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -250,7 +250,7 @@ describe('useQuery', () => { it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { const { data } = useQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -264,7 +264,7 @@ describe('useQuery', () => { it('TData should be narrowed after an isSuccess check when initialData is provided as a function which can return undefined', () => { const { data, isSuccess } = useQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -283,7 +283,7 @@ describe('useQuery', () => { // @ts-expect-error // eslint-disable-next-line const result: UseQueryResult<{ wow: string }> = useQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => { return { wow: true, @@ -297,7 +297,7 @@ describe('useQuery', () => { it('data should not have undefined when initialData is provided', () => { const { data } = useQuery({ - queryKey: ['query-key'], + queryKey: queryKey(), initialData: 42, }) @@ -314,7 +314,7 @@ describe('useQuery', () => { ) => { return useQuery({ ...options, - queryKey: ['todos-key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('data'), }) } @@ -329,7 +329,7 @@ describe('useQuery', () => { it('should be able to use structuralSharing with unknown types', () => { // https://github.com/TanStack/query/issues/6525#issuecomment-1938411343 useQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => 5, structuralSharing: (oldData, newData) => { expectTypeOf(oldData).toBeUnknown() diff --git a/packages/preact-query/src/__tests__/useQuery.test.tsx b/packages/preact-query/src/__tests__/useQuery.test.tsx index c9bfd6a61cb..e99de4e3336 100644 --- a/packages/preact-query/src/__tests__/useQuery.test.tsx +++ b/packages/preact-query/src/__tests__/useQuery.test.tsx @@ -12,7 +12,7 @@ import { useRef, useState, } from 'preact/hooks' -import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' import { @@ -47,6 +47,7 @@ describe('useQuery', () => { }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) @@ -82,10 +83,7 @@ describe('useQuery', () => { function Page() { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'test' - }, + queryFn: () => sleep(10).then(() => 'test'), }) states.push(state) @@ -471,10 +469,7 @@ describe('useQuery', () => { function Component({ value }: { value: string }) { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'data: ' + value - }, + queryFn: () => sleep(10).then(() => `data: ${value}`), gcTime: 0, notifyOnChangeProps: 'all', @@ -533,10 +528,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(5) - return 'data' - }, + queryFn: () => sleep(5).then(() => 'data'), gcTime: 0, notifyOnChangeProps: ['isPending', 'isSuccess', 'data'], @@ -769,7 +761,7 @@ describe('useQuery', () => { queryFn: async () => { await sleep(10) count++ - return 'test' + count + return `test${count}` }, }) @@ -1002,10 +994,7 @@ describe('useQuery', () => { function Page() { const result = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'fetched' - }, + queryFn: () => sleep(10).then(() => 'fetched'), initialData: 'initial', staleTime: Infinity, @@ -1163,10 +1152,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: [key, count], - queryFn: async () => { - await sleep(5) - return count - }, + queryFn: () => sleep(5).then(() => count), enabled: count === 0, }) @@ -1223,10 +1209,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: [key, count], - queryFn: async () => { - await sleep(10) - return count - }, + queryFn: () => sleep(10).then(() => count), placeholderData: keepPreviousData, }) @@ -1593,10 +1576,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: [key, count], - queryFn: async () => { - await sleep(10) - return count - }, + queryFn: () => sleep(10).then(() => count), initialData: 99, placeholderData: keepPreviousData, }) @@ -1665,10 +1645,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: [key, count], - queryFn: async () => { - await sleep(10) - return count - }, + queryFn: () => sleep(10).then(() => count), enabled: false, placeholderData: keepPreviousData, notifyOnChangeProps: 'all', @@ -1763,10 +1740,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: [key, count], - queryFn: async () => { - await sleep(10) - return count - }, + queryFn: () => sleep(10).then(() => count), enabled: false, placeholderData: keepPreviousData, notifyOnChangeProps: 'all', @@ -1841,10 +1815,7 @@ describe('useQuery', () => { function FirstComponent() { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 1 - }, + queryFn: () => sleep(10).then(() => 1), notifyOnChangeProps: 'all', }) const refetch = state.refetch @@ -1904,10 +1875,7 @@ describe('useQuery', () => { queryClient.prefetchQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'prefetch' - }, + queryFn: () => sleep(10).then(() => 'prefetch'), }) await vi.advanceTimersByTimeAsync(20) @@ -1915,10 +1883,7 @@ describe('useQuery', () => { function FirstComponent() { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'one' - }, + queryFn: () => sleep(10).then(() => 'one'), staleTime: 100, }) @@ -1929,10 +1894,7 @@ describe('useQuery', () => { function SecondComponent() { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'two' - }, + queryFn: () => sleep(10).then(() => 'two'), staleTime: 10, }) @@ -2034,10 +1996,7 @@ describe('useQuery', () => { }) => { return useQuery({ queryKey: [key, id], - queryFn: async () => { - await sleep(10) - return { id, name: 'John' } - }, + queryFn: () => sleep(10).then(() => ({ id, name: 'John' })), enabled: !!id && enabled, }) } @@ -2131,10 +2090,7 @@ describe('useQuery', () => { function Page() { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(5) - return 'test' - }, + queryFn: () => sleep(5).then(() => 'test'), notifyOnChangeProps: ['data'], }) @@ -2188,10 +2144,7 @@ describe('useQuery', () => { function Page() { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(5) - return 'test' - }, + queryFn: () => sleep(5).then(() => 'test'), notifyOnChangeProps: () => ['data'], }) @@ -2379,10 +2332,7 @@ describe('useQuery', () => { it('should update query options', () => { const key = queryKey() - const queryFn = async () => { - await sleep(10) - return 'data1' - } + const queryFn = () => sleep(10).then(() => 'data1') function Page() { useQuery({ queryKey: key, queryFn, retryDelay: 10 }) @@ -2400,10 +2350,7 @@ describe('useQuery', () => { let renders = 0 - const queryFn = async () => { - await sleep(15) - return 'data' - } + const queryFn = () => sleep(15).then(() => 'data') function Page() { const query1 = useQuery({ queryKey: key, queryFn }) @@ -2456,18 +2403,12 @@ describe('useQuery', () => { function Page() { const first = useQuery({ queryKey: key1, - queryFn: async () => { - await sleep(10) - return 'data' - }, + queryFn: () => sleep(10).then(() => 'data'), enabled: false, }) const second = useQuery({ queryKey: key2, - queryFn: async () => { - await sleep(10) - return 'data' - }, + queryFn: () => sleep(10).then(() => 'data'), }) return ( @@ -2506,10 +2447,7 @@ describe('useQuery', () => { function Page() { const { status } = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'test' - }, + queryFn: () => sleep(10).then(() => 'test'), }) return
status: {status}
@@ -3554,10 +3492,7 @@ describe('useQuery', () => { function Page() { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'data' - }, + queryFn: () => sleep(10).then(() => 'data'), }) states.push(state) return ( @@ -3647,10 +3582,7 @@ describe('useQuery', () => { const prefetchQueryFn = vi.fn<(...args: Array) => Promise>() - prefetchQueryFn.mockImplementation(async () => { - await sleep(10) - return 'not yet...' - }) + prefetchQueryFn.mockImplementation(() => sleep(10).then(() => 'not yet...')) queryClient.prefetchQuery({ queryKey: key, @@ -3686,9 +3618,8 @@ describe('useQuery', () => { if (counter < 2) { counter++ return Promise.reject(new Error('error')) - } else { - return Promise.resolve('data') } + return Promise.resolve('data') }, retryDelay: 10, }) @@ -3771,10 +3702,7 @@ describe('useQuery', () => { const query = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'data' - }, + queryFn: () => sleep(10).then(() => 'data'), enabled: shouldFetch, }) @@ -3810,10 +3738,7 @@ describe('useQuery', () => { function Page() { const result = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'serverData' - }, + queryFn: () => sleep(10).then(() => 'serverData'), initialData: 'initialData', }) results.push(result) @@ -3986,7 +3911,7 @@ describe('useQuery', () => { }) // See https://github.com/tannerlinsley/react-query/issues/360 - test('should init to status:pending, fetchStatus:idle when enabled is false', async () => { + it('should init to status:pending, fetchStatus:idle when enabled is false', async () => { const key = queryKey() function Page() { @@ -4011,7 +3936,7 @@ describe('useQuery', () => { expect(rendered.getByText('status: pending, idle')).toBeInTheDocument() }) - test('should not schedule garbage collection, if gcTimeout is set to `Infinity`', async () => { + it('should not schedule garbage collection, if gcTimeout is set to `Infinity`', async () => { const key = queryKey() function Page() { @@ -4034,7 +3959,7 @@ describe('useQuery', () => { expect(setTimeoutSpy).not.toHaveBeenCalled() }) - test('should schedule garbage collection, if gcTimeout is not set to infinity', async () => { + it('should schedule garbage collection, if gcTimeout is not set to infinity', async () => { const key = queryKey() function Page() { @@ -4504,10 +4429,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: key1, - queryFn: async () => { - await sleep(10) - return 0 - }, + queryFn: () => sleep(10).then(() => 0), select: useCallback( (data: number) => { selectRun++ @@ -4550,10 +4472,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: key1, - queryFn: async () => { - await sleep(10) - return 0 - }, + queryFn: () => sleep(10).then(() => 0), select: useCallback( (data: number) => { @@ -4603,10 +4522,7 @@ describe('useQuery', () => { const state = useQuery({ queryKey: key1, - queryFn: async () => { - await sleep(10) - return [1, 2] - }, + queryFn: () => sleep(10).then(() => [1, 2]), select: (res) => res.map((x) => x + 1), }) @@ -4747,10 +4663,7 @@ describe('useQuery', () => { const key = queryKey() const states: Array> = [] - const queryFn = async () => { - await sleep(50) - return 'OK' - } + const queryFn = () => sleep(50).then(() => 'OK') function Page() { const [id, setId] = useState(1) @@ -4994,19 +4907,19 @@ describe('useQuery', () => { }) it('should refetch when changed enabled to true in error state', async () => { + const key = queryKey() const queryFn = vi.fn<(...args: Array) => unknown>() - queryFn.mockImplementation(async () => { - await sleep(10) - return Promise.reject(new Error('Suspense Error Bingo')) - }) + queryFn.mockImplementation(() => + sleep(10).then(() => Promise.reject(new Error('Suspense Error Bingo'))), + ) function Page({ enabled }: { enabled: boolean }) { const { error, isPending } = useQuery({ - queryKey: ['key'], + queryKey: key, queryFn, enabled, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -5041,17 +4954,17 @@ describe('useQuery', () => { // // render error state component await vi.advanceTimersByTimeAsync(11) rendered.getByText('error') - expect(queryFn).toBeCalledTimes(1) + expect(queryFn).toHaveBeenCalledTimes(1) // change to enabled to false fireEvent.click(rendered.getByLabelText('retry')) await vi.advanceTimersByTimeAsync(11) rendered.getByText('error') - expect(queryFn).toBeCalledTimes(1) + expect(queryFn).toHaveBeenCalledTimes(1) // // change to enabled to true fireEvent.click(rendered.getByLabelText('retry')) - expect(queryFn).toBeCalledTimes(2) + expect(queryFn).toHaveBeenCalledTimes(2) }) it('should refetch when query key changed when previous status is error', async () => { @@ -5062,12 +4975,11 @@ describe('useQuery', () => { await sleep(10) if (id % 2 === 1) { return Promise.reject(new Error('Error')) - } else { - return 'data' } + return 'data' }, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -5118,12 +5030,10 @@ describe('useQuery', () => { function Page({ id }: { id: boolean }) { const { error, isFetching } = useQuery({ queryKey: [id], - queryFn: async () => { - await sleep(10) - return Promise.reject(new Error('Error')) - }, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Error'))), retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -5255,10 +5165,7 @@ describe('useQuery', () => { function Page() { const state = useQuery({ queryKey: key, - queryFn: async () => { - await sleep(10) - return 'data' - }, + queryFn: () => sleep(10).then(() => 'data'), }) useEffect(() => { @@ -5300,7 +5207,7 @@ describe('useQuery', () => { queryFn: async () => { count++ await sleep(10) - return 'data' + count + return `data${count}` }, }) @@ -5363,7 +5270,7 @@ describe('useQuery', () => { queryFn: async () => { count++ await sleep(10) - return 'data' + count + return `data${count}` }, }) @@ -5411,7 +5318,7 @@ describe('useQuery', () => { queryFn: async () => { count++ await sleep(10) - return 'data' + count + return `data${count}` }, }) @@ -5457,7 +5364,7 @@ describe('useQuery', () => { queryFn: async () => { count++ await sleep(10) - return 'data' + count + return `data${count}` }, initialData: 'initial', }) @@ -5505,7 +5412,7 @@ describe('useQuery', () => { queryFn: async () => { count++ await sleep(10) - return 'data' + count + return `data${count}` }, initialData: 'initial', }) @@ -5565,7 +5472,7 @@ describe('useQuery', () => { queryFn: async (): Promise => { count++ await sleep(10) - throw new Error('failed' + count) + throw new Error(`failed${count}`) }, retry: 2, retryDelay: 10, @@ -5678,7 +5585,7 @@ describe('useQuery', () => { queryFn: async () => { count++ await sleep(10) - return 'data' + count + return `data${count}` }, }) @@ -5735,7 +5642,7 @@ describe('useQuery', () => { queryFn: async () => { count++ await sleep(10) - return 'data' + count + return `data${count}` }, refetchOnReconnect: false, }) @@ -5787,7 +5694,7 @@ describe('useQuery', () => { function Component() { const state = useQuery({ queryKey: key, - queryFn: async () => { + queryFn: () => { count++ return `data${count}` }, @@ -5862,7 +5769,7 @@ describe('useQuery', () => { queryFn: async () => { count++ await sleep(10) - return 'data ' + count + return `data ${count}` }, networkMode: 'always', }) @@ -5898,7 +5805,7 @@ describe('useQuery', () => { queryFn: async (): Promise => { count++ await sleep(10) - throw new Error('error ' + count) + throw new Error(`error ${count}`) }, networkMode: 'always', retry: 1, @@ -5942,7 +5849,7 @@ describe('useQuery', () => { queryFn: async (): Promise => { count++ await sleep(10) - throw new Error('failed' + count) + throw new Error(`failed${count}`) }, retry: 2, retryDelay: 1, @@ -6067,7 +5974,7 @@ describe('useQuery', () => { renders++ return (
- {data ? 'has data' + data : 'no data'} + {data ? `has data${data}` : 'no data'}
}> + +
, + ) + + // No suspend, cached data shown immediately + expect(rendered.getByText('data1: cached1')).toBeInTheDocument() + expect(rendered.getByText('data2: cached2')).toBeInTheDocument() + + // key1 is fresh, no refetch + expect(queryFn1).toHaveBeenCalledTimes(0) + + // key2 background refetch completes + await vi.advanceTimersByTimeAsync(11) + + expect(rendered.getByText('data1: cached1')).toBeInTheDocument() + expect(rendered.getByText('data2: data2')).toBeInTheDocument() + + // key1 is still fresh, no refetch triggered + expect(queryFn1).toHaveBeenCalledTimes(0) + + // after key2 refetch completes, key1 is still fresh with no refetch triggered + await vi.advanceTimersByTimeAsync(10) + + expect(rendered.getByText('data1: cached1')).toBeInTheDocument() + expect(rendered.getByText('data2: data2')).toBeInTheDocument() + expect(queryFn1).toHaveBeenCalledTimes(0) + }) + + it('should not suspend and only refetch the stale query when one query has stale and the other has fresh cached data', async () => { + const key1 = queryKey() + const key2 = queryKey() + + queryClient.setQueryData(key1, 'cached1') + queryClient.setQueryData(key2, 'cached2') + + // Advance past staleTime (min 1000ms in suspense) so key1 becomes stale before mount + vi.advanceTimersByTime(1000) + + // Make key2 fresh again by resetting its data + queryClient.setQueryData(key2, 'cached2') + + const queryFn2 = vi.fn(() => sleep(20).then(() => 'data2')) + + function Page() { + const [result1, result2] = useSuspenseQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 'data1'), + }, + { + queryKey: key2, + queryFn: queryFn2, + }, + ], + }) + + return ( +
+
data1: {result1.data}
+
data2: {result2.data}
+
+ ) + } + + const rendered = renderWithClient( + queryClient, + loading
}> + + , + ) + + // No suspend, cached data shown immediately + expect(rendered.getByText('data1: cached1')).toBeInTheDocument() + expect(rendered.getByText('data2: cached2')).toBeInTheDocument() + + // key2 is fresh, no refetch + expect(queryFn2).toHaveBeenCalledTimes(0) + + // key1 background refetch completes + await vi.advanceTimersByTimeAsync(11) + + expect(rendered.getByText('data1: data1')).toBeInTheDocument() + expect(rendered.getByText('data2: cached2')).toBeInTheDocument() + + // key2 is still fresh, no refetch triggered + expect(queryFn2).toHaveBeenCalledTimes(0) + + // after key1 refetch completes, key2 is still fresh with no refetch triggered + await vi.advanceTimersByTimeAsync(10) + + expect(rendered.getByText('data1: data1')).toBeInTheDocument() + expect(rendered.getByText('data2: cached2')).toBeInTheDocument() + expect(queryFn2).toHaveBeenCalledTimes(0) + }) + it('should not suspend but refetch when all queries have stale cached data', async () => { const key1 = queryKey() const key2 = queryKey() diff --git a/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx b/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx index 310d2a49248..53adae90660 100644 --- a/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx +++ b/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx @@ -1,4 +1,5 @@ import { skipToken } from '@tanstack/query-core' +import { queryKey } from '@tanstack/query-test-utils' import { assertType, describe, expectTypeOf, it } from 'vitest' import { useSuspenseQuery } from '../useSuspenseQuery' @@ -6,7 +7,7 @@ import { useSuspenseQuery } from '../useSuspenseQuery' describe('useSuspenseQuery', () => { it('should always have data defined', () => { const { data } = useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) @@ -15,7 +16,7 @@ describe('useSuspenseQuery', () => { it('should not have pending status', () => { const { status } = useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) @@ -25,14 +26,14 @@ describe('useSuspenseQuery', () => { it('should not allow skipToken in queryFn', () => { assertType( useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), // @ts-expect-error queryFn: skipToken, }), ) assertType( useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), // @ts-expect-error queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), }), @@ -42,7 +43,7 @@ describe('useSuspenseQuery', () => { it('should not allow placeholderData, enabled or throwOnError props', () => { assertType( useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), // @ts-expect-error TS2345 placeholderData: 5, @@ -51,7 +52,7 @@ describe('useSuspenseQuery', () => { ) assertType( useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), // @ts-expect-error TS2345 enabled: true, @@ -59,7 +60,7 @@ describe('useSuspenseQuery', () => { ) assertType( useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), // @ts-expect-error TS2345 throwOnError: true, @@ -70,7 +71,7 @@ describe('useSuspenseQuery', () => { it('should not return isPlaceholderData', () => { expectTypeOf( useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }), ).not.toHaveProperty('isPlaceholderData') @@ -78,7 +79,7 @@ describe('useSuspenseQuery', () => { it('should type-narrow the error field', () => { const query = useSuspenseQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve(5), }) diff --git a/packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx index eb165ca4709..d09cc6b8032 100644 --- a/packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx +++ b/packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx @@ -22,17 +22,20 @@ import { ErrorBoundary } from './ErrorBoundary' import { renderWithClient } from './utils' describe('useSuspenseQuery', () => { + let queryCache: QueryCache + let queryClient: QueryClient + beforeEach(() => { vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ queryCache }) }) afterEach(() => { + queryClient.clear() vi.useRealTimers() }) - const queryCache = new QueryCache() - const queryClient = new QueryClient({ queryCache }) - /** * Preact Suspense handles the rerenders differently than React. * This test only checks for 4 renders (vs. React -> 6) @@ -918,7 +921,6 @@ describe('useSuspenseQuery', () => { useSuspenseQuery({ queryKey: key, // @ts-expect-error - // eslint-disable-next-line queryFn: Math.random() >= 0 ? skipToken : () => Promise.resolve(5), }) diff --git a/packages/preact-query/src/useQuery.ts b/packages/preact-query/src/useQuery.ts index 0633587e094..6d462bc9dd7 100644 --- a/packages/preact-query/src/useQuery.ts +++ b/packages/preact-query/src/useQuery.ts @@ -1,10 +1,5 @@ import { QueryObserver } from '@tanstack/query-core' -import type { - DefaultError, - NoInfer, - QueryClient, - QueryKey, -} from '@tanstack/query-core' +import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core' import type { DefinedInitialDataOptions, diff --git a/packages/preact-query/tsconfig.json b/packages/preact-query/tsconfig.json index bf43c5a1e32..1f11f0d58da 100644 --- a/packages/preact-query/tsconfig.json +++ b/packages/preact-query/tsconfig.json @@ -6,5 +6,11 @@ "jsx": "react-jsx", "jsxImportSource": "preact" }, - "include": ["src", "test-setup.ts", "*.config.*", "package.json"] + "include": [ + "src", + "test-setup.ts", + "*.config.ts", + "*.config.js", + "package.json" + ] } diff --git a/packages/preact-query/tsconfig.prod.json b/packages/preact-query/tsconfig.prod.json index 0f4c92da065..2bb29fdf02a 100644 --- a/packages/preact-query/tsconfig.prod.json +++ b/packages/preact-query/tsconfig.prod.json @@ -4,5 +4,7 @@ "incremental": false, "composite": false, "rootDir": "../../" - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/preact-query/vite.config.ts b/packages/preact-query/vite.config.ts index 2d038569085..3333aac2d9a 100644 --- a/packages/preact-query/vite.config.ts +++ b/packages/preact-query/vite.config.ts @@ -1,8 +1,7 @@ import preact from '@preact/preset-vite' import { defineConfig } from 'vitest/config' -import type { UserConfig as ViteUserConfig } from 'vite' - import packageJson from './package.json' +import type { UserConfig as ViteUserConfig } from 'vite' export default defineConfig({ plugins: [preact() as ViteUserConfig['plugins']], @@ -24,7 +23,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['test-setup.ts'], coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/query-async-storage-persister/CHANGELOG.md b/packages/query-async-storage-persister/CHANGELOG.md index dec6da49251..36c5b9ba941 100644 --- a/packages/query-async-storage-persister/CHANGELOG.md +++ b/packages/query-async-storage-persister/CHANGELOG.md @@ -1,5 +1,241 @@ # @tanstack/query-async-storage-persister +## 5.101.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.101.0 + - @tanstack/query-persist-client-core@5.101.0 + +## 5.100.14 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.14 + - @tanstack/query-persist-client-core@5.100.14 + +## 5.100.13 + +### Patch Changes + +- Updated dependencies [[`d423168`](https://github.com/TanStack/query/commit/d423168f6261a5cb3d353e53b27c8150cc271151)]: + - @tanstack/query-core@5.100.13 + - @tanstack/query-persist-client-core@5.100.13 + +## 5.100.12 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.12 + - @tanstack/query-persist-client-core@5.100.12 + +## 5.100.11 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.11 + - @tanstack/query-persist-client-core@5.100.11 + +## 5.100.10 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.10 + - @tanstack/query-persist-client-core@5.100.10 + +## 5.100.9 + +### Patch Changes + +- Updated dependencies [[`fcee7bd`](https://github.com/TanStack/query/commit/fcee7bdc429385ae8ffa224fa8a7a9ec7b8ee380)]: + - @tanstack/query-core@5.100.9 + - @tanstack/query-persist-client-core@5.100.9 + +## 5.100.8 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.8 + - @tanstack/query-persist-client-core@5.100.8 + +## 5.100.7 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.7 + - @tanstack/query-persist-client-core@5.100.7 + +## 5.100.6 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.6 + - @tanstack/query-persist-client-core@5.100.6 + +## 5.100.5 + +### Patch Changes + +- Updated dependencies [[`a53ef97`](https://github.com/TanStack/query/commit/a53ef97f87decb8ea2431710f5199431d3c94c8d)]: + - @tanstack/query-core@5.100.5 + - @tanstack/query-persist-client-core@5.100.5 + +## 5.100.4 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.4 + - @tanstack/query-persist-client-core@5.100.4 + +## 5.100.3 + +### Patch Changes + +- Updated dependencies [[`f85d825`](https://github.com/TanStack/query/commit/f85d825e02efbbff02e2081528ed28f5e5382f7a)]: + - @tanstack/query-core@5.100.3 + - @tanstack/query-persist-client-core@5.100.3 + +## 5.100.2 + +### Patch Changes + +- Updated dependencies [[`ea4497e`](https://github.com/TanStack/query/commit/ea4497e8aa00d8c1c3a36fb1e17563a889d6ab31), [`d6a7bf3`](https://github.com/TanStack/query/commit/d6a7bf3e3e024c1a77d0536813238cc8007a5fa7), [`645d5d1`](https://github.com/TanStack/query/commit/645d5d130f5e8017cb1bf1a37987f7b980aed705)]: + - @tanstack/query-core@5.100.2 + - @tanstack/query-persist-client-core@5.100.2 + +## 5.100.1 + +### Patch Changes + +- Updated dependencies [[`1bb0d23`](https://github.com/TanStack/query/commit/1bb0d234280fd4ae1725c439088426a20593a8df)]: + - @tanstack/query-core@5.100.1 + - @tanstack/query-persist-client-core@5.100.1 + +## 5.100.0 + +### Patch Changes + +- Updated dependencies [[`6540a41`](https://github.com/TanStack/query/commit/6540a4126b1c087d86d64525e78f32d9920dcd31)]: + - @tanstack/query-core@5.100.0 + - @tanstack/query-persist-client-core@5.100.0 + +## 5.99.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.2 + - @tanstack/query-persist-client-core@5.99.2 + +## 5.99.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.1 + - @tanstack/query-persist-client-core@5.99.1 + +## 5.99.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.0 + - @tanstack/query-persist-client-core@5.99.0 + +## 5.98.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.98.0 + - @tanstack/query-persist-client-core@5.98.0 + +## 5.97.0 + +### Patch Changes + +- Updated dependencies [[`2bfb12c`](https://github.com/TanStack/query/commit/2bfb12cc44f1d8495106136e4ddacb817135f8f9)]: + - @tanstack/query-core@5.97.0 + - @tanstack/query-persist-client-core@5.97.0 + +## 5.96.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.2 + - @tanstack/query-persist-client-core@5.96.2 + +## 5.96.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.1 + - @tanstack/query-persist-client-core@5.96.1 + +## 5.96.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.0 + - @tanstack/query-persist-client-core@5.96.0 + +## 5.95.2 + +### Patch Changes + +- Updated dependencies [[`cd5a35b`](https://github.com/TanStack/query/commit/cd5a35b328837781aa4f9305bb2bd7877ca934e9)]: + - @tanstack/query-core@5.95.2 + - @tanstack/query-persist-client-core@5.95.2 + +## 5.95.1 + +### Patch Changes + +- Updated dependencies [[`1f1775c`](https://github.com/TanStack/query/commit/1f1775ca92f2b6c035682947ff3b3424804ff31a)]: + - @tanstack/query-core@5.95.1 + - @tanstack/query-persist-client-core@5.95.1 + +## 5.95.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.95.0 + - @tanstack/query-persist-client-core@5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]: + - @tanstack/query-core@5.94.5 + - @tanstack/query-persist-client-core@5.94.5 + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + +- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]: + - @tanstack/query-core@5.94.4 + - @tanstack/query-persist-client-core@5.94.4 + ## 5.90.27 ### Patch Changes diff --git a/packages/query-async-storage-persister/package.json b/packages/query-async-storage-persister/package.json index f293c7fb1a8..1a63a95c0f7 100644 --- a/packages/query-async-storage-persister/package.json +++ b/packages/query-async-storage-persister/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-async-storage-persister", - "version": "5.90.27", + "version": "5.101.0", "description": "A persister for asynchronous storages, to be used with TanStack/Query", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/query-async-storage-persister/src/__tests__/asyncThrottle.test.ts b/packages/query-async-storage-persister/src/__tests__/asyncThrottle.test.ts index ec7476d1746..1951ad15ed0 100644 --- a/packages/query-async-storage-persister/src/__tests__/asyncThrottle.test.ts +++ b/packages/query-async-storage-persister/src/__tests__/asyncThrottle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { sleep } from '@tanstack/query-test-utils' import { asyncThrottle } from '../asyncThrottle' @@ -11,7 +11,7 @@ describe('asyncThrottle', () => { vi.useRealTimers() }) - test('basic', async () => { + it('basic', async () => { const interval = 10 const execTimeStamps: Array = [] const mockFunc = vi.fn( @@ -36,7 +36,7 @@ describe('asyncThrottle', () => { await vi.advanceTimersToNextTimerAsync() await vi.advanceTimersByTimeAsync(interval) - expect(mockFunc).toBeCalledTimes(2) + expect(mockFunc).toHaveBeenCalledTimes(2) expect(mockFunc.mock.calls[1]?.[0]).toBe(3) expect(execTimeStamps.length).toBe(2) expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual( @@ -44,7 +44,7 @@ describe('asyncThrottle', () => { ) }) - test('Bug #3331 case 1: Special timing', async () => { + it('Bug #3331 case 1: Special timing', async () => { const interval = 1000 const execTimeStamps: Array = [] const mockFunc = vi.fn( @@ -68,7 +68,7 @@ describe('asyncThrottle', () => { await vi.advanceTimersToNextTimerAsync() await vi.advanceTimersByTimeAsync(interval) - expect(mockFunc).toBeCalledTimes(2) + expect(mockFunc).toHaveBeenCalledTimes(2) expect(mockFunc.mock.calls[1]?.[0]).toBe(4) expect(execTimeStamps.length).toBe(2) expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual( @@ -76,7 +76,7 @@ describe('asyncThrottle', () => { ) }) - test('Bug #3331 case 2: "func" execution time is greater than the interval.', async () => { + it('Bug #3331 case 2: "func" execution time is greater than the interval.', async () => { const interval = 1000 const execTimeStamps: Array = [] const mockFunc = vi.fn( @@ -98,7 +98,7 @@ describe('asyncThrottle', () => { await vi.advanceTimersByTimeAsync(interval + 10) await vi.advanceTimersByTimeAsync(interval + 10) - expect(mockFunc).toBeCalledTimes(2) + expect(mockFunc).toHaveBeenCalledTimes(2) expect(mockFunc.mock.calls[1]?.[0]).toBe(3) expect(execTimeStamps.length).toBe(2) expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual( @@ -106,7 +106,7 @@ describe('asyncThrottle', () => { ) }) - test('"func" throw error not break next invoke', async () => { + it('"func" throw error not break next invoke', async () => { const interval = 10 const mockFunc = vi.fn( @@ -126,11 +126,11 @@ describe('asyncThrottle', () => { new Promise((resolve) => testFunc(2, resolve)) await vi.advanceTimersByTimeAsync(interval) - expect(mockFunc).toBeCalledTimes(2) + expect(mockFunc).toHaveBeenCalledTimes(2) expect(mockFunc.mock.calls[1]?.[0]).toBe(2) }) - test('"onError" should be called when "func" throw error', () => { + it('"onError" should be called when "func" throw error', () => { const err = new Error('error') const handleError = (e: unknown) => { expect(e).toBe(err) @@ -145,7 +145,7 @@ describe('asyncThrottle', () => { testFunc() }) - test('should throw error when "func" is not a function', () => { - expect(() => asyncThrottle(1 as any)).toThrowError() + it('should throw error when "func" is not a function', () => { + expect(() => asyncThrottle(1 as any)).toThrow() }) }) diff --git a/packages/query-async-storage-persister/tsconfig.json b/packages/query-async-storage-persister/tsconfig.json index acf3142c14e..cfe5fad906a 100644 --- a/packages/query-async-storage-persister/tsconfig.json +++ b/packages/query-async-storage-persister/tsconfig.json @@ -4,6 +4,6 @@ "outDir": "./dist-ts", "rootDir": "." }, - "include": ["src", "*.config.*", "package.json"], + "include": ["src", "*.config.ts", "*.config.js", "package.json"], "references": [{ "path": "../query-persist-client-core" }] } diff --git a/packages/query-async-storage-persister/tsconfig.prod.json b/packages/query-async-storage-persister/tsconfig.prod.json index 0f4c92da065..2bb29fdf02a 100644 --- a/packages/query-async-storage-persister/tsconfig.prod.json +++ b/packages/query-async-storage-persister/tsconfig.prod.json @@ -4,5 +4,7 @@ "incremental": false, "composite": false, "rootDir": "../../" - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/query-async-storage-persister/vite.config.ts b/packages/query-async-storage-persister/vite.config.ts index eae90bc77a3..c244c9cc38f 100644 --- a/packages/query-async-storage-persister/vite.config.ts +++ b/packages/query-async-storage-persister/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ dir: './src', watch: false, coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/query-broadcast-client-experimental/CHANGELOG.md b/packages/query-broadcast-client-experimental/CHANGELOG.md index b608bafa707..9f2a4a56c79 100644 --- a/packages/query-broadcast-client-experimental/CHANGELOG.md +++ b/packages/query-broadcast-client-experimental/CHANGELOG.md @@ -1,5 +1,212 @@ # @tanstack/query-broadcast-client-experimental +## 5.101.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.101.0 + +## 5.100.14 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.14 + +## 5.100.13 + +### Patch Changes + +- Updated dependencies [[`d423168`](https://github.com/TanStack/query/commit/d423168f6261a5cb3d353e53b27c8150cc271151)]: + - @tanstack/query-core@5.100.13 + +## 5.100.12 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.12 + +## 5.100.11 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.11 + +## 5.100.10 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.10 + +## 5.100.9 + +### Patch Changes + +- Updated dependencies [[`fcee7bd`](https://github.com/TanStack/query/commit/fcee7bdc429385ae8ffa224fa8a7a9ec7b8ee380)]: + - @tanstack/query-core@5.100.9 + +## 5.100.8 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.8 + +## 5.100.7 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.7 + +## 5.100.6 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.6 + +## 5.100.5 + +### Patch Changes + +- Updated dependencies [[`a53ef97`](https://github.com/TanStack/query/commit/a53ef97f87decb8ea2431710f5199431d3c94c8d)]: + - @tanstack/query-core@5.100.5 + +## 5.100.4 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.100.4 + +## 5.100.3 + +### Patch Changes + +- Updated dependencies [[`f85d825`](https://github.com/TanStack/query/commit/f85d825e02efbbff02e2081528ed28f5e5382f7a)]: + - @tanstack/query-core@5.100.3 + +## 5.100.2 + +### Patch Changes + +- Updated dependencies [[`ea4497e`](https://github.com/TanStack/query/commit/ea4497e8aa00d8c1c3a36fb1e17563a889d6ab31), [`d6a7bf3`](https://github.com/TanStack/query/commit/d6a7bf3e3e024c1a77d0536813238cc8007a5fa7), [`645d5d1`](https://github.com/TanStack/query/commit/645d5d130f5e8017cb1bf1a37987f7b980aed705)]: + - @tanstack/query-core@5.100.2 + +## 5.100.1 + +### Patch Changes + +- Updated dependencies [[`1bb0d23`](https://github.com/TanStack/query/commit/1bb0d234280fd4ae1725c439088426a20593a8df)]: + - @tanstack/query-core@5.100.1 + +## 5.100.0 + +### Patch Changes + +- Updated dependencies [[`6540a41`](https://github.com/TanStack/query/commit/6540a4126b1c087d86d64525e78f32d9920dcd31)]: + - @tanstack/query-core@5.100.0 + +## 5.99.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.2 + +## 5.99.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.1 + +## 5.99.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.99.0 + +## 5.98.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.98.0 + +## 5.97.0 + +### Patch Changes + +- Updated dependencies [[`2bfb12c`](https://github.com/TanStack/query/commit/2bfb12cc44f1d8495106136e4ddacb817135f8f9)]: + - @tanstack/query-core@5.97.0 + +## 5.96.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.2 + +## 5.96.1 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.1 + +## 5.96.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.96.0 + +## 5.95.2 + +### Patch Changes + +- Updated dependencies [[`cd5a35b`](https://github.com/TanStack/query/commit/cd5a35b328837781aa4f9305bb2bd7877ca934e9)]: + - @tanstack/query-core@5.95.2 + +## 5.95.1 + +### Patch Changes + +- Updated dependencies [[`1f1775c`](https://github.com/TanStack/query/commit/1f1775ca92f2b6c035682947ff3b3424804ff31a)]: + - @tanstack/query-core@5.95.1 + +## 5.95.0 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/query-core@5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]: + - @tanstack/query-core@5.94.5 + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + +- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]: + - @tanstack/query-core@5.94.4 + ## 5.90.23 ### Patch Changes diff --git a/packages/query-broadcast-client-experimental/package.json b/packages/query-broadcast-client-experimental/package.json index 3272e813c36..3271f4299df 100644 --- a/packages/query-broadcast-client-experimental/package.json +++ b/packages/query-broadcast-client-experimental/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-broadcast-client-experimental", - "version": "5.90.23", + "version": "5.101.0", "description": "An experimental plugin to for broadcasting the state of your queryClient between browser tabs/windows", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/query-broadcast-client-experimental/tsconfig.json b/packages/query-broadcast-client-experimental/tsconfig.json index 06504fba344..f2244eacf3a 100644 --- a/packages/query-broadcast-client-experimental/tsconfig.json +++ b/packages/query-broadcast-client-experimental/tsconfig.json @@ -4,6 +4,12 @@ "outDir": "./dist-ts", "rootDir": "." }, - "include": ["src", "test-setup.ts", "*.config.*", "package.json"], + "include": [ + "src", + "test-setup.ts", + "*.config.ts", + "*.config.js", + "package.json" + ], "references": [{ "path": "../query-core" }] } diff --git a/packages/query-broadcast-client-experimental/tsconfig.prod.json b/packages/query-broadcast-client-experimental/tsconfig.prod.json index 0f4c92da065..2bb29fdf02a 100644 --- a/packages/query-broadcast-client-experimental/tsconfig.prod.json +++ b/packages/query-broadcast-client-experimental/tsconfig.prod.json @@ -4,5 +4,7 @@ "incremental": false, "composite": false, "rootDir": "../../" - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/query-broadcast-client-experimental/vite.config.ts b/packages/query-broadcast-client-experimental/vite.config.ts index bbb12c8c233..84dd64e02cb 100644 --- a/packages/query-broadcast-client-experimental/vite.config.ts +++ b/packages/query-broadcast-client-experimental/vite.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['test-setup.ts'], coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/query-codemods/tsconfig.json b/packages/query-codemods/tsconfig.json index bcd89cd0c8e..0cc454b2a77 100644 --- a/packages/query-codemods/tsconfig.json +++ b/packages/query-codemods/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "./dist-ts", "rootDir": "." }, - "include": ["src", "*.config.*", "package.json"] + "include": ["src", "*.config.ts", "*.config.js", "package.json"] } diff --git a/packages/query-codemods/vite.config.ts b/packages/query-codemods/vite.config.ts index bd580e8205a..c0905cc5308 100644 --- a/packages/query-codemods/vite.config.ts +++ b/packages/query-codemods/vite.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ watch: false, globals: true, coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*.{js,ts,cjs,mjs,jsx,tsx}'], }, diff --git a/packages/query-core/CHANGELOG.md b/packages/query-core/CHANGELOG.md index 9c24c861bfa..6f0871c021b 100644 --- a/packages/query-core/CHANGELOG.md +++ b/packages/query-core/CHANGELOG.md @@ -1,5 +1,115 @@ # @tanstack/query-core +## 5.101.0 + +## 5.100.14 + +## 5.100.13 + +### Patch Changes + +- fix(query-core): drop the custom `NoInfer` re-export and rely on TypeScript's built-in `NoInfer` (TS ≄ 5.4) so `NoInfer` stays assignable to `X[K]` in generic contexts (fixes [#9937](https://github.com/TanStack/query/issues/9937)) ([#10593](https://github.com/TanStack/query/pull/10593)) + +## 5.100.12 + +## 5.100.11 + +## 5.100.10 + +## 5.100.9 + +### Patch Changes + +- fix(query-core): wrap `persister`'s `TQueryKey` in `NoInfer` so that the `persister` slot no longer contributes to `TQueryKey` inference. Follow-up to #10510, which removed `NoInfer` on all three `persister` generics. Preserving `NoInfer` keeps that fix's benefit for `TQueryFnData` while preventing `TQueryKey` from widening to the augmented constraint when `Register.queryKey` is narrowed — which made `DataTag`-branded wrapper returns un-assignable in contravariant slots. ([#10601](https://github.com/TanStack/query/pull/10601)) + +## 5.100.8 + +## 5.100.7 + +## 5.100.6 + +## 5.100.5 + +### Patch Changes + +- fix(core): propagate AbortSignal reason in infinite queries ([`a53ef97`](https://github.com/TanStack/query/commit/a53ef97f87decb8ea2431710f5199431d3c94c8d)) + +## 5.100.4 + +## 5.100.3 + +### Patch Changes + +- fix(suspense): skip calling combine when queries would suspend ([#10576](https://github.com/TanStack/query/pull/10576)) + +## 5.100.2 + +### Patch Changes + +- fix(query-core): allow `persister` to contribute to `TQueryFnData` inference so a `queryFn` that declares a parameter no longer produces a spurious overload mismatch against a typed persister (#7842). ([#10510](https://github.com/TanStack/query/pull/10510)) + +- fix: preserve infinite query behavior during SSR hydration (#8825) ([#10074](https://github.com/TanStack/query/pull/10074)) + +- ref(core): remove leftover setStateOptions ([#10574](https://github.com/TanStack/query/pull/10574)) + +## 5.100.1 + +### Patch Changes + +- Fix bugs where hydrating queries with promises that had already resolved could cause queries to briefly and incorrectly show as pending/fetching ([#10444](https://github.com/TanStack/query/pull/10444)) + +## 5.100.0 + +### Minor Changes + +- feat(query-core): accept callback function for retryOnMount ([#10515](https://github.com/TanStack/query/pull/10515)) + +## 5.99.2 + +## 5.99.1 + +## 5.99.0 + +## 5.98.0 + +## 5.97.0 + +### Patch Changes + +- fix(query-core): use explicit `undefined` check for timer IDs so that custom `TimeoutProvider`s returning `0` as a valid timer ID are properly cleared ([#10401](https://github.com/TanStack/query/pull/10401)) + +## 5.96.2 + +## 5.96.1 + +## 5.96.0 + +## 5.95.2 + +### Patch Changes + +- fix(timeoutManager): make sure NodeJs.Timout doesn't leak ([#10325](https://github.com/TanStack/query/pull/10325)) + +## 5.95.1 + +### Patch Changes + +- fix(timeoutManager): make sure NodeJs.Timout doesn't leak ([#10323](https://github.com/TanStack/query/pull/10323)) + +## 5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + ## 5.91.2 ### Patch Changes diff --git a/packages/query-core/package.json b/packages/query-core/package.json index 65cb5d92dc6..80f9f479f82 100644 --- a/packages/query-core/package.json +++ b/packages/query-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-core", - "version": "5.91.2", + "version": "5.101.0", "description": "The framework agnostic core that powers TanStack Query", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/query-core/src/__tests__/environmentManager.test.tsx b/packages/query-core/src/__tests__/environmentManager.test.tsx index 8d428d7be0c..3828917a4ea 100644 --- a/packages/query-core/src/__tests__/environmentManager.test.tsx +++ b/packages/query-core/src/__tests__/environmentManager.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { environmentManager, isServer } from '..' describe('environmentManager', () => { @@ -6,11 +6,11 @@ describe('environmentManager', () => { environmentManager.setIsServer(() => isServer) }) - test('should use the default isServer detection', () => { + it('should use the default isServer detection', () => { expect(environmentManager.isServer()).toBe(isServer) }) - test('should allow overriding isServer globally', () => { + it('should allow overriding isServer globally', () => { environmentManager.setIsServer(() => true) expect(environmentManager.isServer()).toBe(true) @@ -18,7 +18,7 @@ describe('environmentManager', () => { expect(environmentManager.isServer()).toBe(false) }) - test('should allow overriding isServer with a function', () => { + it('should allow overriding isServer with a function', () => { let server = true environmentManager.setIsServer(() => server) expect(environmentManager.isServer()).toBe(true) diff --git a/packages/query-core/src/__tests__/focusManager.test.tsx b/packages/query-core/src/__tests__/focusManager.test.tsx index 5900b9b2025..03e60d5d7f5 100644 --- a/packages/query-core/src/__tests__/focusManager.test.tsx +++ b/packages/query-core/src/__tests__/focusManager.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FocusManager } from '../focusManager' describe('focusManager', () => { @@ -53,7 +53,7 @@ describe('focusManager', () => { globalThis.document = document }) - test('cleanup (removeEventListener) should not be called if window is not defined', () => { + it('cleanup (removeEventListener) should not be called if window is not defined', () => { const windowSpy = vi.spyOn(globalThis, 'window', 'get') windowSpy.mockImplementation( () => undefined as unknown as Window & typeof globalThis, @@ -76,7 +76,7 @@ describe('focusManager', () => { windowSpy.mockRestore() }) - test('cleanup (removeEventListener) should not be called if window.addEventListener is not defined', () => { + it('cleanup (removeEventListener) should not be called if window.addEventListener is not defined', () => { const { addEventListener } = globalThis.window // @ts-expect-error @@ -113,7 +113,7 @@ describe('focusManager', () => { unsubscribeSpy.mockRestore() }) - test('should call removeEventListener when last listener unsubscribes', () => { + it('should call removeEventListener when last listener unsubscribes', () => { const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener') const removeEventListenerSpy = vi.spyOn( @@ -131,7 +131,7 @@ describe('focusManager', () => { expect(removeEventListenerSpy).toHaveBeenCalledTimes(1) // visibilitychange event }) - test('should keep setup function even if last listener unsubscribes', () => { + it('should keep setup function even if last listener unsubscribes', () => { const setupSpy = vi.fn().mockImplementation(() => () => undefined) focusManager.setEventListener(setupSpy) @@ -149,7 +149,7 @@ describe('focusManager', () => { unsubscribe2() }) - test('should call listeners when setFocused is called', () => { + it('should call listeners when setFocused is called', () => { const listener = vi.fn() focusManager.subscribe(listener) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 46945e1b891..c64cb10da53 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { sleep } from '@tanstack/query-test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' @@ -15,46 +15,41 @@ describe('dehydration and rehydration', () => { vi.useRealTimers() }) - test('should work with serializable values', async () => { + it('should work with serializable values', async () => { + const stringKey = queryKey() + const numberKey = queryKey() + const booleanKey = queryKey() + const nullKey = queryKey() + const arrayKey = queryKey() + const nestedKey = queryKey() + const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(0).then(() => 'string'), - }), - ) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['number'], - queryFn: () => sleep(0).then(() => 1), - }), - ) - - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['boolean'], - queryFn: () => sleep(0).then(() => true), - }), - ) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['null'], - queryFn: () => sleep(0).then(() => null), - }), - ) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['array'], - queryFn: () => sleep(0).then(() => ['string', 0]), - }), - ) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['nested'], - queryFn: () => sleep(0).then(() => ({ key: [{ nestedKey: 1 }] })), - }), - ) + queryClient.prefetchQuery({ + queryKey: stringKey, + queryFn: () => sleep(0).then(() => 'string'), + }) + queryClient.prefetchQuery({ + queryKey: numberKey, + queryFn: () => sleep(0).then(() => 1), + }) + queryClient.prefetchQuery({ + queryKey: booleanKey, + queryFn: () => sleep(0).then(() => true), + }) + queryClient.prefetchQuery({ + queryKey: nullKey, + queryFn: () => sleep(0).then(() => null), + }) + queryClient.prefetchQuery({ + queryKey: arrayKey, + queryFn: () => sleep(0).then(() => ['string', 0]), + }) + queryClient.prefetchQuery({ + queryKey: nestedKey, + queryFn: () => sleep(0).then(() => ({ key: [{ nestedKey: 1 }] })), + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient) const stringified = JSON.stringify(dehydrated) @@ -66,51 +61,49 @@ describe('dehydration and rehydration', () => { queryCache: hydrationCache, }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find({ queryKey: ['string'] })?.state.data).toBe( + expect(hydrationCache.find({ queryKey: stringKey })?.state.data).toBe( 'string', ) - expect(hydrationCache.find({ queryKey: ['number'] })?.state.data).toBe(1) - expect(hydrationCache.find({ queryKey: ['boolean'] })?.state.data).toBe( - true, - ) - expect(hydrationCache.find({ queryKey: ['null'] })?.state.data).toBe(null) - expect(hydrationCache.find({ queryKey: ['array'] })?.state.data).toEqual([ + expect(hydrationCache.find({ queryKey: numberKey })?.state.data).toBe(1) + expect(hydrationCache.find({ queryKey: booleanKey })?.state.data).toBe(true) + expect(hydrationCache.find({ queryKey: nullKey })?.state.data).toBe(null) + expect(hydrationCache.find({ queryKey: arrayKey })?.state.data).toEqual([ 'string', 0, ]) - expect(hydrationCache.find({ queryKey: ['nested'] })?.state.data).toEqual({ + expect(hydrationCache.find({ queryKey: nestedKey })?.state.data).toEqual({ key: [{ nestedKey: 1 }], }) const fetchDataAfterHydration = vi.fn<(...args: Array) => unknown>() await hydrationClient.prefetchQuery({ - queryKey: ['string'], + queryKey: stringKey, queryFn: fetchDataAfterHydration, staleTime: 1000, }) await hydrationClient.prefetchQuery({ - queryKey: ['number'], + queryKey: numberKey, queryFn: fetchDataAfterHydration, staleTime: 1000, }) await hydrationClient.prefetchQuery({ - queryKey: ['boolean'], + queryKey: booleanKey, queryFn: fetchDataAfterHydration, staleTime: 1000, }) await hydrationClient.prefetchQuery({ - queryKey: ['null'], + queryKey: nullKey, queryFn: fetchDataAfterHydration, staleTime: 1000, }) await hydrationClient.prefetchQuery({ - queryKey: ['array'], + queryKey: arrayKey, queryFn: fetchDataAfterHydration, staleTime: 1000, }) await hydrationClient.prefetchQuery({ - queryKey: ['nested'], + queryKey: nestedKey, queryFn: fetchDataAfterHydration, staleTime: 1000, }) @@ -120,15 +113,15 @@ describe('dehydration and rehydration', () => { hydrationClient.clear() }) - test('should not dehydrate queries if dehydrateQueries is set to false', () => { + it('should not dehydrate queries if dehydrateQueries is set to false', async () => { + const key = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(0).then(() => 'string'), - }), - ) + queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'string'), + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient, { shouldDehydrateQuery: () => false, @@ -139,16 +132,16 @@ describe('dehydration and rehydration', () => { queryClient.clear() }) - test('should use the garbage collection time from the client', async () => { + it('should use the garbage collection time from the client', async () => { + const key = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(0).then(() => 'string'), - gcTime: 50, - }), - ) + queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'string'), + gcTime: 50, + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient) const stringified = JSON.stringify(dehydrated) @@ -160,25 +153,23 @@ describe('dehydration and rehydration', () => { const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find({ queryKey: ['string'] })?.state.data).toBe( - 'string', - ) + expect(hydrationCache.find({ queryKey: key })?.state.data).toBe('string') await vi.advanceTimersByTimeAsync(100) - expect(hydrationCache.find({ queryKey: ['string'] })).toBeTruthy() + expect(hydrationCache.find({ queryKey: key })).toBeTruthy() queryClient.clear() hydrationClient.clear() }) - test('should be able to provide default options for the hydrated queries', async () => { + it('should be able to provide default options for the hydrated queries', async () => { + const key = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(0).then(() => 'string'), - }), - ) + queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'string'), + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient) const stringified = JSON.stringify(dehydrated) const parsed = JSON.parse(stringified) @@ -187,14 +178,13 @@ describe('dehydration and rehydration', () => { hydrate(hydrationClient, parsed, { defaultOptions: { queries: { retry: 10 } }, }) - expect(hydrationCache.find({ queryKey: ['string'] })?.options.retry).toBe( - 10, - ) + expect(hydrationCache.find({ queryKey: key })?.options.retry).toBe(10) queryClient.clear() hydrationClient.clear() }) - test('should respect query defaultOptions specified on the QueryClient', async () => { + it('should respect query defaultOptions specified on the QueryClient', async () => { + const key = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache, @@ -202,13 +192,12 @@ describe('dehydration and rehydration', () => { dehydrate: { shouldDehydrateQuery: () => true }, }, }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string'], - retry: 0, - queryFn: () => Promise.reject(new Error('error')), - }), - ) + queryClient.prefetchQuery({ + queryKey: key, + retry: 0, + queryFn: () => Promise.reject(new Error('error')), + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient) expect(dehydrated.queries.length).toBe(1) expect(dehydrated.queries[0]?.state.error).toStrictEqual(new Error('error')) @@ -222,17 +211,14 @@ describe('dehydration and rehydration', () => { hydrate(hydrationClient, parsed, { defaultOptions: { queries: { gcTime: 10 } }, }) - expect(hydrationCache.find({ queryKey: ['string'] })?.options.retry).toBe( - 10, - ) - expect(hydrationCache.find({ queryKey: ['string'] })?.options.gcTime).toBe( - 10, - ) + expect(hydrationCache.find({ queryKey: key })?.options.retry).toBe(10) + expect(hydrationCache.find({ queryKey: key })?.options.gcTime).toBe(10) queryClient.clear() hydrationClient.clear() }) - test('should respect mutation defaultOptions specified on the QueryClient', async () => { + it('should respect mutation defaultOptions specified on the QueryClient', async () => { + const key = queryKey() const mutationCache = new MutationCache() const queryClient = new QueryClient({ mutationCache, @@ -245,7 +231,7 @@ describe('dehydration and rehydration', () => { await executeMutation( queryClient, { - mutationKey: ['string'], + mutationKey: key, mutationFn: () => Promise.resolve('done'), }, undefined, @@ -264,25 +250,22 @@ describe('dehydration and rehydration', () => { hydrate(hydrationClient, parsed, { defaultOptions: { mutations: { gcTime: 10 } }, }) - expect( - hydrationCache.find({ mutationKey: ['string'] })?.options.retry, - ).toBe(10) - expect( - hydrationCache.find({ mutationKey: ['string'] })?.options.gcTime, - ).toBe(10) + expect(hydrationCache.find({ mutationKey: key })?.options.retry).toBe(10) + expect(hydrationCache.find({ mutationKey: key })?.options.gcTime).toBe(10) queryClient.clear() hydrationClient.clear() }) - test('should work with complex keys', async () => { + it('should work with complex keys', async () => { + const key = queryKey() + const complexKey = [...key, { key: ['string'], key2: 0 }] const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string', { key: ['string'], key2: 0 }], - queryFn: () => sleep(0).then(() => 'string'), - }), - ) + queryClient.prefetchQuery({ + queryKey: complexKey, + queryFn: () => sleep(0).then(() => 'string'), + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient) const stringified = JSON.stringify(dehydrated) @@ -294,49 +277,49 @@ describe('dehydration and rehydration', () => { hydrate(hydrationClient, parsed) expect( hydrationCache.find({ - queryKey: ['string', { key: ['string'], key2: 0 }], + queryKey: complexKey, })?.state.data, ).toBe('string') const fetchDataAfterHydration = vi.fn<(...args: Array) => unknown>() - await vi.waitFor(() => - hydrationClient.prefetchQuery({ - queryKey: ['string', { key: ['string'], key2: 0 }], - queryFn: fetchDataAfterHydration, - staleTime: 100, - }), - ) + await hydrationClient.prefetchQuery({ + queryKey: complexKey, + queryFn: fetchDataAfterHydration, + staleTime: 100, + }) expect(fetchDataAfterHydration).toHaveBeenCalledTimes(0) queryClient.clear() hydrationClient.clear() }) - test('should only hydrate successful queries by default', async () => { + it('should only hydrate successful queries by default', async () => { + const successKey = queryKey() + const loadingKey = queryKey() + const errorKey = queryKey() + const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['success'], - queryFn: () => sleep(0).then(() => 'success'), - }), - ) queryClient.prefetchQuery({ - queryKey: ['loading'], + queryKey: successKey, + queryFn: () => sleep(0).then(() => 'success'), + }) + await vi.advanceTimersByTimeAsync(0) + queryClient.prefetchQuery({ + queryKey: loadingKey, queryFn: () => sleep(10000).then(() => 'loading'), }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['error'], - queryFn: () => { - throw new Error() - }, - }), - ) + queryClient.prefetchQuery({ + queryKey: errorKey, + queryFn: () => { + throw new Error() + }, + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient) const stringified = JSON.stringify(dehydrated) @@ -347,38 +330,37 @@ describe('dehydration and rehydration', () => { const hydrationClient = new QueryClient({ queryCache: hydrationCache }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find({ queryKey: ['success'] })).toBeTruthy() - expect(hydrationCache.find({ queryKey: ['loading'] })).toBeFalsy() - expect(hydrationCache.find({ queryKey: ['error'] })).toBeFalsy() + expect(hydrationCache.find({ queryKey: successKey })).toBeTruthy() + expect(hydrationCache.find({ queryKey: loadingKey })).toBeFalsy() + expect(hydrationCache.find({ queryKey: errorKey })).toBeFalsy() queryClient.clear() hydrationClient.clear() consoleMock.mockRestore() }) - test('should filter queries via dehydrateQuery', async () => { + it('should filter queries via dehydrateQuery', async () => { + const stringKey = queryKey() + const numberKey = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(0).then(() => 'string'), - }), - ) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['number'], - queryFn: () => sleep(0).then(() => 1), - }), - ) + queryClient.prefetchQuery({ + queryKey: stringKey, + queryFn: () => sleep(0).then(() => 'string'), + }) + queryClient.prefetchQuery({ + queryKey: numberKey, + queryFn: () => sleep(0).then(() => 1), + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient, { - shouldDehydrateQuery: (query) => query.queryKey[0] !== 'string', + shouldDehydrateQuery: (query) => query.queryKey !== stringKey, }) // This is testing implementation details that can change and are not // part of the public API, but is important for keeping the payload small const dehydratedQuery = dehydrated.queries.find( - (query) => query.queryKey[0] === 'string', + (query) => query.queryKey === stringKey, ) expect(dehydratedQuery).toBeUndefined() @@ -390,22 +372,23 @@ describe('dehydration and rehydration', () => { const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find({ queryKey: ['string'] })).toBeUndefined() - expect(hydrationCache.find({ queryKey: ['number'] })?.state.data).toBe(1) + expect(hydrationCache.find({ queryKey: stringKey })).toBeUndefined() + expect(hydrationCache.find({ queryKey: numberKey })?.state.data).toBe(1) queryClient.clear() hydrationClient.clear() }) - test('should not overwrite query in cache if hydrated query is older', async () => { + it('should not overwrite query in cache if hydrated query is older', async () => { + const key = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(5).then(() => 'string-older'), - }), - ) + const promise1 = queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(5).then(() => 'string-older'), + }) + await vi.advanceTimersByTimeAsync(5) + await promise1 const dehydrated = dehydrate(queryClient) const stringified = JSON.stringify(dehydrated) @@ -414,15 +397,15 @@ describe('dehydration and rehydration', () => { const parsed = JSON.parse(stringified) const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) - await vi.waitFor(() => - hydrationClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(5).then(() => 'string-newer'), - }), - ) + const promise2 = hydrationClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(5).then(() => 'string-newer'), + }) + await vi.advanceTimersByTimeAsync(5) + await promise2 hydrate(hydrationClient, parsed) - expect(hydrationCache.find({ queryKey: ['string'] })?.state.data).toBe( + expect(hydrationCache.find({ queryKey: key })?.state.data).toBe( 'string-newer', ) @@ -430,26 +413,27 @@ describe('dehydration and rehydration', () => { hydrationClient.clear() }) - test('should overwrite query in cache if hydrated query is newer', async () => { + it('should overwrite query in cache if hydrated query is newer', async () => { + const key = queryKey() const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) - await vi.waitFor(() => - hydrationClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(5).then(() => 'string-older'), - }), - ) + const promise1 = hydrationClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(5).then(() => 'string-older'), + }) + await vi.advanceTimersByTimeAsync(5) + await promise1 // --- const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['string'], - queryFn: () => sleep(5).then(() => 'string-newer'), - }), - ) + const promise2 = queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(5).then(() => 'string-newer'), + }) + await vi.advanceTimersByTimeAsync(5) + await promise2 const dehydrated = dehydrate(queryClient) const stringified = JSON.stringify(dehydrated) @@ -457,7 +441,7 @@ describe('dehydration and rehydration', () => { const parsed = JSON.parse(stringified) hydrate(hydrationClient, parsed) - expect(hydrationCache.find({ queryKey: ['string'] })?.state.data).toBe( + expect(hydrationCache.find({ queryKey: key })?.state.data).toBe( 'string-newer', ) @@ -465,7 +449,8 @@ describe('dehydration and rehydration', () => { hydrationClient.clear() }) - test('should be able to dehydrate mutations and continue on hydration', async () => { + it('should be able to dehydrate mutations and continue on hydration', async () => { + const key = queryKey() const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) const onlineMock = mockOnlineManagerIsOnline(false) @@ -483,7 +468,7 @@ describe('dehydration and rehydration', () => { const serverClient = new QueryClient() - serverClient.setMutationDefaults(['addTodo'], { + serverClient.setMutationDefaults(key, { mutationFn: serverAddTodo, onMutate: serverOnMutate, onSuccess: serverOnSuccess, @@ -494,7 +479,7 @@ describe('dehydration and rehydration', () => { executeMutation( serverClient, { - mutationKey: ['addTodo'], + mutationKey: key, }, { text: 'text' }, ).catch(() => undefined) @@ -526,7 +511,7 @@ describe('dehydration and rehydration', () => { }) const clientOnSuccess = vi.fn() - client.setMutationDefaults(['addTodo'], { + client.setMutationDefaults(key, { mutationFn: clientAddTodo, onMutate: clientOnMutate, onSuccess: clientOnSuccess, @@ -545,7 +530,7 @@ describe('dehydration and rehydration', () => { { id: 2, text: 'text' }, { text: 'text' }, { optimisticTodo: { id: 1, text: 'text' } }, - { client: client, meta: undefined, mutationKey: ['addTodo'] }, + { client: client, meta: undefined, mutationKey: key }, ) client.clear() @@ -553,7 +538,8 @@ describe('dehydration and rehydration', () => { onlineMock.mockRestore() }) - test('should not dehydrate mutations if dehydrateMutations is set to false', async () => { + it('should not dehydrate mutations if dehydrateMutations is set to false', async () => { + const key = queryKey() const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) @@ -563,7 +549,7 @@ describe('dehydration and rehydration', () => { const queryClient = new QueryClient() - queryClient.setMutationDefaults(['addTodo'], { + queryClient.setMutationDefaults(key, { mutationFn: serverAddTodo, retry: false, }) @@ -571,7 +557,7 @@ describe('dehydration and rehydration', () => { executeMutation( queryClient, { - mutationKey: ['addTodo'], + mutationKey: key, }, { text: 'text' }, ).catch(() => undefined) @@ -587,7 +573,8 @@ describe('dehydration and rehydration', () => { consoleMock.mockRestore() }) - test('should not dehydrate mutation if mutation state is set to pause', async () => { + it('should not dehydrate mutation if mutation state is set to pause', async () => { + const key = queryKey() const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) @@ -597,7 +584,7 @@ describe('dehydration and rehydration', () => { const queryClient = new QueryClient() - queryClient.setMutationDefaults(['addTodo'], { + queryClient.setMutationDefaults(key, { mutationFn: serverAddTodo, retry: 1, retryDelay: 20, @@ -606,7 +593,7 @@ describe('dehydration and rehydration', () => { executeMutation( queryClient, { - mutationKey: ['addTodo'], + mutationKey: key, }, { text: 'text' }, ).catch(() => undefined) @@ -622,7 +609,7 @@ describe('dehydration and rehydration', () => { consoleMock.mockRestore() }) - test('should not hydrate if the hydratedState is null or is not an object', () => { + it('should not hydrate if the hydratedState is null or is not an object', () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) @@ -632,7 +619,7 @@ describe('dehydration and rehydration', () => { queryClient.clear() }) - test('should support hydratedState with undefined queries and mutations', () => { + it('should support hydratedState with undefined queries and mutations', () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) @@ -642,7 +629,8 @@ describe('dehydration and rehydration', () => { queryClient.clear() }) - test('should set the fetchStatus to idle when creating a query with dehydrate', async () => { + it('should set the fetchStatus to idle when creating a query with dehydrate', async () => { + const key = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) @@ -663,16 +651,16 @@ describe('dehydration and rehydration', () => { } await queryClient.prefetchQuery({ - queryKey: ['string'], + queryKey: key, queryFn: () => customFetchData(), }) - queryClient.refetchQueries({ queryKey: ['string'] }) + queryClient.refetchQueries({ queryKey: key }) const dehydrated = dehydrate(queryClient) resolvePromise('string') expect( - dehydrated.queries.find((q) => q.queryHash === '["string"]')?.state + dehydrated.queries.find((q) => q.queryHash === JSON.stringify(key))?.state .fetchStatus, ).toBe('fetching') const stringified = JSON.stringify(dehydrated) @@ -682,45 +670,48 @@ describe('dehydration and rehydration', () => { const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) hydrate(hydrationClient, parsed) - expect( - hydrationCache.find({ queryKey: ['string'] })?.state.fetchStatus, - ).toBe('idle') + expect(hydrationCache.find({ queryKey: key })?.state.fetchStatus).toBe( + 'idle', + ) }) - test('should dehydrate and hydrate meta for queries', async () => { + it('should dehydrate and hydrate meta for queries', async () => { + const metaKey = queryKey() + const noMetaKey = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['meta'], - queryFn: () => Promise.resolve('meta'), - meta: { - some: 'meta', - }, - }), - ) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['no-meta'], - queryFn: () => Promise.resolve('no-meta'), - }), - ) + queryClient.prefetchQuery({ + queryKey: metaKey, + queryFn: () => Promise.resolve('meta'), + meta: { + some: 'meta', + }, + }) + queryClient.prefetchQuery({ + queryKey: noMetaKey, + queryFn: () => Promise.resolve('no-meta'), + }) + await vi.advanceTimersByTimeAsync(0) const dehydrated = dehydrate(queryClient) expect( - dehydrated.queries.find((q) => q.queryHash === '["meta"]')?.meta, + dehydrated.queries.find((q) => q.queryHash === JSON.stringify(metaKey)) + ?.meta, ).toEqual({ some: 'meta', }) expect( - dehydrated.queries.find((q) => q.queryHash === '["no-meta"]')?.meta, + dehydrated.queries.find((q) => q.queryHash === JSON.stringify(noMetaKey)) + ?.meta, ).toEqual(undefined) expect( Object.keys( - dehydrated.queries.find((q) => q.queryHash === '["no-meta"]')!, + dehydrated.queries.find( + (q) => q.queryHash === JSON.stringify(noMetaKey), + )!, ), ).not.toEqual(expect.arrayContaining(['meta'])) @@ -734,22 +725,24 @@ describe('dehydration and rehydration', () => { queryCache: hydrationCache, }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find({ queryKey: ['meta'] })?.meta).toEqual({ + expect(hydrationCache.find({ queryKey: metaKey })?.meta).toEqual({ some: 'meta', }) - expect(hydrationCache.find({ queryKey: ['no-meta'] })?.meta).toEqual( + expect(hydrationCache.find({ queryKey: noMetaKey })?.meta).toEqual( undefined, ) }) - test('should dehydrate and hydrate meta for mutations', async () => { + it('should dehydrate and hydrate meta for mutations', async () => { + const metaKey = queryKey() + const noMetaKey = queryKey() const mutationCache = new MutationCache() const queryClient = new QueryClient({ mutationCache }) await executeMutation( queryClient, { - mutationKey: ['meta'], + mutationKey: metaKey, mutationFn: () => Promise.resolve('meta'), meta: { some: 'meta', @@ -761,7 +754,7 @@ describe('dehydration and rehydration', () => { await executeMutation( queryClient, { - mutationKey: ['no-meta'], + mutationKey: noMetaKey, mutationFn: () => Promise.resolve('no-meta'), }, undefined, @@ -793,30 +786,30 @@ describe('dehydration and rehydration', () => { mutationCache: hydrationCache, }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find({ mutationKey: ['meta'] })?.meta).toEqual({ + expect(hydrationCache.find({ mutationKey: metaKey })?.meta).toEqual({ some: 'meta', }) - expect(hydrationCache.find({ mutationKey: ['no-meta'] })?.meta).toEqual( + expect(hydrationCache.find({ mutationKey: noMetaKey })?.meta).toEqual( undefined, ) }) - test('should not change fetchStatus when updating a query with dehydrate', async () => { + it('should not change fetchStatus when updating a query with dehydrate', async () => { + const key = queryKey() const queryClient = new QueryClient() const options = { - queryKey: ['string'], - queryFn: async () => { - await sleep(10) - return 'string' - }, + queryKey: key, + queryFn: () => sleep(10).then(() => 'string'), } as const - await vi.waitFor(() => queryClient.prefetchQuery(options)) + const prefetchPromise = queryClient.prefetchQuery(options) + await vi.advanceTimersByTimeAsync(10) + await prefetchPromise const dehydrated = dehydrate(queryClient) expect( - dehydrated.queries.find((q) => q.queryHash === '["string"]')?.state + dehydrated.queries.find((q) => q.queryHash === JSON.stringify(key))?.state .fetchStatus, ).toBe('idle') const stringified = JSON.stringify(dehydrated) @@ -828,23 +821,25 @@ describe('dehydration and rehydration', () => { const promise = hydrationClient.prefetchQuery(options) hydrate(hydrationClient, parsed) - expect( - hydrationCache.find({ queryKey: ['string'] })?.state.fetchStatus, - ).toBe('fetching') - await vi.waitFor(() => promise) - expect( - hydrationCache.find({ queryKey: ['string'] })?.state.fetchStatus, - ).toBe('idle') + expect(hydrationCache.find({ queryKey: key })?.state.fetchStatus).toBe( + 'fetching', + ) + await vi.advanceTimersByTimeAsync(10) + await promise + expect(hydrationCache.find({ queryKey: key })?.state.fetchStatus).toBe( + 'idle', + ) }) - test('should dehydrate and hydrate mutation scopes', () => { + it('should dehydrate and hydrate mutation scopes', () => { + const key = queryKey() const queryClient = new QueryClient() const onlineMock = mockOnlineManagerIsOnline(false) void executeMutation( queryClient, { - mutationKey: ['mutation'], + mutationKey: key, mutationFn: () => { return Promise.resolve('mutation') }, @@ -871,21 +866,22 @@ describe('dehydration and rehydration', () => { onlineMock.mockRestore() }) - test('should dehydrate promises for pending queries', async () => { + it('should dehydrate promises for pending queries', async () => { + const successKey = queryKey() + const pendingKey = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache, defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } }, }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['success'], - queryFn: () => sleep(0).then(() => 'success'), - }), - ) + queryClient.prefetchQuery({ + queryKey: successKey, + queryFn: () => sleep(0).then(() => 'success'), + }) + await vi.advanceTimersByTimeAsync(0) const promise = queryClient.prefetchQuery({ - queryKey: ['pending'], + queryKey: pendingKey, queryFn: () => sleep(10).then(() => 'pending'), }) const dehydrated = dehydrate(queryClient) @@ -893,25 +889,27 @@ describe('dehydration and rehydration', () => { expect(dehydrated.queries[0]?.promise).toBeUndefined() expect(dehydrated.queries[1]?.promise).toBeInstanceOf(Promise) - await vi.waitFor(() => promise) + await vi.advanceTimersByTimeAsync(10) + await promise queryClient.clear() }) - test('should hydrate promises even without observers', async () => { + it('should hydrate promises even without observers', async () => { + const successKey = queryKey() + const pendingKey = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache, defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } }, }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['success'], - queryFn: () => sleep(0).then(() => 'success'), - }), - ) + queryClient.prefetchQuery({ + queryKey: successKey, + queryFn: () => sleep(0).then(() => 'success'), + }) + await vi.advanceTimersByTimeAsync(0) void queryClient.prefetchQuery({ - queryKey: ['pending'], + queryKey: pendingKey, queryFn: () => sleep(20).then(() => 'pending'), }) const dehydrated = dehydrate(queryClient) @@ -925,48 +923,45 @@ describe('dehydration and rehydration', () => { hydrate(hydrationClient, dehydrated) - expect(hydrationCache.find({ queryKey: ['success'] })?.state.data).toBe( + expect(hydrationCache.find({ queryKey: successKey })?.state.data).toBe( 'success', ) - expect(hydrationCache.find({ queryKey: ['pending'] })?.state).toMatchObject( - { - data: undefined, - dataUpdateCount: 0, - dataUpdatedAt: 0, - error: null, - errorUpdateCount: 0, - errorUpdatedAt: 0, - fetchFailureCount: 0, - fetchFailureReason: null, - fetchMeta: null, - fetchStatus: 'fetching', - isInvalidated: false, - status: 'pending', - }, - ) + expect(hydrationCache.find({ queryKey: pendingKey })?.state).toMatchObject({ + data: undefined, + dataUpdateCount: 0, + dataUpdatedAt: 0, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + fetchStatus: 'fetching', + isInvalidated: false, + status: 'pending', + }) - await vi.waitFor(() => - expect( - hydrationCache.find({ queryKey: ['pending'] })?.state, - ).toMatchObject({ - data: 'pending', - dataUpdateCount: 1, - dataUpdatedAt: expect.any(Number), - error: null, - errorUpdateCount: 0, - errorUpdatedAt: 0, - fetchFailureCount: 0, - fetchFailureReason: null, - fetchMeta: null, - fetchStatus: 'idle', - isInvalidated: false, - status: 'success', - }), - ) + await vi.advanceTimersByTimeAsync(20) + + expect(hydrationCache.find({ queryKey: pendingKey })?.state).toMatchObject({ + data: 'pending', + dataUpdateCount: 1, + dataUpdatedAt: expect.any(Number), + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + fetchStatus: 'idle', + isInvalidated: false, + status: 'success', + }) }) - test('should transform promise result', async () => { + it('should transform promise result', async () => { + const key = queryKey() const queryClient = new QueryClient({ defaultOptions: { dehydrate: { @@ -977,7 +972,7 @@ describe('dehydration and rehydration', () => { }) const promise = queryClient.prefetchQuery({ - queryKey: ['transformedStringToDate'], + queryKey: key, queryFn: () => sleep(20).then(() => new Date('2024-01-01T00:00:00.000Z')), }) const dehydrated = dehydrate(queryClient) @@ -992,17 +987,16 @@ describe('dehydration and rehydration', () => { }) hydrate(hydrationClient, dehydrated) - await vi.waitFor(() => promise) - await vi.waitFor(() => - expect( - hydrationClient.getQueryData(['transformedStringToDate']), - ).toBeInstanceOf(Date), - ) + await vi.advanceTimersByTimeAsync(20) + await promise + + expect(hydrationClient.getQueryData(key)).toBeInstanceOf(Date) queryClient.clear() }) - test('should transform query data if promise is already resolved', async () => { + it('should transform query data if promise is already resolved', async () => { + const key = queryKey() const queryClient = new QueryClient({ defaultOptions: { dehydrate: { @@ -1013,7 +1007,7 @@ describe('dehydration and rehydration', () => { }) const promise = queryClient.prefetchQuery({ - queryKey: ['transformedStringToDate'], + queryKey: key, queryFn: () => sleep(0).then(() => new Date('2024-01-01T00:00:00.000Z')), }) await vi.advanceTimersByTimeAsync(20) @@ -1028,17 +1022,15 @@ describe('dehydration and rehydration', () => { }) hydrate(hydrationClient, dehydrated) - await vi.waitFor(() => promise) - await vi.waitFor(() => - expect( - hydrationClient.getQueryData(['transformedStringToDate']), - ).toBeInstanceOf(Date), - ) + await promise + + expect(hydrationClient.getQueryData(key)).toBeInstanceOf(Date) queryClient.clear() }) - test('should overwrite query in cache if hydrated query is newer (with transformation)', async () => { + it('should overwrite query in cache if hydrated query is newer (with transformation)', async () => { + const key = queryKey() const hydrationClient = new QueryClient({ defaultOptions: { hydrate: { @@ -1046,13 +1038,12 @@ describe('dehydration and rehydration', () => { }, }, }) - await vi.waitFor(() => - hydrationClient.prefetchQuery({ - queryKey: ['date'], - queryFn: () => - sleep(5).then(() => new Date('2024-01-01T00:00:00.000Z')), - }), - ) + const hydrationPromise = hydrationClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(5).then(() => new Date('2024-01-01T00:00:00.000Z')), + }) + await vi.advanceTimersByTimeAsync(5) + await hydrationPromise // --- @@ -1064,20 +1055,19 @@ describe('dehydration and rehydration', () => { }, }, }) - await vi.waitFor(() => - queryClient.prefetchQuery({ - queryKey: ['date'], - queryFn: () => - sleep(10).then(() => new Date('2024-01-02T00:00:00.000Z')), - }), - ) + const queryPromise = queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => new Date('2024-01-02T00:00:00.000Z')), + }) + await vi.advanceTimersByTimeAsync(10) + await queryPromise const dehydrated = dehydrate(queryClient) // --- hydrate(hydrationClient, dehydrated) - expect(hydrationClient.getQueryData(['date'])).toStrictEqual( + expect(hydrationClient.getQueryData(key)).toStrictEqual( new Date('2024-01-02T00:00:00.000Z'), ) @@ -1085,7 +1075,8 @@ describe('dehydration and rehydration', () => { hydrationClient.clear() }) - test('should overwrite query in cache if hydrated query is newer (with promise)', async () => { + it('should overwrite query in cache if hydrated query is newer (with promise)', async () => { + const key = queryKey() // --- server --- const serverQueryClient = new QueryClient({ @@ -1097,11 +1088,8 @@ describe('dehydration and rehydration', () => { }) const promise = serverQueryClient.prefetchQuery({ - queryKey: ['data'], - queryFn: async () => { - await sleep(10) - return 'server data' - }, + queryKey: key, + queryFn: () => sleep(10).then(() => 'server data'), }) const dehydrated = dehydrate(serverQueryClient) @@ -1110,20 +1098,21 @@ describe('dehydration and rehydration', () => { const clientQueryClient = new QueryClient() - clientQueryClient.setQueryData(['data'], 'old data', { updatedAt: 10 }) + clientQueryClient.setQueryData(key, 'old data', { updatedAt: 10 }) hydrate(clientQueryClient, dehydrated) - await vi.waitFor(() => promise) - await vi.waitFor(() => - expect(clientQueryClient.getQueryData(['data'])).toBe('server data'), - ) + await vi.advanceTimersByTimeAsync(10) + await promise + + expect(clientQueryClient.getQueryData(key)).toBe('server data') clientQueryClient.clear() serverQueryClient.clear() }) - test('should not overwrite query in cache if existing query is newer (with promise)', async () => { + it('should not overwrite query in cache if existing query is newer (with promise)', async () => { + const key = queryKey() // --- server --- const serverQueryClient = new QueryClient({ @@ -1135,11 +1124,8 @@ describe('dehydration and rehydration', () => { }) const promise = serverQueryClient.prefetchQuery({ - queryKey: ['data'], - queryFn: async () => { - await sleep(10) - return 'server data' - }, + queryKey: key, + queryFn: () => sleep(10).then(() => 'server data'), }) const dehydrated = dehydrate(serverQueryClient) @@ -1155,7 +1141,7 @@ describe('dehydration and rehydration', () => { const clientQueryClient = new QueryClient() - clientQueryClient.setQueryData(['data'], 'newer data', { + clientQueryClient.setQueryData(key, 'newer data', { updatedAt: Date.now(), }) @@ -1164,20 +1150,17 @@ describe('dehydration and rehydration', () => { // If the query was hydrated in error, it would still take some time for it // to end up in the cache, so for the test to fail properly on regressions, // wait for the fetchStatus to be idle - await vi.waitFor(() => - expect(clientQueryClient.getQueryState(['data'])?.fetchStatus).toBe( - 'idle', - ), - ) - await vi.waitFor(() => - expect(clientQueryClient.getQueryData(['data'])).toBe('newer data'), - ) + await vi.advanceTimersByTimeAsync(0) + + expect(clientQueryClient.getQueryState(key)?.fetchStatus).toBe('idle') + expect(clientQueryClient.getQueryData(key)).toBe('newer data') clientQueryClient.clear() serverQueryClient.clear() }) - test('should overwrite data when a new promise is streamed in', async () => { + it('should overwrite data when a new promise is streamed in', async () => { + const key = queryKey() const serializeDataMock = vi.fn((data: any) => data) const deserializeDataMock = vi.fn((data: any) => data) @@ -1193,11 +1176,8 @@ describe('dehydration and rehydration', () => { }) const query = { - queryKey: ['data'], - queryFn: async () => { - await sleep(10) - return countRef.current - }, + queryKey: key, + queryFn: () => sleep(10).then(() => countRef.current), } const promise = serverQueryClient.prefetchQuery(query) @@ -1216,10 +1196,10 @@ describe('dehydration and rehydration', () => { hydrate(clientQueryClient, dehydrated) - await vi.waitFor(() => promise) - await vi.waitFor(() => - expect(clientQueryClient.getQueryData(query.queryKey)).toBe(0), - ) + await vi.advanceTimersByTimeAsync(10) + await promise + + expect(clientQueryClient.getQueryData(query.queryKey)).toBe(0) expect(serializeDataMock).toHaveBeenCalledTimes(1) expect(serializeDataMock).toHaveBeenCalledWith(0) @@ -1229,6 +1209,7 @@ describe('dehydration and rehydration', () => { // --- server --- countRef.current++ + await vi.advanceTimersByTimeAsync(1) serverQueryClient.clear() const promise2 = serverQueryClient.prefetchQuery(query) @@ -1238,10 +1219,10 @@ describe('dehydration and rehydration', () => { hydrate(clientQueryClient, dehydrated) - await vi.waitFor(() => promise2) - await vi.waitFor(() => - expect(clientQueryClient.getQueryData(query.queryKey)).toBe(1), - ) + await vi.advanceTimersByTimeAsync(10) + await promise2 + + expect(clientQueryClient.getQueryData(query.queryKey)).toBe(1) expect(serializeDataMock).toHaveBeenCalledTimes(2) expect(serializeDataMock).toHaveBeenCalledWith(1) @@ -1253,7 +1234,8 @@ describe('dehydration and rehydration', () => { serverQueryClient.clear() }) - test('should not redact errors when shouldRedactErrors returns false', async () => { + it('should not redact errors when shouldRedactErrors returns false', async () => { + const key = queryKey() const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache, @@ -1269,7 +1251,7 @@ describe('dehydration and rehydration', () => { const promise = queryClient .prefetchQuery({ - queryKey: ['error'], + queryKey: key, queryFn: () => Promise.reject(testError), retry: false, }) @@ -1282,7 +1264,8 @@ describe('dehydration and rehydration', () => { await promise }) - test('should handle errors in promises for pending queries', async () => { + it('should handle errors in promises for pending queries', async () => { + const key = queryKey() const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) @@ -1298,7 +1281,7 @@ describe('dehydration and rehydration', () => { const promise = queryClient .prefetchQuery({ - queryKey: ['error'], + queryKey: key, queryFn: () => Promise.reject(new Error('test error')), retry: false, }) @@ -1313,7 +1296,8 @@ describe('dehydration and rehydration', () => { consoleMock.mockRestore() }) - test('should log error in development environment when redacting errors', async () => { + it('should log error in development environment when redacting errors', async () => { + const key = queryKey() const originalNodeEnv = process.env.NODE_ENV process.env.NODE_ENV = 'development' @@ -1335,7 +1319,7 @@ describe('dehydration and rehydration', () => { const promise = queryClient .prefetchQuery({ - queryKey: ['error'], + queryKey: key, queryFn: () => Promise.reject(testError), retry: false, }) @@ -1363,7 +1347,8 @@ describe('dehydration and rehydration', () => { // What can end up happening otherwise is that the content is visible from the // server, but the client renders a Suspense fallback, only to immediately show // the data again. - test('should rehydrate synchronous thenable immediately', async () => { + it('should rehydrate synchronous thenable immediately', async () => { + const key = queryKey() // --- server --- const serverQueryClient = new QueryClient({ @@ -1374,7 +1359,7 @@ describe('dehydration and rehydration', () => { }, }) const originalPromise = serverQueryClient.prefetchQuery({ - queryKey: ['data'], + queryKey: key, queryFn: () => null, }) @@ -1394,10 +1379,429 @@ describe('dehydration and rehydration', () => { hydrate(clientQueryClient, dehydrated) // If data is already resolved, it should end up in the cache immediately - expect(clientQueryClient.getQueryData(['data'])).toBe('server data') + expect(clientQueryClient.getQueryData(key)).toBe('server data') // Need to await the original promise or else it will get a cancellation // error and test will fail await originalPromise }) + + it('should preserve queryType for infinite queries during hydration', async () => { + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + await vi.waitFor(() => + queryClient.prefetchInfiniteQuery({ + queryKey: ['infinite'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`page-${pageParam}`], + nextCursor: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { + items: Array + nextCursor: number + }) => lastPage.nextCursor, + }), + ) + + const dehydrated = dehydrate(queryClient) + + const infiniteQueryState = dehydrated.queries.find( + (q) => q.queryKey[0] === 'infinite', + ) + expect(infiniteQueryState?.queryType).toBe('infinite') + + const hydrationCache = new QueryCache() + const hydrationClient = new QueryClient({ queryCache: hydrationCache }) + hydrate(hydrationClient, dehydrated) + + const hydratedQuery = hydrationCache.find({ queryKey: ['infinite'] }) + expect(hydratedQuery?.state.data).toBeDefined() + expect(hydratedQuery?.state.data).toHaveProperty('pages') + expect(hydratedQuery?.state.data).toHaveProperty('pageParams') + expect((hydratedQuery?.state.data as any).pages).toHaveLength(1) + }) + + it('should attach infiniteQueryBehavior during hydration', async () => { + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + await vi.waitFor(() => + queryClient.prefetchInfiniteQuery({ + queryKey: ['infinite-with-behavior'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + data: `page-${pageParam}`, + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { data: string; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(queryClient) + + const hydrationCache = new QueryCache() + const hydrationClient = new QueryClient({ queryCache: hydrationCache }) + hydrate(hydrationClient, dehydrated) + + const result = await vi.waitFor(() => + hydrationClient.fetchInfiniteQuery({ + queryKey: ['infinite-with-behavior'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + data: `page-${pageParam}`, + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { data: string; next: number }) => + lastPage.next, + }), + ) + + expect(result.pages).toHaveLength(1) + expect(result.pageParams).toHaveLength(1) + }) + + it('should restore infinite query type through dehydrate and hydrate cycle', async () => { + const serverClient = new QueryClient({ queryCache: new QueryCache() }) + + await vi.waitFor(() => + serverClient.prefetchInfiniteQuery({ + queryKey: ['infinite-type-restore'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`item-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(serverClient) + + const dehydratedQuery = dehydrated.queries.find( + (q) => q.queryKey[0] === 'infinite-type-restore', + ) + expect(dehydratedQuery?.queryType).toBe('infinite') + + const clientCache = new QueryCache() + const clientClient = new QueryClient({ queryCache: clientCache }) + hydrate(clientClient, dehydrated) + + const hydratedQuery = clientCache.find({ + queryKey: ['infinite-type-restore'], + }) + expect(hydratedQuery?.queryType).toBe('infinite') + }) + + it('should preserve pages structure when refetching infinite query after hydration', async () => { + const serverClient = new QueryClient({ queryCache: new QueryCache() }) + + await vi.waitFor(() => + serverClient.prefetchInfiniteQuery({ + queryKey: ['refetch'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`page-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(serverClient) + + const clientCache = new QueryCache() + const clientClient = new QueryClient({ queryCache: clientCache }) + hydrate(clientClient, dehydrated) + + const beforeRefetch = clientClient.getQueryData<{ + pages: Array<{ items: Array; next: number }> + pageParams: Array + }>(['refetch']) + expect(beforeRefetch?.pages).toHaveLength(1) + expect(beforeRefetch?.pageParams).toHaveLength(1) + + const result = await vi.waitFor(() => + clientClient.fetchInfiniteQuery({ + queryKey: ['refetch'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`page-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + expect(result).toHaveProperty('pages') + expect(result).toHaveProperty('pageParams') + expect(Array.isArray(result.pages)).toBe(true) + expect(result.pages).toHaveLength(1) + expect(result.pages[0]).toHaveProperty('items') + }) + + it('should retain infinite query type after subsequent setOptions calls', async () => { + const serverClient = new QueryClient({ queryCache: new QueryCache() }) + + await vi.waitFor(() => + serverClient.prefetchInfiniteQuery({ + queryKey: ['infinite-setoptions-guard'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + data: `p${pageParam}`, + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { data: string; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(serverClient) + + const clientCache = new QueryCache() + const clientClient = new QueryClient({ queryCache: clientCache }) + hydrate(clientClient, dehydrated) + + const query = clientCache.find({ queryKey: ['infinite-setoptions-guard'] })! + expect(query.queryType).toBe('infinite') + + query.setOptions({ queryKey: ['infinite-setoptions-guard'] }) + expect(query.queryType).toBe('infinite') + }) + + it('should restore all pages when refetching multi-page infinite query after hydration', async () => { + const serverClient = new QueryClient({ queryCache: new QueryCache() }) + + await vi.waitFor(() => + serverClient.prefetchInfiniteQuery({ + queryKey: ['infinite-multipage-restore'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`item-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + pages: 2, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(serverClient) + + const clientCache = new QueryCache() + const clientClient = new QueryClient({ queryCache: clientCache }) + hydrate(clientClient, dehydrated) + + const beforeRefetch = clientClient.getQueryData<{ + pages: Array + pageParams: Array + }>(['infinite-multipage-restore']) + expect(beforeRefetch?.pages).toHaveLength(2) + + const result = await vi.waitFor(() => + clientClient.fetchInfiniteQuery({ + queryKey: ['infinite-multipage-restore'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`item-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + pages: 2, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + expect(result.pages).toHaveLength(2) + expect(result.pageParams).toHaveLength(2) + expect(result.pages[0]).toHaveProperty('items') + expect(result.pages[1]).toHaveProperty('items') + }) + + // Companion to the test above: when the query already exists in the cache + // (e.g. after an initial render or a first hydration pass), the same + // synchronous thenable resolution must also produce status: 'success'. + // Previously the if (query) branch would spread status: 'pending' from the + // server state without correcting it for the resolved data. + it('should set status to success when rehydrating an existing pending query with a synchronously resolved promise', async () => { + const key = queryKey() + // --- server --- + + const serverQueryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + let resolvePrefetch: undefined | ((value?: unknown) => void) + const prefetchPromise = new Promise((res) => { + resolvePrefetch = res + }) + // Keep the query pending so it dehydrates with status: 'pending' and a promise + void serverQueryClient.prefetchQuery({ + queryKey: key, + queryFn: () => prefetchPromise, + }) + + const dehydrated = dehydrate(serverQueryClient) + expect(dehydrated.queries[0]?.state.status).toBe('pending') + + // Simulate a synchronous thenable – models a React streaming promise that + // resolved before the second hydrate() call. + resolvePrefetch?.('server data') + // @ts-expect-error + dehydrated.queries[0].promise.then = (cb) => { + cb?.('server data') + // @ts-expect-error + return dehydrated.queries[0].promise + } + + // --- client --- + // Query already exists in the cache in a pending state, as it would after + // a first hydration pass or an initial render. + const clientQueryClient = new QueryClient() + void clientQueryClient.prefetchQuery({ + queryKey: key, + queryFn: () => { + throw new Error('QueryFn on client should not be called') + }, + }) + + const query = clientQueryClient.getQueryCache().find({ queryKey: key })! + expect(query.state.status).toBe('pending') + + hydrate(clientQueryClient, dehydrated) + + expect(clientQueryClient.getQueryData(key)).toBe('server data') + expect(query.state.status).toBe('success') + + clientQueryClient.clear() + serverQueryClient.clear() + }) + + it('should not transition to a fetching/pending state when hydrating an already resolved promise into a new query', async () => { + const key = queryKey() + // --- server --- + const serverQueryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + let resolvePrefetch: undefined | ((value?: unknown) => void) + const prefetchPromise = new Promise((res) => { + resolvePrefetch = res + }) + void serverQueryClient.prefetchQuery({ + queryKey: key, + queryFn: () => prefetchPromise, + }) + const dehydrated = dehydrate(serverQueryClient) + + // Simulate a synchronous thenable – the promise was already resolved + // before we hydrate on the client + resolvePrefetch?.('server data') + // @ts-expect-error + dehydrated.queries[0].promise.then = (cb) => { + cb?.('server data') + // @ts-expect-error + return dehydrated.queries[0].promise + } + + // --- client --- + const clientQueryClient = new QueryClient() + + const states: Array<{ status: string; fetchStatus: string }> = [] + const unsubscribe = clientQueryClient.getQueryCache().subscribe((event) => { + if (event.type === 'updated') { + const { status, fetchStatus } = event.query.state + states.push({ status, fetchStatus }) + } + }) + + hydrate(clientQueryClient, dehydrated) + await vi.advanceTimersByTimeAsync(0) + unsubscribe() + + expect(states).not.toContainEqual( + expect.objectContaining({ fetchStatus: 'fetching' }), + ) + expect(states).not.toContainEqual( + expect.objectContaining({ status: 'pending' }), + ) + + clientQueryClient.clear() + serverQueryClient.clear() + }) + + it('should not transition to a fetching/pending state when hydrating an already resolved promise into an existing query', async () => { + const key = queryKey() + // --- server --- + const serverQueryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + let resolvePrefetch: undefined | ((value?: unknown) => void) + const prefetchPromise = new Promise((res) => { + resolvePrefetch = res + }) + void serverQueryClient.prefetchQuery({ + queryKey: key, + queryFn: () => prefetchPromise, + }) + const dehydrated = dehydrate(serverQueryClient) + + // Simulate a synchronous thenable – the promise was already resolved + // before we hydrate on the client + resolvePrefetch?.('server data') + // @ts-expect-error + dehydrated.queries[0].promise.then = (cb) => { + cb?.('server data') + // @ts-expect-error + return dehydrated.queries[0].promise + } + + // --- client --- + // Pre-populate with old data (updatedAt: 0 ensures dehydratedAt is newer) + const clientQueryClient = new QueryClient() + clientQueryClient.setQueryData(key, 'old data', { updatedAt: 0 }) + + const states: Array<{ status: string; fetchStatus: string }> = [] + const unsubscribe = clientQueryClient.getQueryCache().subscribe((event) => { + if (event.type === 'updated') { + const { status, fetchStatus } = event.query.state + states.push({ status, fetchStatus }) + } + }) + + hydrate(clientQueryClient, dehydrated) + await vi.advanceTimersByTimeAsync(0) + unsubscribe() + + expect(states).not.toContainEqual( + expect.objectContaining({ fetchStatus: 'fetching' }), + ) + expect(states).not.toContainEqual( + expect.objectContaining({ status: 'pending' }), + ) + + clientQueryClient.clear() + serverQueryClient.clear() + }) }) diff --git a/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx b/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx index db96ea17da6..1a4bf1bee30 100644 --- a/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx @@ -1,6 +1,7 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { CancelledError, InfiniteQueryObserver, QueryClient } from '..' +import { infiniteQueryBehavior } from '../infiniteQueryBehavior' import type { InfiniteData, InfiniteQueryObserverResult, QueryCache } from '..' describe('InfiniteQueryBehavior', () => { @@ -19,7 +20,7 @@ describe('InfiniteQueryBehavior', () => { vi.useRealTimers() }) - test('should throw an error if the queryFn is not defined', async () => { + it('should throw an error if the queryFn is not defined', async () => { const key = queryKey() const observer = new InfiniteQueryObserver(queryClient, { @@ -47,7 +48,7 @@ describe('InfiniteQueryBehavior', () => { unsubscribe() }) - test('should apply the maxPages option to limit the number of pages', async () => { + it('should apply the maxPages option to limit the number of pages', async () => { const key = queryKey() let abortSignal: AbortSignal | null = null @@ -195,7 +196,7 @@ describe('InfiniteQueryBehavior', () => { unsubscribe() }) - test('should support query cancellation', async () => { + it('should support query cancellation', async () => { const key = queryKey() let abortSignal: AbortSignal | null = null @@ -247,7 +248,7 @@ describe('InfiniteQueryBehavior', () => { unsubscribe() }) - test('should not refetch pages if the query is cancelled', async () => { + it('should not refetch pages if the query is cancelled', async () => { const key = queryKey() let abortSignal: AbortSignal | null = null @@ -332,7 +333,47 @@ describe('InfiniteQueryBehavior', () => { unsubscribe() }) - test('should not enter an infinite loop when a page errors while retry is on #8046', async () => { + it('should surface the abort reason when cancellation happens between refetched pages', async () => { + const key = queryKey() + const abortController = new AbortController() + const queryFn = vi.fn().mockImplementation(({ pageParam, signal }) => { + void signal.aborted + + if (pageParam === 1) { + abortController.abort() + } + + return pageParam + }) + + const behavior = infiniteQueryBehavior() + const context = { + client: queryClient, + queryKey: key, + fetchOptions: undefined, + options: { + queryKey: key, + queryFn, + initialPageParam: 1, + getNextPageParam: (lastPage: number) => lastPage + 1, + }, + state: { + data: { + pages: [1, 2], + pageParams: [1, 2], + }, + }, + fetchFn: () => Promise.resolve(), + signal: abortController.signal, + } + + behavior.onFetch(context as any, {} as any) + + await expect(context.fetchFn()).rejects.toBe(abortController.signal.reason) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not enter an infinite loop when a page errors while retry is on #8046', async () => { let errorCount = 0 const key = queryKey() @@ -406,7 +447,7 @@ describe('InfiniteQueryBehavior', () => { expect(reFetchedData.data?.pageParams).toEqual([1, 2, 3]) }) - test('should fetch even if initialPageParam is null', async () => { + it('should fetch even if initialPageParam is null', async () => { const key = queryKey() const observer = new InfiniteQueryObserver(queryClient, { @@ -433,7 +474,7 @@ describe('InfiniteQueryBehavior', () => { unsubscribe() }) - test('should not fetch next page when getNextPageParam returns null', async () => { + it('should not fetch next page when getNextPageParam returns null', async () => { const key = queryKey() const observer = new InfiniteQueryObserver(queryClient, { @@ -467,7 +508,7 @@ describe('InfiniteQueryBehavior', () => { unsubscribe() }) - test('should use persister when provided', async () => { + it('should use persister when provided', async () => { const key = queryKey() const persisterSpy = vi.fn().mockImplementation(async (fn) => { diff --git a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx index 1a72d81c987..99c00e37d22 100644 --- a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { InfiniteQueryObserver, QueryClient } from '..' import type { @@ -20,7 +20,7 @@ describe('InfiniteQueryObserver', () => { vi.useRealTimers() }) - test('should be able to fetch an infinite query with selector', async () => { + it('should be able to fetch an infinite query with selector', async () => { const key = queryKey() const observer = new InfiniteQueryObserver(queryClient, { queryKey: key, @@ -43,7 +43,7 @@ describe('InfiniteQueryObserver', () => { }) }) - test('should pass the meta option to the queryFn', async () => { + it('should pass the meta option to the queryFn', async () => { const meta = { it: 'works', } @@ -73,7 +73,7 @@ describe('InfiniteQueryObserver', () => { expect(queryFn).toBeCalledWith(expect.objectContaining({ meta })) }) - test('should make getNextPageParam and getPreviousPageParam receive current pageParams', async () => { + it('should make getNextPageParam and getPreviousPageParam receive current pageParams', async () => { const key = queryKey() let single: Array = [] let all: Array = [] @@ -112,7 +112,7 @@ describe('InfiniteQueryObserver', () => { expect(all).toEqual(['next0', 'next0,1', 'prev0,1']) }) - test('should not invoke getNextPageParam and getPreviousPageParam on empty pages', () => { + it('should not invoke getNextPageParam and getPreviousPageParam on empty pages', () => { const key = queryKey() const getNextPageParam = vi.fn() @@ -143,7 +143,7 @@ describe('InfiniteQueryObserver', () => { unsubscribe() }) - test('should stop refetching if undefined is returned from getNextPageParam', async () => { + it('should stop refetching if undefined is returned from getNextPageParam', async () => { const key = queryKey() let next: number | undefined = 2 const queryFn = vi.fn<(...args: Array) => any>(({ pageParam }) => @@ -175,7 +175,7 @@ describe('InfiniteQueryObserver', () => { expect(observer.getCurrentResult().hasNextPage).toBe(false) }) - test('should stop refetching if null is returned from getNextPageParam', async () => { + it('should stop refetching if null is returned from getNextPageParam', async () => { const key = queryKey() let next: number | null = 2 const queryFn = vi.fn<(...args: Array) => any>(({ pageParam }) => @@ -207,7 +207,7 @@ describe('InfiniteQueryObserver', () => { expect(observer.getCurrentResult().hasNextPage).toBe(false) }) - test('should set infinite query behavior via getOptimisticResult and return the initial state', () => { + it('should set infinite query behavior via getOptimisticResult and return the initial state', () => { const key = queryKey() const observer = new InfiniteQueryObserver(queryClient, { queryKey: key, @@ -235,8 +235,7 @@ describe('InfiniteQueryObserver', () => { const result = observer.getOptimisticResult(options) - expect(options.behavior).toBeDefined() - expect(options.behavior?.onFetch).toBeDefined() + expect(options._type).toBe('infinite') expect(result).toMatchObject({ data: undefined, diff --git a/packages/query-core/src/__tests__/mutationCache.test.tsx b/packages/query-core/src/__tests__/mutationCache.test.tsx index d2c16987bcc..05d4e581dfa 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { MutationCache, MutationObserver, QueryClient } from '..' import { executeMutation } from './utils' @@ -13,7 +13,7 @@ describe('mutationCache', () => { }) describe('MutationCacheConfig error callbacks', () => { - test('should call onError and onSettled when a mutation errors', async () => { + it('should call onError and onSettled when a mutation errors', async () => { const key = queryKey() const onError = vi.fn() const onSuccess = vi.fn() @@ -63,7 +63,7 @@ describe('mutationCache', () => { ) }) - test('should be awaited', async () => { + it('should be awaited', async () => { const key = queryKey() const states: Array = [] const onError = () => @@ -106,7 +106,7 @@ describe('mutationCache', () => { }) describe('MutationCacheConfig success callbacks', () => { - test('should call onSuccess and onSettled when a mutation is successful', async () => { + it('should call onSuccess and onSettled when a mutation is successful', async () => { const key = queryKey() const onError = vi.fn() const onSuccess = vi.fn() @@ -155,7 +155,7 @@ describe('mutationCache', () => { ) }) - test('should be awaited', async () => { + it('should be awaited', async () => { const key = queryKey() const states: Array = [] const onSuccess = () => @@ -196,7 +196,7 @@ describe('mutationCache', () => { }) describe('MutationCacheConfig.onMutate', () => { - test('should be called before a mutation executes', () => { + it('should be called before a mutation executes', () => { const key = queryKey() const onMutate = vi.fn() const testCache = new MutationCache({ onMutate }) @@ -221,7 +221,7 @@ describe('mutationCache', () => { }) }) - test('should be awaited', async () => { + it('should be awaited', async () => { const key = queryKey() const states: Array = [] const onMutate = () => @@ -250,7 +250,7 @@ describe('mutationCache', () => { expect(states).toEqual([1, 2, 3, 4]) }) - test('options.onMutate should run synchronously when mutationCache.config.onMutate is not defined', () => { + it('options.onMutate should run synchronously when mutationCache.config.onMutate is not defined', () => { const key = queryKey() const states: Array = [] @@ -276,7 +276,7 @@ describe('mutationCache', () => { }) describe('find', () => { - test('should filter correctly', () => { + it('should filter correctly', () => { const testCache = new MutationCache() const testClient = new QueryClient({ mutationCache: testCache }) const key = ['mutation', 'vars'] @@ -306,7 +306,7 @@ describe('mutationCache', () => { }) describe('findAll', () => { - test('should filter correctly', () => { + it('should filter correctly', () => { const testCache = new MutationCache() const testClient = new QueryClient({ mutationCache: testCache }) @@ -351,7 +351,7 @@ describe('mutationCache', () => { }) describe('garbage collection', () => { - test('should remove unused mutations after gcTime has elapsed', async () => { + it('should remove unused mutations after gcTime has elapsed', async () => { const testCache = new MutationCache() const testClient = new QueryClient({ mutationCache: testCache }) const onSuccess = vi.fn() @@ -375,7 +375,7 @@ describe('mutationCache', () => { expect(onSuccess).toHaveBeenCalledTimes(1) }) - test('should not remove mutations if there are active observers', async () => { + it('should not remove mutations if there are active observers', async () => { const queryClient = new QueryClient() const observer = new MutationObserver(queryClient, { gcTime: 10, @@ -400,7 +400,7 @@ describe('mutationCache', () => { expect(queryClient.getMutationCache().getAll()).toHaveLength(0) }) - test('should be garbage collected later when unsubscribed and mutation is pending', async () => { + it('should be garbage collected later when unsubscribed and mutation is pending', async () => { const queryClient = new QueryClient() const onSuccess = vi.fn() const observer = new MutationObserver(queryClient, { @@ -426,7 +426,7 @@ describe('mutationCache', () => { expect(onSuccess).toHaveBeenCalledTimes(1) }) - test('should call callbacks even with gcTime 0 and mutation still pending', async () => { + it('should call callbacks even with gcTime 0 and mutation still pending', async () => { const queryClient = new QueryClient() const onSuccess = vi.fn() const observer = new MutationObserver(queryClient, { @@ -447,7 +447,7 @@ describe('mutationCache', () => { }) describe('remove', () => { - test('should remove only the target mutation from scope when multiple scoped mutations exist', () => { + it('should remove only the target mutation from scope when multiple scoped mutations exist', () => { const testCache = new MutationCache() const testClient = new QueryClient({ mutationCache: testCache }) @@ -468,7 +468,7 @@ describe('mutationCache', () => { expect(testCache.getAll()).toEqual([mutation2]) }) - test('should delete scope when removing the only mutation in that scope', () => { + it('should delete scope when removing the only mutation in that scope', () => { const testCache = new MutationCache() const testClient = new QueryClient({ mutationCache: testCache }) @@ -483,5 +483,31 @@ describe('mutationCache', () => { expect(testCache.getAll()).toHaveLength(0) }) + + it('should still notify removal when removing a mutation that does not exist in the cache', () => { + const testCache = new MutationCache() + const testClient = new QueryClient({ mutationCache: testCache }) + + const mutation = testCache.build(testClient, { + mutationFn: () => Promise.resolve('data'), + }) + + expect(testCache.getAll()).toHaveLength(1) + testCache.remove(mutation) + expect(testCache.getAll()).toHaveLength(0) + + // Remove again — mutation is already gone from the cache + const callback = vi.fn() + const unsubscribe = testCache.subscribe(callback) + testCache.remove(mutation) + + expect(testCache.getAll()).toHaveLength(0) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ type: 'removed', mutation }), + ) + + unsubscribe() + }) }) }) diff --git a/packages/query-core/src/__tests__/mutationObserver.test.tsx b/packages/query-core/src/__tests__/mutationObserver.test.tsx index cc196f024ab..59cb4ebce81 100644 --- a/packages/query-core/src/__tests__/mutationObserver.test.tsx +++ b/packages/query-core/src/__tests__/mutationObserver.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { MutationObserver, QueryClient } from '..' @@ -16,7 +16,7 @@ describe('mutationObserver', () => { vi.useRealTimers() }) - test('onUnsubscribe should not remove the current mutation observer if there is still a subscription', async () => { + it('onUnsubscribe should not remove the current mutation observer if there is still a subscription', async () => { const mutation = new MutationObserver(queryClient, { mutationFn: (text: string) => sleep(20).then(() => text), }) @@ -41,7 +41,7 @@ describe('mutationObserver', () => { unsubscribe2() }) - test('unsubscribe should remove observer to trigger GC', async () => { + it('unsubscribe should remove observer to trigger GC', async () => { const mutation = new MutationObserver(queryClient, { mutationFn: (text: string) => sleep(5).then(() => text), gcTime: 10, @@ -62,7 +62,7 @@ describe('mutationObserver', () => { expect(queryClient.getMutationCache().findAll()).toHaveLength(0) }) - test('reset should remove observer to trigger GC', async () => { + it('reset should remove observer to trigger GC', async () => { const mutation = new MutationObserver(queryClient, { mutationFn: (text: string) => sleep(5).then(() => text), gcTime: 10, @@ -85,7 +85,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('changing mutation keys should reset the observer', async () => { + it('changing mutation keys should reset the observer', async () => { const key = queryKey() const mutation = new MutationObserver(queryClient, { mutationKey: [...key, '1'], @@ -115,7 +115,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('changing mutation keys should not affect already existing mutations', async () => { + it('changing mutation keys should not affect already existing mutations', async () => { const key = queryKey() const mutationObserver = new MutationObserver(queryClient, { mutationKey: [...key, '1'], @@ -156,7 +156,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('changing mutation meta should not affect successful mutations', async () => { + it('changing mutation meta should not affect successful mutations', async () => { const mutationObserver = new MutationObserver(queryClient, { meta: { a: 1 }, mutationFn: (text: string) => sleep(5).then(() => text), @@ -192,7 +192,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('mutation cache should have different meta when updated between mutations', async () => { + it('mutation cache should have different meta when updated between mutations', async () => { const mutationFn = (text: string) => sleep(5).then(() => text) const mutationObserver = new MutationObserver(queryClient, { meta: { a: 1 }, @@ -233,7 +233,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('changing mutation meta should not affect rejected mutations', async () => { + it('changing mutation meta should not affect rejected mutations', async () => { const mutationObserver = new MutationObserver(queryClient, { meta: { a: 1 }, mutationFn: (_: string) => @@ -268,7 +268,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('changing mutation meta should affect pending mutations', async () => { + it('changing mutation meta should affect pending mutations', async () => { const mutationObserver = new MutationObserver(queryClient, { meta: { a: 1 }, mutationFn: (text: string) => sleep(20).then(() => text), @@ -301,7 +301,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('mutation callbacks should be called in correct order with correct arguments for success case', async () => { + it('mutation callbacks should be called in correct order with correct arguments for success case', async () => { const onSuccess = vi.fn() const onSettled = vi.fn() @@ -341,7 +341,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('mutation callbacks should be called in correct order with correct arguments for error case', async () => { + it('mutation callbacks should be called in correct order with correct arguments for error case', async () => { const onError = vi.fn() const onSettled = vi.fn() @@ -385,7 +385,7 @@ describe('mutationObserver', () => { }) describe('erroneous mutation callback', () => { - test('onSuccess and onSettled is transferred to different execution context where it is reported', async ({ + it('onSuccess and onSettled is transferred to different execution context where it is reported', async ({ onTestFinished, }) => { const unhandledRejectionFn = vi.fn() @@ -429,7 +429,7 @@ describe('mutationObserver', () => { unsubscribe() }) - test('onError and onSettled is transferred to different execution context where it is reported', async ({ + it('onError and onSettled is transferred to different execution context where it is reported', async ({ onTestFinished, }) => { const unhandledRejectionFn = vi.fn() @@ -477,7 +477,7 @@ describe('mutationObserver', () => { }) }) - test('should not notify cache when setOptions is called with same options', () => { + it('should not notify cache when setOptions is called with same options', () => { const mutationObserver = new MutationObserver(queryClient, { mutationFn: (text: string) => Promise.resolve(text), }) diff --git a/packages/query-core/src/__tests__/mutations.test.tsx b/packages/query-core/src/__tests__/mutations.test.tsx index cc36cd3b2d3..506f404fbf2 100644 --- a/packages/query-core/src/__tests__/mutations.test.tsx +++ b/packages/query-core/src/__tests__/mutations.test.tsx @@ -1,5 +1,5 @@ import { queryKey, sleep } from '@tanstack/query-test-utils' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MutationCache, QueryClient } from '..' import { MutationObserver } from '../mutationObserver' import { executeMutation } from './utils' @@ -19,7 +19,7 @@ describe('mutations', () => { vi.useRealTimers() }) - test('mutate should accept null values', async () => { + it('mutate should accept null values', async () => { let variables const mutation = new MutationObserver(queryClient, { @@ -35,7 +35,7 @@ describe('mutations', () => { expect(variables).toBe(null) }) - test('setMutationDefaults should be able to set defaults', async () => { + it('setMutationDefaults should be able to set defaults', async () => { const key = queryKey() const fn = vi.fn() @@ -60,7 +60,7 @@ describe('mutations', () => { }) }) - test('mutation should set correct success states', async () => { + it('mutation should set correct success states', async () => { const mutation = new MutationObserver(queryClient, { mutationFn: (text: string) => sleep(10).then(() => text), onMutate: (text) => text, @@ -153,7 +153,7 @@ describe('mutations', () => { }) }) - test('mutation should set correct error states', async () => { + it('mutation should set correct error states', async () => { const mutation = new MutationObserver(queryClient, { mutationFn: (_: string) => sleep(20).then(() => Promise.reject(new Error('err'))), @@ -251,7 +251,7 @@ describe('mutations', () => { }) }) - test('should be able to restore a mutation', async () => { + it('should be able to restore a mutation', async () => { const key = queryKey() const onMutate = vi.fn() @@ -331,7 +331,7 @@ describe('mutations', () => { expect(onSettled).toHaveBeenCalled() }) - test('addObserver should not add an existing observer', () => { + it('addObserver should not add an existing observer', () => { const mutationCache = queryClient.getMutationCache() const observer = new MutationObserver(queryClient, {}) const currentMutation = mutationCache.build(queryClient, {}) @@ -351,7 +351,7 @@ describe('mutations', () => { unsubscribe() }) - test('mutate should throw an error if no mutationFn found', async () => { + it('mutate should throw an error if no mutationFn found', async () => { const mutation = new MutationObserver(queryClient, { mutationFn: undefined, retry: false, @@ -366,7 +366,7 @@ describe('mutations', () => { expect(error).toEqual(new Error('No mutationFn found')) }) - test('mutate update the mutation state even without an active subscription 1', async () => { + it('mutate update the mutation state even without an active subscription 1', async () => { const onSuccess = vi.fn() const onSettled = vi.fn() @@ -384,7 +384,7 @@ describe('mutations', () => { expect(onSettled).not.toHaveBeenCalled() }) - test('mutate update the mutation state even without an active subscription 2', async () => { + it('mutate update the mutation state even without an active subscription 2', async () => { const onSuccess = vi.fn() const onSettled = vi.fn() @@ -402,14 +402,11 @@ describe('mutations', () => { expect(onSettled).not.toHaveBeenCalled() }) - test('mutation callbacks should see updated options', async () => { + it('mutation callbacks should see updated options', async () => { const onSuccess = vi.fn() const mutation = new MutationObserver(queryClient, { - mutationFn: async () => { - await sleep(100) - return Promise.resolve('update') - }, + mutationFn: () => sleep(100).then(() => Promise.resolve('update')), onSuccess: () => { onSuccess(1) }, @@ -418,10 +415,7 @@ describe('mutations', () => { void mutation.mutate() mutation.setOptions({ - mutationFn: async () => { - await sleep(100) - return Promise.resolve('update') - }, + mutationFn: () => sleep(100).then(() => Promise.resolve('update')), onSuccess: () => { onSuccess(2) }, @@ -434,7 +428,7 @@ describe('mutations', () => { }) describe('scoped mutations', () => { - test('mutations in the same scope should run in serial', async () => { + it('mutations in the same scope should run in serial', async () => { const key1 = queryKey() const key2 = queryKey() @@ -499,7 +493,7 @@ describe('mutations', () => { }) }) - test('mutations without scope should run in parallel', async () => { + it('mutations without scope should run in parallel', async () => { const key1 = queryKey() const key2 = queryKey() @@ -543,7 +537,7 @@ describe('mutations', () => { ]) }) - test('each scope should run in parallel, serial within scope', async () => { + it('each scope should run in parallel, serial within scope', async () => { const results: Array = [] executeMutation( @@ -625,7 +619,7 @@ describe('mutations', () => { }) describe('callback return types', () => { - test('should handle all sync callback patterns', async () => { + it('should handle all sync callback patterns', async () => { const key = queryKey() const results: Array = [] @@ -663,7 +657,7 @@ describe('mutations', () => { ]) }) - test('should handle all async callback patterns', async () => { + it('should handle all async callback patterns', async () => { const key = queryKey() const results: Array = [] @@ -701,7 +695,7 @@ describe('mutations', () => { ]) }) - test('should handle Promise.all() and Promise.allSettled() patterns', async () => { + it('should handle Promise.all() and Promise.allSettled() patterns', async () => { const key = queryKey() const results: Array = [] @@ -742,7 +736,7 @@ describe('mutations', () => { ]) }) - test('should handle mixed sync/async patterns and return value isolation', async () => { + it('should handle mixed sync/async patterns and return value isolation', async () => { const key = queryKey() const results: Array = [] @@ -789,7 +783,7 @@ describe('mutations', () => { ]) }) - test('should handle error cases with all callback patterns', async () => { + it('should handle error cases with all callback patterns', async () => { const key = queryKey() const results: Array = [] @@ -844,7 +838,7 @@ describe('mutations', () => { }) describe('erroneous mutation callback', () => { - test('error by global onSuccess triggers onError callback', async () => { + it('error by global onSuccess triggers onError callback', async () => { const newMutationError = new Error('mutation-error') queryClient = new QueryClient({ @@ -902,7 +896,7 @@ describe('mutations', () => { expect(mutationError).toEqual(newMutationError) }) - test('error by mutations onSuccess triggers onError callback', async () => { + it('error by mutations onSuccess triggers onError callback', async () => { const key = queryKey() const results: Array = [] @@ -952,7 +946,7 @@ describe('mutations', () => { expect(mutationError).toEqual(newMutationError) }) - test('error by global onSettled triggers onError callback, calling global onSettled callback twice', async ({ + it('error by global onSettled triggers onError callback, calling global onSettled callback twice', async ({ onTestFinished, }) => { const newMutationError = new Error('mutation-error') @@ -1026,7 +1020,7 @@ describe('mutations', () => { expect(mutationError).toEqual(newMutationError) }) - test('error by mutations onSettled triggers onError callback, calling both onSettled callbacks twice', async ({ + it('error by mutations onSettled triggers onError callback, calling both onSettled callbacks twice', async ({ onTestFinished, }) => { const unhandledRejectionFn = vi.fn() @@ -1090,7 +1084,7 @@ describe('mutations', () => { expect(mutationError).toEqual(newMutationError) }) - test('errors by onError and consecutive onSettled callbacks are transferred to different execution context where it are reported', async ({ + it('errors by onError and consecutive onSettled callbacks are transferred to different execution context where it are reported', async ({ onTestFinished, }) => { const unhandledRejectionFn = vi.fn() @@ -1172,7 +1166,7 @@ describe('mutations', () => { }) }) - test('should not remove mutation when one observer is removed but another still exists', async () => { + it('should not remove mutation when one observer is removed but another still exists', async () => { const observer1 = new MutationObserver(queryClient, { gcTime: 10, mutationFn: () => sleep(10).then(() => 'data'), diff --git a/packages/query-core/src/__tests__/onlineManager.test.tsx b/packages/query-core/src/__tests__/onlineManager.test.tsx index 79fbb697ee6..d9219e01970 100644 --- a/packages/query-core/src/__tests__/onlineManager.test.tsx +++ b/packages/query-core/src/__tests__/onlineManager.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OnlineManager } from '../onlineManager' describe('onlineManager', () => { @@ -13,7 +13,7 @@ describe('onlineManager', () => { vi.useRealTimers() }) - test('isOnline should return true if navigator is undefined', () => { + it('isOnline should return true if navigator is undefined', () => { const navigatorSpy = vi.spyOn(globalThis, 'navigator', 'get') // Force navigator to be undefined @@ -24,7 +24,7 @@ describe('onlineManager', () => { navigatorSpy.mockRestore() }) - test('isOnline should return true if navigator.onLine is true', () => { + it('isOnline should return true if navigator.onLine is true', () => { const navigatorSpy = vi.spyOn(navigator, 'onLine', 'get') navigatorSpy.mockImplementation(() => true) @@ -33,7 +33,7 @@ describe('onlineManager', () => { navigatorSpy.mockRestore() }) - test('setEventListener should use online boolean arg', () => { + it('setEventListener should use online boolean arg', () => { let count = 0 const setup = (setOnline: (online: boolean) => void) => { @@ -51,7 +51,7 @@ describe('onlineManager', () => { expect(onlineManager.isOnline()).toBeFalsy() }) - test('setEventListener should call previous remove handler when replacing an event listener', () => { + it('setEventListener should call previous remove handler when replacing an event listener', () => { const remove1Spy = vi.fn() const remove2Spy = vi.fn() @@ -62,7 +62,7 @@ describe('onlineManager', () => { expect(remove2Spy).not.toHaveBeenCalled() }) - test('cleanup (removeEventListener) should not be called if window is not defined', () => { + it('cleanup (removeEventListener) should not be called if window is not defined', () => { const windowSpy = vi.spyOn(globalThis, 'window', 'get') windowSpy.mockImplementation( () => undefined as unknown as Window & typeof globalThis, @@ -79,7 +79,7 @@ describe('onlineManager', () => { windowSpy.mockRestore() }) - test('cleanup (removeEventListener) should not be called if window.addEventListener is not defined', () => { + it('cleanup (removeEventListener) should not be called if window.addEventListener is not defined', () => { const { addEventListener } = globalThis.window // @ts-expect-error @@ -96,7 +96,7 @@ describe('onlineManager', () => { globalThis.window.addEventListener = addEventListener }) - test('it should replace default window listener when a new event listener is set', () => { + it('should replace default window listener when a new event listener is set', () => { const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener') const removeEventListenerSpy = vi.spyOn( @@ -121,7 +121,7 @@ describe('onlineManager', () => { removeEventListenerSpy.mockRestore() }) - test('should call removeEventListener when last listener unsubscribes', () => { + it('should call removeEventListener when last listener unsubscribes', () => { const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener') const removeEventListenerSpy = vi.spyOn( @@ -139,7 +139,7 @@ describe('onlineManager', () => { expect(removeEventListenerSpy).toHaveBeenCalledTimes(2) // online + offline }) - test('should keep setup function even if last listener unsubscribes', () => { + it('should keep setup function even if last listener unsubscribes', () => { const setupSpy = vi.fn().mockImplementation(() => () => undefined) onlineManager.setEventListener(setupSpy) @@ -157,7 +157,21 @@ describe('onlineManager', () => { unsubscribe2() }) - test('should call listeners when setOnline is called', () => { + it('should update online status from window online and offline events', () => { + const unsubscribe = onlineManager.subscribe(() => undefined) + + expect(onlineManager.isOnline()).toBe(true) + + window.dispatchEvent(new Event('offline')) + expect(onlineManager.isOnline()).toBe(false) + + window.dispatchEvent(new Event('online')) + expect(onlineManager.isOnline()).toBe(true) + + unsubscribe() + }) + + it('should call listeners when setOnline is called', () => { const listener = vi.fn() onlineManager.subscribe(listener) diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index 88b763f712c..1adfa6195a6 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueriesObserver, QueryClient, QueryObserver } from '..' import type { QueryObserverResult } from '..' @@ -17,7 +17,7 @@ describe('queriesObserver', () => { vi.useRealTimers() }) - test('should return an array with all query results', async () => { + it('should return an array with all query results', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -38,7 +38,7 @@ describe('queriesObserver', () => { expect(observerResult).toMatchObject([{ data: 1 }, { data: 2 }]) }) - test('should return current queries via getQueries', async () => { + it('should return current queries via getQueries', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -60,7 +60,7 @@ describe('queriesObserver', () => { unsubscribe() }) - test('should update when a query updates', async () => { + it('should update when a query updates', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -106,7 +106,7 @@ describe('queriesObserver', () => { ]) }) - test('should return current observers via getObservers', async () => { + it('should return current observers via getObservers', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -128,7 +128,7 @@ describe('queriesObserver', () => { unsubscribe() }) - test('should update when a query is removed', async () => { + it('should update when a query is removed', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -177,7 +177,7 @@ describe('queriesObserver', () => { expect(results[5]).toMatchObject([{ status: 'success', data: 2 }]) }) - test('should update when a query changed position', async () => { + it('should update when a query changed position', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -227,7 +227,7 @@ describe('queriesObserver', () => { ]) }) - test('should not update when nothing has changed', async () => { + it('should not update when nothing has changed', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -273,7 +273,7 @@ describe('queriesObserver', () => { ]) }) - test('should trigger all fetches when subscribed', () => { + it('should trigger all fetches when subscribed', () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -291,15 +291,12 @@ describe('queriesObserver', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should not destroy the observer if there is still a subscription', async () => { + it('should not destroy the observer if there is still a subscription', async () => { const key1 = queryKey() const observer = new QueriesObserver(queryClient, [ { queryKey: key1, - queryFn: async () => { - await sleep(20) - return 1 - }, + queryFn: () => sleep(20).then(() => 1), }, ]) @@ -322,7 +319,7 @@ describe('queriesObserver', () => { unsubscribe2() }) - test('should handle duplicate query keys in different positions', async () => { + it('should handle duplicate query keys in different positions', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -392,7 +389,7 @@ describe('queriesObserver', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should notify when results change during early return', async () => { + it('should notify when results change during early return', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi.fn().mockReturnValue(1) @@ -439,7 +436,7 @@ describe('queriesObserver', () => { ]) }) - test('should update combined result when queries are added with stable combine reference', () => { + it('should update combined result when queries are added with stable combine reference', () => { const combine = vi.fn((results: Array) => ({ count: results.length, results, @@ -476,7 +473,83 @@ describe('queriesObserver', () => { expect(newCombined.count).toBe(2) }) - test('should handle queries being removed with stable combine reference', () => { + it('should skip combine notifications while suspense queries have no data', async () => { + const key = queryKey() + const combine = vi.fn((results: Array) => + results.map((result) => result.data), + ) + const query = { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + suspense: true, + } + + queryClient.setQueryData(key, 'data') + + const observer = new QueriesObserver>(queryClient, [query], { + combine, + }) + + const [rawResult, getCombinedResult] = observer.getOptimisticResult( + [query], + combine, + ) + expect(getCombinedResult(rawResult)).toEqual(['data']) + expect(combine).toHaveBeenCalledTimes(1) + + const unsubscribe = observer.subscribe(() => undefined) + + void queryClient.resetQueries({ queryKey: key }) + expect(combine).toHaveBeenCalledTimes(1) + + unsubscribe() + }) + + it('should skip combine notifications after suspense is enabled without structural changes', async () => { + const key = queryKey() + const combine = vi.fn((results: Array) => + results.map((result) => result.data), + ) + const query = { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + suspense: false, + } + + queryClient.setQueryData(key, 'data') + + const observer = new QueriesObserver>(queryClient, [query], { + combine, + }) + + const [rawResult, getCombinedResult] = observer.getOptimisticResult( + [query], + combine, + ) + expect(getCombinedResult(rawResult)).toEqual(['data']) + expect(combine).toHaveBeenCalledTimes(1) + + const unsubscribe = observer.subscribe(() => undefined) + + observer.setQueries( + [ + { + ...query, + suspense: true, + }, + ], + { combine }, + ) + + void queryClient.resetQueries({ queryKey: key }) + expect(combine).toHaveBeenCalledTimes(1) + + unsubscribe() + }) + + it('should handle queries being removed with stable combine reference', () => { const combine = vi.fn((results: Array) => ({ count: results.length, results, @@ -520,7 +593,7 @@ describe('queriesObserver', () => { expect(newCombined.count).toBe(1) }) - test('should update combined result when queries are replaced with different ones (same length)', () => { + it('should update combined result when queries are replaced with different ones (same length)', () => { const combine = vi.fn((results: Array) => ({ keys: results.map((r) => r.status), results, @@ -555,7 +628,114 @@ describe('queriesObserver', () => { expect(newCombined.keys).toEqual(['pending']) }) - test('should track properties on all observers when trackResult is called', () => { + it('should recalculate combined result when combine function changes', () => { + const combine1 = vi.fn((results: Array) => ({ + total: results.length, + })) + const combine2 = vi.fn((results: Array) => ({ + total: results.length * 4, + })) + + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + const queryFn2 = vi.fn().mockReturnValue(2) + + const queries = [ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ] + + const observer = new QueriesObserver<{ total: number }>( + queryClient, + queries, + { combine: combine1 }, + ) + + const [raw1, getCombined1] = observer.getOptimisticResult(queries, combine1) + const combined1 = getCombined1(raw1) + + const [raw2, getCombined2] = observer.getOptimisticResult(queries, combine2) + const combined2 = getCombined2(raw2) + + expect(combined1.total).toBe(2) + expect(combined2.total).toBe(8) + }) + + it('should use fallback result when combineResult is called without raw argument', () => { + const combine = vi.fn((results: Array) => ({ + count: results.length, + })) + + const key = queryKey() + const queryFn = vi.fn().mockReturnValue(1) + + const observer = new QueriesObserver<{ count: number }>( + queryClient, + [{ queryKey: key, queryFn }], + { combine }, + ) + + const [, getCombined] = observer.getOptimisticResult( + [{ queryKey: key, queryFn }], + combine, + ) + const combined = getCombined() + + expect(combined.count).toBe(1) + }) + + it('should return observer result directly when notifyOnChangeProps is set', () => { + const key = queryKey() + const queryFn = vi.fn().mockReturnValue(1) + + const observer = new QueriesObserver(queryClient, [ + { queryKey: key, queryFn, notifyOnChangeProps: ['data'] }, + ]) + + const trackResultSpy = vi.spyOn(QueryObserver.prototype, 'trackResult') + + const [, , trackResult] = observer.getOptimisticResult( + [{ queryKey: key, queryFn, notifyOnChangeProps: ['data'] }], + undefined, + ) + + const trackedResults = trackResult() + + expect(trackedResults).toHaveLength(1) + // trackResult should NOT be called when notifyOnChangeProps is set + expect(trackResultSpy).not.toHaveBeenCalled() + + trackResultSpy.mockRestore() + }) + + it('should return cached combined result when nothing has changed', () => { + const combine = vi.fn((results: Array) => ({ + count: results.length, + })) + + const key = queryKey() + const queryFn = vi.fn().mockReturnValue(1) + + const queries = [{ queryKey: key, queryFn }] + + const observer = new QueriesObserver<{ count: number }>( + queryClient, + queries, + { combine }, + ) + + const [raw1, getCombined1] = observer.getOptimisticResult(queries, combine) + const combined1 = getCombined1(raw1) + + const [raw2, getCombined2] = observer.getOptimisticResult(queries, combine) + const combined2 = getCombined2(raw2) + + // Same combine, same queries → cached result returned + expect(combined1).toBe(combined2) + }) + + it('should track properties on all observers when trackResult is called', () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = () => 'data1' @@ -591,7 +771,7 @@ describe('queriesObserver', () => { trackPropSpy.mockRestore() }) - test('should subscribe to new observers when a query is added while subscribed', async () => { + it('should subscribe to new observers when a query is added while subscribed', async () => { const key1 = queryKey() const key2 = queryKey() const key3 = queryKey() diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index dfb8c0381a3..192abac6b8a 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockVisibilityState, queryKey, @@ -37,7 +37,7 @@ describe('query', () => { vi.useRealTimers() }) - test('should use the longest garbage collection time it has seen', async () => { + it('should use the longest garbage collection time it has seen', async () => { const key = queryKey() await queryClient.prefetchQuery({ queryKey: key, @@ -231,15 +231,12 @@ describe('query', () => { onlineMock.mockRestore() }) - test('should not throw a CancelledError when fetchQuery is in progress and the last observer unsubscribes when AbortSignal is consumed', async () => { + it('should not throw a CancelledError when fetchQuery is in progress and the last observer unsubscribes when AbortSignal is consumed', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, - queryFn: async () => { - await sleep(100) - return 'data' - }, + queryFn: () => sleep(100).then(() => 'data'), }) const unsubscribe = observer.subscribe(() => undefined) @@ -249,10 +246,7 @@ describe('query', () => { const promise = queryClient.fetchQuery({ queryKey: key, - queryFn: async ({ signal }) => { - await sleep(100) - return 'data2' + String(signal) - }, + queryFn: ({ signal }) => sleep(100).then(() => 'data2' + String(signal)), }) // Ensure the fetch is in progress @@ -270,7 +264,7 @@ describe('query', () => { expect(queryCache.find({ queryKey: key })?.state.data).toBe('data') }) - test('should provide context to queryFn', () => { + it('should provide context to queryFn', () => { const key = queryKey() const queryFn = vi @@ -292,7 +286,7 @@ describe('query', () => { expect(args.client).toEqual(queryClient) }) - test('should continue if cancellation is not supported and signal is not consumed', async () => { + it('should continue if cancellation is not supported and signal is not consumed', async () => { const key = queryKey() queryClient.prefetchQuery({ @@ -321,15 +315,13 @@ describe('query', () => { }) }) - test('should not continue when last observer unsubscribed if the signal was consumed', async () => { + it('should not continue when last observer unsubscribed if the signal was consumed', async () => { const key = queryKey() queryClient.prefetchQuery({ queryKey: key, - queryFn: async ({ signal }) => { - await sleep(100) - return signal.aborted ? 'aborted' : 'data' - }, + queryFn: ({ signal }) => + sleep(100).then(() => (signal.aborted ? 'aborted' : 'data')), }) await vi.advanceTimersByTimeAsync(10) @@ -353,7 +345,7 @@ describe('query', () => { }) }) - test('should provide an AbortSignal to the queryFn that provides info about the cancellation state', async () => { + it('should provide an AbortSignal to the queryFn that provides info about the cancellation state', async () => { const key = queryKey() const queryFn = @@ -405,7 +397,7 @@ describe('query', () => { expect(error).toBeInstanceOf(CancelledError) }) - test('should not continue if explicitly cancelled', async () => { + it('should not continue if explicitly cancelled', async () => { const key = queryKey() const queryFn = vi.fn<(...args: Array) => unknown>() @@ -437,7 +429,7 @@ describe('query', () => { expect(error).toBeInstanceOf(CancelledError) }) - test('should not error if reset while pending', async () => { + it('should not error if reset while pending', async () => { const key = queryKey() const queryFn = vi.fn<(...args: Array) => unknown>() @@ -481,10 +473,11 @@ describe('query', () => { expect(error).toBeInstanceOf(CancelledError) }) - test('should reset to default state when created from hydration', async () => { + it('should reset to default state when created from hydration', async () => { + const key = queryKey() const client = new QueryClient() await client.prefetchQuery({ - queryKey: ['string'], + queryKey: key, queryFn: () => Promise.resolve('string'), }) @@ -493,15 +486,15 @@ describe('query', () => { const hydrationClient = new QueryClient() hydrate(hydrationClient, dehydrated) - expect(hydrationClient.getQueryData(['string'])).toBe('string') + expect(hydrationClient.getQueryData(key)).toBe('string') - const query = hydrationClient.getQueryCache().find({ queryKey: ['string'] }) + const query = hydrationClient.getQueryCache().find({ queryKey: key }) query?.reset() - expect(hydrationClient.getQueryData(['string'])).toBe(undefined) + expect(hydrationClient.getQueryData(key)).toBe(undefined) }) - test('should be able to refetch a cancelled query', async () => { + it('should be able to refetch a cancelled query', async () => { const key = queryKey() const queryFn = vi.fn<(...args: Array) => unknown>() @@ -523,7 +516,7 @@ describe('query', () => { expect(queryFn).toHaveBeenCalledTimes(2) }) - test('cancelling a resolved query should not have any effect', async () => { + it('cancelling a resolved query should not have any effect', async () => { const key = queryKey() await queryClient.prefetchQuery({ queryKey: key, @@ -535,7 +528,7 @@ describe('query', () => { expect(query.state.data).toBe('data') }) - test('cancelling a rejected query should not have any effect', async () => { + it('cancelling a rejected query should not have any effect', async () => { const key = queryKey() const error = new Error('error') @@ -551,7 +544,7 @@ describe('query', () => { expect(query.state.error).not.toBeInstanceOf(CancelledError) }) - test('the previous query status should be kept when refetching', async () => { + it('the previous query status should be kept when refetching', async () => { const key = queryKey() await queryClient.prefetchQuery({ queryKey: key, queryFn: () => 'data' }) @@ -576,7 +569,7 @@ describe('query', () => { expect(query.state.status).toBe('error') }) - test('queries with gcTime 0 should be removed immediately after unsubscribing', async () => { + it('queries with gcTime 0 should be removed immediately after unsubscribing', async () => { const key = queryKey() let count = 0 const observer = new QueryObserver(queryClient, { @@ -601,7 +594,7 @@ describe('query', () => { expect(count).toBe(1) }) - test('should be garbage collected when unsubscribed to', async () => { + it('should be garbage collected when unsubscribed to', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -617,7 +610,7 @@ describe('query', () => { expect(queryCache.find({ queryKey: key })).toBeUndefined() }) - test('should be garbage collected later when unsubscribed and query is fetching', async () => { + it('should be garbage collected later when unsubscribed and query is fetching', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -636,7 +629,7 @@ describe('query', () => { expect(queryCache.find({ queryKey: key })).toBeUndefined() }) - test('should not be garbage collected unless there are no subscribers', async () => { + it('should not be garbage collected unless there are no subscribers', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -655,7 +648,7 @@ describe('query', () => { expect(queryCache.find({ queryKey: key })).toBeDefined() }) - test('should return proper count of observers', () => { + it('should return proper count of observers', () => { const key = queryKey() const options = { queryKey: key, queryFn: () => 'data' } const observer = new QueryObserver(queryClient, options) @@ -680,7 +673,7 @@ describe('query', () => { expect(query?.getObserversCount()).toEqual(0) }) - test('stores meta object in query', async () => { + it('stores meta object in query', async () => { const meta = { it: 'works', } @@ -699,7 +692,7 @@ describe('query', () => { expect(query.options.meta).toBe(meta) }) - test('updates meta object on change', async () => { + it('updates meta object on change', async () => { const meta = { it: 'works', } @@ -717,7 +710,7 @@ describe('query', () => { expect(query.options.meta).toBeUndefined() }) - test('can use default meta', async () => { + it('can use default meta', async () => { const meta = { it: 'works', } @@ -734,7 +727,7 @@ describe('query', () => { expect(query.meta).toBe(meta) }) - test('provides meta object inside query function', async () => { + it('provides meta object inside query function', async () => { const meta = { it: 'works', } @@ -752,7 +745,7 @@ describe('query', () => { ) }) - test('should refetch the observer when online method is called', () => { + it('should refetch the observer when online method is called', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -771,7 +764,7 @@ describe('query', () => { refetchSpy.mockRestore() }) - test('should not add an existing observer', async () => { + it('should not add an existing observer', async () => { const key = queryKey() await queryClient.prefetchQuery({ queryKey: key, queryFn: () => 'data' }) @@ -790,7 +783,7 @@ describe('query', () => { expect(query.getObserversCount()).toEqual(1) }) - test('should not try to remove an observer that does not exist', async () => { + it('should not try to remove an observer that does not exist', async () => { const key = queryKey() await queryClient.prefetchQuery({ queryKey: key, queryFn: () => 'data' }) @@ -807,7 +800,7 @@ describe('query', () => { notifySpy.mockRestore() }) - test('should not change state on invalidate() if already invalidated', async () => { + it('should not change state on invalidate() if already invalidated', async () => { const key = queryKey() await queryClient.prefetchQuery({ queryKey: key, queryFn: () => 'data' }) @@ -823,7 +816,7 @@ describe('query', () => { expect(query.state).toBe(previousState) }) - test('fetch should not dispatch "fetch" query is already fetching', async () => { + it('fetch should not dispatch "fetch" query is already fetching', async () => { const key = queryKey() const queryFn = () => sleep(10).then(() => 'data') @@ -856,7 +849,7 @@ describe('query', () => { unsubscribe() }) - test('fetch should throw an error if the queryFn is not defined', async () => { + it('fetch should throw an error if the queryFn is not defined', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -876,7 +869,7 @@ describe('query', () => { unsubscribe() }) - test('fetch should dispatch an error if the queryFn returns undefined', async () => { + it('fetch should dispatch an error if the queryFn returns undefined', async () => { const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) const key = queryKey() @@ -930,7 +923,7 @@ describe('query', () => { resetIsServer() }) - test('constructor should call initialDataUpdatedAt if defined as a function', async () => { + it('constructor should call initialDataUpdatedAt if defined as a function', async () => { const key = queryKey() const initialDataUpdatedAtSpy = vi.fn() @@ -945,7 +938,7 @@ describe('query', () => { expect(initialDataUpdatedAtSpy).toHaveBeenCalled() }) - test('should work with initialDataUpdatedAt set to zero', async () => { + it('should work with initialDataUpdatedAt set to zero', async () => { const key = queryKey() await queryClient.prefetchQuery({ @@ -963,7 +956,7 @@ describe('query', () => { }) }) - test('queries should be garbage collected even if they never fetched', async () => { + it('queries should be garbage collected even if they never fetched', async () => { const key = queryKey() queryClient.setQueryDefaults(key, { gcTime: 10 }) @@ -986,7 +979,7 @@ describe('query', () => { unsubscribe() }) - test('should always revert to idle state (#5968)', async () => { + it('should always revert to idle state (#5968)', async () => { let mockedData = [1] const key = queryKey() @@ -1050,24 +1043,22 @@ describe('query', () => { expect(spy).toHaveBeenCalledWith('1 - 2') }) - test('should not reject a promise when silently cancelled in the background', async () => { + it('should not reject a promise when silently cancelled in the background', async () => { const key = queryKey() let x = 0 queryClient.setQueryData(key, 'initial') - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(100) - return 'data' + x - }) + const queryFn = vi + .fn() + .mockImplementation(() => sleep(100).then(() => 'data' + x)) const promise = queryClient.fetchQuery({ queryKey: key, queryFn, }) - await vi.advanceTimersByTimeAsync(10) - + await vi.advanceTimersByTimeAsync(0) expect(queryFn).toHaveBeenCalledTimes(1) x = 1 @@ -1075,10 +1066,9 @@ describe('query', () => { // cancel ongoing re-fetches void queryClient.refetchQueries({ queryKey: key }, { cancelRefetch: true }) - await vi.advanceTimersByTimeAsync(10) - // The promise should not reject - await vi.waitFor(() => expect(promise).resolves.toBe('data1')) + await vi.advanceTimersByTimeAsync(100) + await expect(promise).resolves.toBe('data1') expect(queryFn).toHaveBeenCalledTimes(2) }) @@ -1151,7 +1141,7 @@ describe('query', () => { expect(query.state.status).toBe('error') }) - test('should use persister if provided', async () => { + it('should use persister if provided', async () => { const key = queryKey() await queryClient.prefetchQuery({ @@ -1164,7 +1154,7 @@ describe('query', () => { expect(query.state.data).toBe('persisted data') }) - test('should use queryFn from observer if not provided in options', async () => { + it('should use queryFn from observer if not provided in options', async () => { const key = queryKey() const queryFn = () => Promise.resolve('data') const observer = new QueryObserver(queryClient, { @@ -1186,7 +1176,7 @@ describe('query', () => { expect(query.options.queryFn).toBe(queryFn) }) - test('should log error when queryKey is not an array', async () => { + it('should log error when queryKey is not an array', async () => { const consoleMock = vi.spyOn(console, 'error') const key: unknown = 'string-key' @@ -1202,7 +1192,7 @@ describe('query', () => { consoleMock.mockRestore() }) - test('should call initialData function when it is a function', () => { + it('should call initialData function when it is a function', () => { const key = queryKey() const initialDataFn = vi.fn(() => 'initial data') @@ -1220,12 +1210,9 @@ describe('query', () => { expect(query.state.data).toBe('initial data') }) - test('should update initialData when Query exists without data', async () => { + it('should update initialData when Query exists without data', async () => { const key = queryKey() - const queryFn = vi.fn(async () => { - await sleep(100) - return 'data' - }) + const queryFn = vi.fn(() => sleep(100).then(() => 'data')) const promise = queryClient.prefetchQuery({ queryKey: key, @@ -1282,7 +1269,7 @@ describe('query', () => { }) }) - test('should not override fetching state when revert happens after new observer subscribes', async () => { + it('should not override fetching state when revert happens after new observer subscribes', async () => { const key = queryKey() let count = 0 @@ -1321,7 +1308,8 @@ describe('query', () => { query.fetch() await expect(promise1).rejects.toBeInstanceOf(CancelledError) - await vi.waitFor(() => expect(query.state.fetchStatus).toBe('idle')) + await vi.advanceTimersByTimeAsync(50) + expect(query.state.fetchStatus).toBe('idle') expect(queryFn).toHaveBeenCalledTimes(2) @@ -1332,7 +1320,7 @@ describe('query', () => { }) }) - test('should not increment dataUpdateCount when setting initialData on prefetched query', async () => { + it('should not increment dataUpdateCount when setting initialData on prefetched query', async () => { const key = queryKey() const queryFn = vi.fn().mockImplementation(() => 'fetched-data') diff --git a/packages/query-core/src/__tests__/queryCache.test.tsx b/packages/query-core/src/__tests__/queryCache.test.tsx index 821efa8061a..c404c1f942b 100644 --- a/packages/query-core/src/__tests__/queryCache.test.tsx +++ b/packages/query-core/src/__tests__/queryCache.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryCache, QueryClient, QueryObserver, hashKey } from '..' @@ -18,7 +18,7 @@ describe('queryCache', () => { }) describe('subscribe', () => { - test('should pass the correct query', () => { + it('should pass the correct query', () => { const key = queryKey() const subscriber = vi.fn() const unsubscribe = queryCache.subscribe(subscriber) @@ -28,7 +28,7 @@ describe('queryCache', () => { unsubscribe() }) - test('should notify listeners when new query is added', async () => { + it('should notify listeners when new query is added', async () => { const key = queryKey() const callback = vi.fn() queryCache.subscribe(callback) @@ -40,7 +40,7 @@ describe('queryCache', () => { expect(callback).toHaveBeenCalled() }) - test('should notify query cache when a query becomes stale', async () => { + it('should notify query cache when a query becomes stale', async () => { const key = queryKey() const events: Array = [] const queries: Array = [] @@ -79,7 +79,7 @@ describe('queryCache', () => { unsubScribeObserver() }) - test('should include the queryCache and query when notifying listeners', async () => { + it('should include the queryCache and query when notifying listeners', async () => { const key = queryKey() const callback = vi.fn() queryCache.subscribe(callback) @@ -92,7 +92,7 @@ describe('queryCache', () => { expect(callback).toHaveBeenCalledWith({ query, type: 'added' }) }) - test('should notify subscribers when new query with initialData is added', async () => { + it('should notify subscribers when new query with initialData is added', async () => { const key = queryKey() const callback = vi.fn() queryCache.subscribe(callback) @@ -105,7 +105,7 @@ describe('queryCache', () => { expect(callback).toHaveBeenCalled() }) - test('should be able to limit cache size', async () => { + it('should be able to limit cache size', async () => { const testCache = new QueryCache() const unsubscribe = testCache.subscribe((event) => { @@ -125,18 +125,21 @@ describe('queryCache', () => { const testClient = new QueryClient({ queryCache: testCache }) + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() testClient.prefetchQuery({ - queryKey: ['key1'], + queryKey: key1, queryFn: () => sleep(100).then(() => 'data1'), }) expect(testCache.findAll().length).toBe(1) testClient.prefetchQuery({ - queryKey: ['key2'], + queryKey: key2, queryFn: () => sleep(100).then(() => 'data2'), }) expect(testCache.findAll().length).toBe(2) testClient.prefetchQuery({ - queryKey: ['key3'], + queryKey: key3, queryFn: () => sleep(100).then(() => 'data3'), }) await vi.advanceTimersByTimeAsync(100) @@ -148,7 +151,7 @@ describe('queryCache', () => { }) describe('find', () => { - test('find should filter correctly', async () => { + it('find should filter correctly', async () => { const key = queryKey() queryClient.prefetchQuery({ queryKey: key, @@ -159,7 +162,7 @@ describe('queryCache', () => { expect(query).toBeDefined() }) - test('find should filter correctly with exact set to false', async () => { + it('find should filter correctly with exact set to false', async () => { const key = queryKey() queryClient.prefetchQuery({ queryKey: key, @@ -172,7 +175,7 @@ describe('queryCache', () => { }) describe('findAll', () => { - test('should filter correctly', async () => { + it('should filter correctly', async () => { const key1 = queryKey() const key2 = queryKey() const keyFetching = queryKey() @@ -293,7 +296,7 @@ describe('queryCache', () => { expect(queryCache.findAll({ fetchStatus: 'fetching' })).toEqual([]) }) - test('should return all the queries when no filters are defined', async () => { + it('should return all the queries when no filters are defined', async () => { const key1 = queryKey() const key2 = queryKey() await queryClient.prefetchQuery({ @@ -309,7 +312,7 @@ describe('queryCache', () => { }) describe('QueryCacheConfig error callbacks', () => { - test('should call onError and onSettled when a query errors', async () => { + it('should call onError and onSettled when a query errors', async () => { const key = queryKey() const onSuccess = vi.fn() const onSettled = vi.fn() @@ -331,7 +334,7 @@ describe('queryCache', () => { }) describe('QueryCacheConfig success callbacks', () => { - test('should call onSuccess and onSettled when a query is successful', async () => { + it('should call onSuccess and onSettled when a query is successful', async () => { const key = queryKey() const onSuccess = vi.fn() const onSettled = vi.fn() @@ -353,7 +356,7 @@ describe('queryCache', () => { }) describe('build', () => { - test('should compute queryHash from queryKey when queryHash is not provided', () => { + it('should compute queryHash from queryKey when queryHash is not provided', () => { const key = queryKey() const query = queryCache.build(queryClient, { @@ -363,7 +366,7 @@ describe('queryCache', () => { expect(query.queryHash).toBe(hashKey(key)) }) - test('should use provided queryHash instead of computing it', () => { + it('should use provided queryHash instead of computing it', () => { const key = queryKey() const customHash = 'custom-hash' @@ -377,8 +380,24 @@ describe('queryCache', () => { }) }) + describe('QueryCache.remove', () => { + it('should only delete the instance currently stored under its queryHash', () => { + const key = queryKey() + + const staleQuery = queryCache.build(queryClient, { queryKey: key }) + queryCache.remove(staleQuery) + + const currentQuery = queryCache.build(queryClient, { queryKey: key }) + expect(currentQuery).not.toBe(staleQuery) + + queryCache.remove(staleQuery) + + expect(queryCache.get(hashKey(key))).toBe(currentQuery) + }) + }) + describe('QueryCache.add', () => { - test('should not try to add a query already added to the cache', async () => { + it('should not try to add a query already added to the cache', async () => { const key = queryKey() queryClient.prefetchQuery({ diff --git a/packages/query-core/src/__tests__/queryClient.test-d.tsx b/packages/query-core/src/__tests__/queryClient.test-d.tsx index 8a3be1a9e23..4cd092ddd64 100644 --- a/packages/query-core/src/__tests__/queryClient.test-d.tsx +++ b/packages/query-core/src/__tests__/queryClient.test-d.tsx @@ -1,4 +1,5 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import type { MutationFilters, QueryFilters, Updater } from '../utils' import type { Mutation } from '../mutation' @@ -18,25 +19,25 @@ import type { describe('getQueryData', () => { it('should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() - const data = queryClient.getQueryData(queryKey) + const data = queryClient.getQueryData(key) expectTypeOf(data).toEqualTypeOf() }) it('should infer unknown if key is not tagged', () => { - const queryKey = ['key'] as const + const key = ['key'] as const const queryClient = new QueryClient() - const data = queryClient.getQueryData(queryKey) + const data = queryClient.getQueryData(key) expectTypeOf(data).toEqualTypeOf() }) it('should infer passed generic if passed', () => { - const queryKey = ['key'] as const + const key = ['key'] as const const queryClient = new QueryClient() - const data = queryClient.getQueryData(queryKey) + const data = queryClient.getQueryData(key) expectTypeOf(data).toEqualTypeOf() }) @@ -51,9 +52,9 @@ describe('getQueryData', () => { describe('setQueryData', () => { it('updater should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, (prev) => { + const data = queryClient.setQueryData(key, (prev) => { expectTypeOf(prev).toEqualTypeOf() return prev }) @@ -62,24 +63,24 @@ describe('setQueryData', () => { }) it('value should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() // @ts-expect-error value should be a number - queryClient.setQueryData(queryKey, '1') + queryClient.setQueryData(key, '1') // @ts-expect-error value should be a number - queryClient.setQueryData(queryKey, () => '1') + queryClient.setQueryData(key, () => '1') - const data = queryClient.setQueryData(queryKey, 1) + const data = queryClient.setQueryData(key, 1) expectTypeOf(data).toEqualTypeOf() }) it('should infer unknown for updater if key is not tagged', () => { - const queryKey = ['key'] as const + const key = ['key'] as const const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, (prev) => { + const data = queryClient.setQueryData(key, (prev) => { expectTypeOf(prev).toEqualTypeOf() return prev }) @@ -88,17 +89,17 @@ describe('setQueryData', () => { }) it('should infer unknown for value if key is not tagged', () => { - const queryKey = ['key'] as const + const key = ['key'] as const const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, 'foo') + const data = queryClient.setQueryData(key, 'foo') expectTypeOf(data).toEqualTypeOf() }) it('should infer passed generic if passed', () => { - const queryKey = ['key'] as const + const key = ['key'] as const const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, (prev) => { + const data = queryClient.setQueryData(key, (prev) => { expectTypeOf(prev).toEqualTypeOf() return prev }) @@ -107,21 +108,21 @@ describe('setQueryData', () => { }) it('should infer passed generic for value', () => { - const queryKey = ['key'] as const + const key = ['key'] as const const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, 'foo') + const data = queryClient.setQueryData(key, 'foo') expectTypeOf(data).toEqualTypeOf() }) it('should preserve updater parameter type inference when used in functions with explicit return types', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() // Simulate usage inside a function with explicit return type // The outer function returns 'unknown' but this shouldn't affect the updater's type inference ;(() => - queryClient.setQueryData(queryKey, (data) => { + queryClient.setQueryData(key, (data) => { expectTypeOf(data).toEqualTypeOf() return data })) satisfies () => unknown @@ -130,26 +131,26 @@ describe('setQueryData', () => { describe('getQueryState', () => { it('should be loose typed without tag', () => { - const queryKey = ['key'] as const + const key = ['key'] as const const queryClient = new QueryClient() - const data = queryClient.getQueryState(queryKey) + const data = queryClient.getQueryState(key) expectTypeOf(data).toEqualTypeOf | undefined>() }) it('should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() - const data = queryClient.getQueryState(queryKey) + const data = queryClient.getQueryState(key) expectTypeOf(data).toEqualTypeOf | undefined>() }) it('should be typed including error if key is tagged', () => { type CustomError = Error & { customError: string } - const queryKey = ['key'] as DataTag, number, CustomError> + const key = ['key'] as DataTag, number, CustomError> const queryClient = new QueryClient() - const data = queryClient.getQueryState(queryKey) + const data = queryClient.getQueryState(key) expectTypeOf(data).toEqualTypeOf< QueryState | undefined @@ -160,7 +161,7 @@ describe('getQueryState', () => { describe('fetchInfiniteQuery', () => { it('should allow passing pages', async () => { const data = await new QueryClient().fetchInfiniteQuery({ - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => Promise.resolve('string'), getNextPageParam: () => 1, initialPageParam: 1, @@ -252,7 +253,7 @@ describe('fully typed usage', () => { return false }, } - const queryKey = queryFilters.queryKey! + const filterKey = queryFilters.queryKey! const mutationFilters: MutationFilters = { predicate(mutation) { @@ -268,10 +269,10 @@ describe('fully typed usage', () => { // Method type tests // - const state = queryClient.getQueryState(queryKey) + const state = queryClient.getQueryState(filterKey) expectTypeOf(state).toEqualTypeOf | undefined>() - const queryData1 = queryClient.getQueryData(queryKey) + const queryData1 = queryClient.getQueryData(filterKey) expectTypeOf(queryData1).toEqualTypeOf() const queryData2 = await queryClient.ensureQueryData(queryOptions) @@ -282,9 +283,9 @@ describe('fully typed usage', () => { Array<[ReadonlyArray, unknown]> >() - const queryData3 = queryClient.setQueryData(queryKey, { foo: '' }) + const queryData3 = queryClient.setQueryData(filterKey, { foo: '' }) type SetQueryDataUpdaterArg = Parameters< - typeof queryClient.setQueryData + typeof queryClient.setQueryData >[1] expectTypeOf().toEqualTypeOf< @@ -302,7 +303,7 @@ describe('fully typed usage', () => { >() expectTypeOf(queriesData2).toEqualTypeOf>() - const queryState = queryClient.getQueryState(queryKey) + const queryState = queryClient.getQueryState(filterKey) expectTypeOf(queryState).toEqualTypeOf< QueryState | undefined >() @@ -343,7 +344,7 @@ describe('fully typed usage', () => { }, }) - const queryDefaults = queryClient.getQueryDefaults(queryKey) + const queryDefaults = queryClient.getQueryDefaults(filterKey) expectTypeOf(queryDefaults).toEqualTypeOf< OmitKeyof, 'queryKey'> >() @@ -358,7 +359,7 @@ describe('fully typed usage', () => { queryClient.invalidateQueries(queryFilters) queryClient.refetchQueries(queryFilters) queryClient.prefetchInfiniteQuery(fetchInfiniteQueryOptions) - queryClient.setQueryDefaults(queryKey, {} as any) + queryClient.setQueryDefaults(filterKey, {} as any) queryClient.getMutationDefaults(mutationKey) }) @@ -391,7 +392,7 @@ describe('fully typed usage', () => { return false }, } - const queryKey = queryFilters.queryKey! + const filterKey = queryFilters.queryKey! const mutationFilters: MutationFilters = { predicate(mutation) { @@ -407,12 +408,12 @@ describe('fully typed usage', () => { // Method type tests // - const state = queryClient.getQueryState(queryKey) + const state = queryClient.getQueryState(filterKey) expectTypeOf(state).toEqualTypeOf< QueryState | undefined >() - const queryData1 = queryClient.getQueryData(queryKey) + const queryData1 = queryClient.getQueryData(filterKey) expectTypeOf(queryData1).toEqualTypeOf() const queryData2 = await queryClient.ensureQueryData(queryOptions) @@ -421,9 +422,9 @@ describe('fully typed usage', () => { const queriesData = queryClient.getQueriesData(queryFilters) expectTypeOf(queriesData).toEqualTypeOf>() - const queryData3 = queryClient.setQueryData(queryKey, { foo: '' }) + const queryData3 = queryClient.setQueryData(filterKey, { foo: '' }) type SetQueryDataUpdaterArg = Parameters< - typeof queryClient.setQueryData + typeof queryClient.setQueryData >[1] expectTypeOf().toEqualTypeOf< @@ -441,7 +442,7 @@ describe('fully typed usage', () => { >() expectTypeOf(queriesData2).toEqualTypeOf>() - const queryState = queryClient.getQueryState(queryKey) + const queryState = queryClient.getQueryState(filterKey) expectTypeOf(queryState).toEqualTypeOf< QueryState | undefined >() @@ -488,7 +489,7 @@ describe('fully typed usage', () => { }, }) - const queryDefaults = queryClient.getQueryDefaults(queryKey) + const queryDefaults = queryClient.getQueryDefaults(filterKey) expectTypeOf(queryDefaults).toEqualTypeOf< OmitKeyof, 'queryKey'> >() @@ -503,7 +504,7 @@ describe('fully typed usage', () => { queryClient.invalidateQueries(queryFilters) queryClient.refetchQueries(queryFilters) queryClient.prefetchInfiniteQuery(fetchInfiniteQueryOptions) - queryClient.setQueryDefaults(queryKey, {} as any) + queryClient.setQueryDefaults(filterKey, {} as any) queryClient.getMutationDefaults(mutationKey) }) }) @@ -526,10 +527,10 @@ describe('invalidateQueries', () => { }) }) it('predicate should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() queryClient.invalidateQueries({ - queryKey, + queryKey: key, predicate: (query) => { expectTypeOf(query.state.data).toEqualTypeOf() expectTypeOf(query.queryKey).toEqualTypeOf() @@ -541,10 +542,10 @@ describe('invalidateQueries', () => { describe('cancelQueries', () => { it('predicate should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() queryClient.cancelQueries({ - queryKey, + queryKey: key, predicate: (query) => { expectTypeOf(query.state.data).toEqualTypeOf() expectTypeOf(query.queryKey).toEqualTypeOf() @@ -556,10 +557,10 @@ describe('cancelQueries', () => { describe('removeQueries', () => { it('predicate should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() queryClient.removeQueries({ - queryKey, + queryKey: key, predicate: (query) => { expectTypeOf(query.state.data).toEqualTypeOf() expectTypeOf(query.queryKey).toEqualTypeOf() @@ -571,10 +572,10 @@ describe('removeQueries', () => { describe('refetchQueries', () => { it('predicate should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() queryClient.refetchQueries({ - queryKey, + queryKey: key, predicate: (query) => { expectTypeOf(query.state.data).toEqualTypeOf() expectTypeOf(query.queryKey).toEqualTypeOf() @@ -586,10 +587,10 @@ describe('refetchQueries', () => { describe('resetQueries', () => { it('predicate should be typed if key is tagged', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() queryClient.resetQueries({ - queryKey, + queryKey: key, predicate: (query) => { expectTypeOf(query.state.data).toEqualTypeOf() expectTypeOf(query.queryKey).toEqualTypeOf() @@ -600,24 +601,24 @@ describe('resetQueries', () => { }) type SuccessCallback = () => unknown it('should infer types correctly with expression body arrow functions', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() // @ts-expect-error const callbackTest: SuccessCallback = () => - queryClient.setQueryData(queryKey, (data) => { + queryClient.setQueryData(key, (data) => { expectTypeOf(data).toEqualTypeOf() return data }) }) it('should infer types correctly with block body arrow functions', () => { - const queryKey = ['key'] as DataTag, number> + const key = ['key'] as DataTag, number> const queryClient = new QueryClient() // @ts-expect-error const callbackTest2: SuccessCallback = () => { - queryClient.setQueryData(queryKey, (data) => { + queryClient.setQueryData(key, (data) => { expectTypeOf(data).toEqualTypeOf() return data }) diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index 2676717fb62..c09db304467 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { CancelledError, @@ -32,7 +32,7 @@ describe('queryClient', () => { }) describe('defaultOptions', () => { - test('should merge defaultOptions', () => { + it('should merge defaultOptions', () => { const key = queryKey() const queryFn = () => 'data' @@ -43,7 +43,7 @@ describe('queryClient', () => { expect(() => testClient.prefetchQuery({ queryKey: key })).not.toThrow() }) - test('should merge defaultOptions when query is added to cache', async () => { + it('should merge defaultOptions when query is added to cache', async () => { const key = queryKey() const testClient = new QueryClient({ @@ -58,7 +58,7 @@ describe('queryClient', () => { expect(newQuery?.options.gcTime).toBe(Infinity) }) - test('should get defaultOptions', () => { + it('should get defaultOptions', () => { const queryFn = () => 'data' const defaultOptions = { queries: { queryFn } } const testClient = new QueryClient({ @@ -69,14 +69,14 @@ describe('queryClient', () => { }) describe('setQueryDefaults', () => { - test('should not trigger a fetch', () => { + it('should not trigger a fetch', () => { const key = queryKey() queryClient.setQueryDefaults(key, { queryFn: () => 'data' }) const data = queryClient.getQueryData(key) expect(data).toBeUndefined() }) - test('should be able to override defaults', async () => { + it('should be able to override defaults', async () => { const key = queryKey() queryClient.setQueryDefaults(key, { queryFn: () => 'data' }) const observer = new QueryObserver(queryClient, { queryKey: key }) @@ -84,7 +84,7 @@ describe('queryClient', () => { expect(data).toBe('data') }) - test('should match the query key partially', async () => { + it('should match the query key partially', async () => { const key = queryKey() queryClient.setQueryDefaults([key], { queryFn: () => 'data' }) const observer = new QueryObserver(queryClient, { @@ -94,7 +94,7 @@ describe('queryClient', () => { expect(data).toBe('data') }) - test('should not match if the query key is a subset', async () => { + it('should not match if the query key is a subset', async () => { const key = queryKey() queryClient.setQueryDefaults([key, 'a'], { queryFn: () => 'data', @@ -108,7 +108,7 @@ describe('queryClient', () => { expect(status).toBe('error') }) - test('should also set defaults for observers', () => { + it('should also set defaults for observers', () => { const key = queryKey() queryClient.setQueryDefaults(key, { queryFn: () => 'data', @@ -121,7 +121,7 @@ describe('queryClient', () => { expect(observer.getCurrentResult().fetchStatus).toBe('idle') }) - test('should update existing query defaults', () => { + it('should update existing query defaults', () => { const key = queryKey() const queryOptions1 = { queryFn: () => 'data' } const queryOptions2 = { retry: false } @@ -130,7 +130,7 @@ describe('queryClient', () => { expect(queryClient.getQueryDefaults(key)).toMatchObject(queryOptions2) }) - test('should merge defaultOptions', () => { + it('should merge defaultOptions', () => { const key = queryKey() queryClient.setQueryDefaults([...key, 'todo'], { suspense: true }) @@ -145,7 +145,7 @@ describe('queryClient', () => { }) describe('defaultQueryOptions', () => { - test('should default networkMode when persister is present', () => { + it('should default networkMode when persister is present', () => { expect( new QueryClient({ defaultOptions: { @@ -157,7 +157,7 @@ describe('queryClient', () => { ).toBe('offlineFirst') }) - test('should not default networkMode without persister', () => { + it('should not default networkMode without persister', () => { expect( new QueryClient({ defaultOptions: { @@ -169,7 +169,7 @@ describe('queryClient', () => { ).toBe(undefined) }) - test('should not default networkMode when already present', () => { + it('should not default networkMode when already present', () => { expect( new QueryClient({ defaultOptions: { @@ -184,7 +184,7 @@ describe('queryClient', () => { }) describe('setQueryData', () => { - test('should not crash if query could not be found', () => { + it('should not crash if query could not be found', () => { const key = queryKey() const user = { userId: 1 } expect(() => { @@ -195,7 +195,7 @@ describe('queryClient', () => { }).not.toThrow() }) - test('should not crash when variable is null', () => { + it('should not crash when variable is null', () => { const key = queryKey() queryClient.setQueryData([key, { userId: null }], 'Old Data') expect(() => { @@ -203,7 +203,7 @@ describe('queryClient', () => { }).not.toThrow() }) - test('should use default options', () => { + it('should use default options', () => { const key = queryKey() const testClient = new QueryClient({ defaultOptions: { queries: { queryKeyHashFn: () => 'someKey' } }, @@ -214,19 +214,19 @@ describe('queryClient', () => { expect(testCache.find({ queryKey: key })).toBe(testCache.get('someKey')) }) - test('should create a new query if query was not found 1', () => { + it('should create a new query if query was not found 1', () => { const key = queryKey() queryClient.setQueryData(key, 'bar') expect(queryClient.getQueryData(key)).toBe('bar') }) - test('should create a new query if query was not found 2', () => { + it('should create a new query if query was not found 2', () => { const key = queryKey() queryClient.setQueryData(key, 'qux') expect(queryClient.getQueryData(key)).toBe('qux') }) - test('should not create a new query if query was not found and data is undefined', () => { + it('should not create a new query if query was not found and data is undefined', () => { const key = queryKey() expect(queryClient.getQueryCache().find({ queryKey: key })).toBe( undefined, @@ -237,7 +237,7 @@ describe('queryClient', () => { ) }) - test('should not create a new query if query was not found and updater returns undefined', () => { + it('should not create a new query if query was not found and updater returns undefined', () => { const key = queryKey() expect(queryClient.getQueryCache().find({ queryKey: key })).toBe( undefined, @@ -248,21 +248,21 @@ describe('queryClient', () => { ) }) - test('should not update query data if data is undefined', () => { + it('should not update query data if data is undefined', () => { const key = queryKey() queryClient.setQueryData(key, 'qux') queryClient.setQueryData(key, undefined) expect(queryClient.getQueryData(key)).toBe('qux') }) - test('should not update query data if updater returns undefined', () => { + it('should not update query data if updater returns undefined', () => { const key = queryKey() queryClient.setQueryData(key, 'qux') queryClient.setQueryData(key, () => undefined) expect(queryClient.getQueryData(key)).toBe('qux') }) - test('should accept an update function', () => { + it('should accept an update function', () => { const key = queryKey() const updater = vi.fn((oldData) => `new data + ${oldData}`) @@ -276,7 +276,7 @@ describe('queryClient', () => { ) }) - test('should set the new data without comparison if structuralSharing is set to false', () => { + it('should set the new data without comparison if structuralSharing is set to false', () => { const key = queryKey() queryClient.setDefaultOptions({ @@ -293,7 +293,7 @@ describe('queryClient', () => { expect(queryCache.find({ queryKey: key })!.state.data).toBe(newData) }) - test('should apply a custom structuralSharing function when provided', () => { + it('should apply a custom structuralSharing function when provided', () => { const key = queryKey() const queryObserverOptions = { @@ -325,7 +325,7 @@ describe('queryClient', () => { expect(queryCache.find({ queryKey: key })!.state.data).toBe(distinctData) }) - test('should not set isFetching to false', async () => { + it('should not set isFetching to false', async () => { const key = queryKey() queryClient.prefetchQuery({ queryKey: key, @@ -349,7 +349,7 @@ describe('queryClient', () => { }) describe('setQueriesData', () => { - test('should update all existing, matching queries', () => { + it('should update all existing, matching queries', () => { queryClient.setQueryData(['key', 1], 1) queryClient.setQueryData(['key', 2], 2) @@ -366,7 +366,7 @@ describe('queryClient', () => { expect(queryClient.getQueryData(['key', 2])).toBe(7) }) - test('should accept queryFilters', () => { + it('should accept queryFilters', () => { queryClient.setQueryData(['key', 1], 1) queryClient.setQueryData(['key', 2], 2) const query1 = queryCache.find({ queryKey: ['key', 1] })! @@ -381,7 +381,7 @@ describe('queryClient', () => { expect(queryClient.getQueryData(['key', 2])).toBe(2) }) - test('should not update non existing queries', () => { + it('should not update non existing queries', () => { const result = queryClient.setQueriesData( { queryKey: ['key'] }, 'data', @@ -393,7 +393,7 @@ describe('queryClient', () => { }) describe('isFetching', () => { - test('should return length of fetching queries', async () => { + it('should return length of fetching queries', async () => { expect(queryClient.isFetching()).toBe(0) queryClient.prefetchQuery({ queryKey: queryKey(), @@ -413,7 +413,7 @@ describe('queryClient', () => { }) describe('isMutating', () => { - test('should return length of mutating', async () => { + it('should return length of mutating', async () => { expect(queryClient.isMutating()).toBe(0) new MutationObserver(queryClient, { mutationFn: () => sleep(10).then(() => 'data'), @@ -431,18 +431,18 @@ describe('queryClient', () => { }) describe('getQueryData', () => { - test('should return the query data if the query is found', () => { + it('should return the query data if the query is found', () => { const key = queryKey() queryClient.setQueryData([key, 'id'], 'bar') expect(queryClient.getQueryData([key, 'id'])).toBe('bar') }) - test('should return undefined if the query is not found', () => { + it('should return undefined if the query is not found', () => { const key = queryKey() expect(queryClient.getQueryData(key)).toBeUndefined() }) - test('should match exact by default', () => { + it('should match exact by default', () => { const key = queryKey() queryClient.setQueryData([key, 'id'], 'bar') expect(queryClient.getQueryData([key])).toBeUndefined() @@ -450,7 +450,7 @@ describe('queryClient', () => { }) describe('ensureQueryData', () => { - test('should return the cached query data if the query is found', async () => { + it('should return the cached query data if the query is found', async () => { const key = queryKey() const queryFn = () => Promise.resolve('data') @@ -461,7 +461,7 @@ describe('queryClient', () => { ).resolves.toEqual('bar') }) - test('should return the cached query data if the query is found and cached query data is falsy', async () => { + it('should return the cached query data if the query is found and cached query data is falsy', async () => { const key = queryKey() const queryFn = () => Promise.resolve(0) @@ -472,7 +472,7 @@ describe('queryClient', () => { ).resolves.toEqual(null) }) - test('should call fetchQuery and return its results if the query is not found', async () => { + it('should call fetchQuery and return its results if the query is not found', async () => { const key = queryKey() const queryFn = () => Promise.resolve('data') @@ -481,7 +481,7 @@ describe('queryClient', () => { ).resolves.toEqual('data') }) - test('should return the cached query data if the query is found and preFetchQuery in the background when revalidateIfStale is set', async () => { + it('should return the cached query data if the query is found and preFetchQuery in the background when revalidateIfStale is set', async () => { const TIMEOUT = 10 const key = queryKey() queryClient.setQueryData([key, 'id'], 'old') @@ -508,7 +508,7 @@ describe('queryClient', () => { ).resolves.toEqual('new') }) - test('should not fetch with initialDat', async () => { + it('should not fetch with initialDat', async () => { const key = queryKey() const queryFn = vi.fn().mockImplementation(() => Promise.resolve('data')) @@ -525,7 +525,7 @@ describe('queryClient', () => { }) describe('ensureInfiniteQueryData', () => { - test('should return the cached query data if the query is found', async () => { + it('should return the cached query data if the query is found', async () => { const key = queryKey() const queryFn = () => Promise.resolve('data') @@ -541,7 +541,7 @@ describe('queryClient', () => { ).resolves.toEqual({ pages: ['bar'], pageParams: [0] }) }) - test('should fetch the query and return its results if the query is not found', async () => { + it('should fetch the query and return its results if the query is not found', async () => { const key = queryKey() const queryFn = () => Promise.resolve('data') @@ -555,7 +555,7 @@ describe('queryClient', () => { ).resolves.toEqual({ pages: ['data'], pageParams: [1] }) }) - test('should return the cached query data if the query is found and preFetchQuery in the background when revalidateIfStale is set', async () => { + it('should return the cached query data if the query is found and preFetchQuery in the background when revalidateIfStale is set', async () => { const TIMEOUT = 10 const key = queryKey() queryClient.setQueryData([key, 'id'], { pages: ['old'], pageParams: [0] }) @@ -585,7 +585,7 @@ describe('queryClient', () => { }) describe('getQueriesData', () => { - test('should return the query data for all matched queries', () => { + it('should return the query data for all matched queries', () => { const key1 = queryKey() const key2 = queryKey() queryClient.setQueryData([key1, 1], 1) @@ -597,12 +597,12 @@ describe('queryClient', () => { ]) }) - test('should return empty array if queries are not found', () => { + it('should return empty array if queries are not found', () => { const key = queryKey() expect(queryClient.getQueriesData({ queryKey: key })).toEqual([]) }) - test('should accept query filters', () => { + it('should accept query filters', () => { queryClient.setQueryData(['key', 1], 1) queryClient.setQueryData(['key', 2], 2) const query1 = queryCache.find({ queryKey: ['key', 1] })! @@ -616,7 +616,7 @@ describe('queryClient', () => { }) describe('fetchQuery', () => { - test('should not type-error with strict query key', async () => { + it('should not type-error with strict query key', async () => { type StrictData = 'data' type StrictQueryKey = ['strict', ...ReturnType] const key: StrictQueryKey = ['strict', ...queryKey()] @@ -633,7 +633,7 @@ describe('queryClient', () => { }) // https://github.com/tannerlinsley/react-query/issues/652 - test('should not retry by default', async () => { + it('should not retry by default', async () => { const key = queryKey() await expect( @@ -646,7 +646,7 @@ describe('queryClient', () => { ).rejects.toEqual(new Error('error')) }) - test('should return the cached data on cache hit', async () => { + it('should return the cached data on cache hit', async () => { const key = queryKey() const fetchFn = () => Promise.resolve('data') @@ -662,7 +662,7 @@ describe('queryClient', () => { expect(second).toBe(first) }) - test('should read from cache with static staleTime even if invalidated', async () => { + it('should read from cache with static staleTime even if invalidated', async () => { const key = queryKey() const fetchFn = vi.fn(() => Promise.resolve({ data: 'data' })) @@ -691,7 +691,7 @@ describe('queryClient', () => { expect(second).toBe(first) }) - test('should be able to fetch when garbage collection time is set to 0 and then be removed', async () => { + it('should be able to fetch when garbage collection time is set to 0 and then be removed', async () => { const key1 = queryKey() const promise = queryClient.fetchQuery({ queryKey: key1, @@ -704,7 +704,7 @@ describe('queryClient', () => { expect(queryClient.getQueryData(key1)).toEqual(undefined) }) - test('should keep a query in cache if garbage collection time is Infinity', async () => { + it('should keep a query in cache if garbage collection time is Infinity', async () => { const key1 = queryKey() const promise = queryClient.fetchQuery({ queryKey: key1, @@ -717,7 +717,7 @@ describe('queryClient', () => { expect(result2).toEqual(1) }) - test('should not force fetch', async () => { + it('should not force fetch', async () => { const key = queryKey() queryClient.setQueryData(key, 'og') @@ -731,7 +731,7 @@ describe('queryClient', () => { expect(first).toBe('og') }) - test('should only fetch if the data is older then the given stale time', async () => { + it('should only fetch if the data is older then the given stale time', async () => { const key = queryKey() let count = 0 @@ -766,7 +766,7 @@ describe('queryClient', () => { await expect(fourthPromise).resolves.toBe(2) }) - test('should allow new meta', async () => { + it('should allow new meta', async () => { const key = queryKey() const first = await queryClient.fetchQuery({ @@ -790,7 +790,7 @@ describe('queryClient', () => { }) describe('fetchInfiniteQuery', () => { - test('should not type-error with strict query key', async () => { + it('should not type-error with strict query key', async () => { type StrictData = string type StrictQueryKey = ['strict', ...ReturnType] const key: StrictQueryKey = ['strict', ...queryKey()] @@ -814,7 +814,7 @@ describe('queryClient', () => { ).resolves.toEqual(data) }) - test('should return infinite query data', async () => { + it('should return infinite query data', async () => { const key = queryKey() const result = await queryClient.fetchInfiniteQuery({ queryKey: key, @@ -834,7 +834,7 @@ describe('queryClient', () => { }) describe('prefetchInfiniteQuery', () => { - test('should not type-error with strict query key', async () => { + it('should not type-error with strict query key', async () => { type StrictData = 'data' type StrictQueryKey = ['strict', ...ReturnType] const key: StrictQueryKey = ['strict', ...queryKey()] @@ -858,7 +858,7 @@ describe('queryClient', () => { }) }) - test('should return infinite query data', async () => { + it('should return infinite query data', async () => { const key = queryKey() await queryClient.prefetchInfiniteQuery({ @@ -875,7 +875,7 @@ describe('queryClient', () => { }) }) - test('should prefetch multiple pages', async () => { + it('should prefetch multiple pages', async () => { const key = queryKey() await queryClient.prefetchInfiniteQuery({ @@ -895,7 +895,7 @@ describe('queryClient', () => { }) }) - test('should stop prefetching if getNextPageParam returns undefined', async () => { + it('should stop prefetching if getNextPageParam returns undefined', async () => { const key = queryKey() let count = 0 @@ -923,7 +923,7 @@ describe('queryClient', () => { }) describe('prefetchQuery', () => { - test('should not type-error with strict query key', async () => { + it('should not type-error with strict query key', async () => { type StrictData = 'data' type StrictQueryKey = ['strict', ...ReturnType] const key: StrictQueryKey = ['strict', ...queryKey()] @@ -943,7 +943,7 @@ describe('queryClient', () => { expect(result).toEqual('data') }) - test('should return undefined when an error is thrown', async () => { + it('should return undefined when an error is thrown', async () => { const key = queryKey() const result = await queryClient.prefetchQuery({ @@ -957,7 +957,7 @@ describe('queryClient', () => { expect(result).toBeUndefined() }) - test('should be garbage collected after gcTime if unused', async () => { + it('should be garbage collected after gcTime if unused', async () => { const key = queryKey() await queryClient.prefetchQuery({ @@ -972,7 +972,7 @@ describe('queryClient', () => { }) describe('removeQueries', () => { - test('should not crash when exact is provided', async () => { + it('should not crash when exact is provided', async () => { const key = queryKey() const fetchFn = () => Promise.resolve('data') @@ -992,7 +992,7 @@ describe('queryClient', () => { }) describe('cancelQueries', () => { - test('should revert queries to their previous state', async () => { + it('should revert queries to their previous state', async () => { const key1 = queryKey() queryClient.setQueryData(key1, 'data') @@ -1015,7 +1015,7 @@ describe('queryClient', () => { }) }) - test('should not revert if revert option is set to false', async () => { + it('should not revert if revert option is set to false', async () => { const key1 = queryKey() await queryClient.fetchQuery({ queryKey: key1, @@ -1033,15 +1033,12 @@ describe('queryClient', () => { }) }) - test('should throw CancelledError for imperative methods when initial fetch is cancelled', async () => { + it('should throw CancelledError for imperative methods when initial fetch is cancelled', async () => { const key = queryKey() const promise = queryClient.fetchQuery({ queryKey: key, - queryFn: async () => { - await sleep(50) - return 25 - }, + queryFn: () => sleep(50).then(() => 25), }) await vi.advanceTimersByTimeAsync(10) @@ -1063,7 +1060,7 @@ describe('queryClient', () => { }) describe('refetchQueries', () => { - test('should not refetch if all observers are disabled', async () => { + it('should not refetch if all observers are disabled', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -1079,7 +1076,7 @@ describe('queryClient', () => { observer1.destroy() expect(queryFn).toHaveBeenCalledTimes(1) }) - test('should refetch if at least one observer is enabled', async () => { + it('should refetch if at least one observer is enabled', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -1102,7 +1099,7 @@ describe('queryClient', () => { observer2.destroy() expect(queryFn).toHaveBeenCalledTimes(2) }) - test('should refetch all queries when no arguments are given', async () => { + it('should refetch all queries when no arguments are given', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1134,7 +1131,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(2) }) - test('should be able to refetch all fresh queries', async () => { + it('should be able to refetch all fresh queries', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1157,7 +1154,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should be able to refetch all stale queries', async () => { + it('should be able to refetch all stale queries', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1181,7 +1178,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should be able to refetch all stale and active queries', async () => { + it('should be able to refetch all stale and active queries', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1207,7 +1204,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should be able to refetch all active and inactive queries (queryClient.refetchQueries()', async () => { + it('should be able to refetch all active and inactive queries (queryClient.refetchQueries()', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1230,7 +1227,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(2) }) - test('should be able to refetch all active and inactive queries (queryClient.refetchQueries({ type: "all" }))', async () => { + it('should be able to refetch all active and inactive queries (queryClient.refetchQueries({ type: "all" }))', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1253,7 +1250,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(2) }) - test('should be able to refetch only active queries', async () => { + it('should be able to refetch only active queries', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1276,7 +1273,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should be able to refetch only inactive queries', async () => { + it('should be able to refetch only inactive queries', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1299,7 +1296,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(2) }) - test('should throw an error if throwOnError option is set to true', async () => { + it('should throw an error if throwOnError option is set to true', async () => { const key1 = queryKey() const queryFnError = () => Promise.reject('error') try { @@ -1321,7 +1318,7 @@ describe('queryClient', () => { expect(error).toEqual('error') }) - test('should resolve Promise immediately if query is paused', async () => { + it('should resolve Promise immediately if query is paused', async () => { const key1 = queryKey() const queryFn1 = vi .fn<(...args: Array) => string>() @@ -1336,7 +1333,7 @@ describe('queryClient', () => { onlineMock.mockRestore() }) - test('should refetch if query we are offline but query networkMode is always', async () => { + it('should refetch if query we are offline but query networkMode is always', async () => { const key1 = queryKey() queryClient.setQueryDefaults(key1, { networkMode: 'always' }) const queryFn1 = vi @@ -1352,7 +1349,7 @@ describe('queryClient', () => { onlineMock.mockRestore() }) - test('should not refetch static queries', async () => { + it('should not refetch static queries', async () => { const key = queryKey() const queryFn = vi.fn(() => 'data1') await queryClient.fetchQuery({ queryKey: key, queryFn: queryFn }) @@ -1373,7 +1370,7 @@ describe('queryClient', () => { }) describe('invalidateQueries', () => { - test('should refetch active queries by default', async () => { + it('should refetch active queries by default', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1396,7 +1393,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should not refetch inactive queries by default', async () => { + it('should not refetch inactive queries by default', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1419,7 +1416,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should not refetch active queries when "refetch" is "none"', async () => { + it('should not refetch active queries when "refetch" is "none"', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1445,7 +1442,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should refetch inactive queries when "refetch" is "inactive"', async () => { + it('should refetch inactive queries when "refetch" is "inactive"', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1473,7 +1470,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should refetch active and inactive queries when "refetch" is "all"', async () => { + it('should refetch active and inactive queries when "refetch" is "all"', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = vi @@ -1498,7 +1495,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(2) }) - test('should not refetch disabled inactive queries even if "refetchType" is "all', async () => { + it('should not refetch disabled inactive queries even if "refetchType" is "all', async () => { const queryFn = vi .fn<(...args: Array) => string>() .mockReturnValue('data1') @@ -1516,7 +1513,7 @@ describe('queryClient', () => { expect(queryFn).toHaveBeenCalledTimes(0) }) - test('should not refetch inactive queries that have a skipToken queryFn even if "refetchType" is "all', async () => { + it('should not refetch inactive queries that have a skipToken queryFn even if "refetchType" is "all', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -1538,7 +1535,7 @@ describe('queryClient', () => { expect(queryClient.getQueryState(key)?.dataUpdateCount).toBe(1) }) - test('should cancel ongoing fetches if cancelRefetch option is set (default value)', async () => { + it('should cancel ongoing fetches if cancelRefetch option is set (default value)', async () => { const key = queryKey() const abortFn = vi.fn() let fetchCount = 0 @@ -1561,7 +1558,7 @@ describe('queryClient', () => { expect(fetchCount).toBe(2) }) - test('should not cancel ongoing fetches if cancelRefetch option is set to false', async () => { + it('should not cancel ongoing fetches if cancelRefetch option is set to false', async () => { const key = queryKey() const abortFn = vi.fn() let fetchCount = 0 @@ -1585,7 +1582,7 @@ describe('queryClient', () => { expect(fetchCount).toBe(1) }) - test('should not refetch static queries after invalidation', async () => { + it('should not refetch static queries after invalidation', async () => { const key = queryKey() const queryFn = vi.fn(() => 'data1') await queryClient.fetchQuery({ queryKey: key, queryFn: queryFn }) @@ -1606,7 +1603,7 @@ describe('queryClient', () => { }) describe('resetQueries', () => { - test('should notify listeners when a query is reset', async () => { + it('should notify listeners when a query is reset', async () => { const key = queryKey() const callback = vi.fn() @@ -1620,7 +1617,7 @@ describe('queryClient', () => { expect(callback).toHaveBeenCalled() }) - test('should reset query', async () => { + it('should reset query', async () => { const key = queryKey() await queryClient.prefetchQuery({ queryKey: key, queryFn: () => 'data' }) @@ -1639,7 +1636,7 @@ describe('queryClient', () => { expect(state?.fetchStatus).toEqual('idle') }) - test('should reset query data to initial data if set', async () => { + it('should reset query data to initial data if set', async () => { const key = queryKey() await queryClient.prefetchQuery({ @@ -1659,7 +1656,7 @@ describe('queryClient', () => { expect(state?.data).toEqual('initial') }) - test('should refetch all active queries', async () => { + it('should refetch all active queries', async () => { const key1 = queryKey() const key2 = queryKey() const key3 = queryKey() @@ -1702,7 +1699,7 @@ describe('queryClient', () => { onlineManager.setOnline(true) focusManager.setFocused(undefined) }) - test('should notify queryCache and mutationCache if focused', async () => { + it('should notify queryCache and mutationCache if focused', async () => { const testClient = new QueryClient() testClient.mount() @@ -1735,7 +1732,7 @@ describe('queryClient', () => { queryCacheOnOnlineSpy.mockRestore() }) - test('should notify queryCache and mutationCache if online', async () => { + it('should notify queryCache and mutationCache if online', async () => { const testClient = new QueryClient() testClient.mount() @@ -1769,7 +1766,7 @@ describe('queryClient', () => { mutationCacheResumePausedMutationsSpy.mockRestore() }) - test('should resume paused mutations when coming online', async () => { + it('should resume paused mutations when coming online', async () => { const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) onlineManager.setOnline(false) @@ -1794,7 +1791,7 @@ describe('queryClient', () => { expect(observer2.getCurrentResult().status).toBe('success') }) - test('should resume paused mutations in parallel', async () => { + it('should resume paused mutations in parallel', async () => { onlineManager.setOnline(false) const orders: Array = [] @@ -1831,7 +1828,7 @@ describe('queryClient', () => { expect(orders).toEqual(['1start', '2start', '2end', '1end']) }) - test('should resume paused mutations one after the other when in the same scope when invoked manually at the same time', async () => { + it('should resume paused mutations one after the other when in the same scope when invoked manually at the same time', async () => { const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) onlineManager.setOnline(false) @@ -1877,7 +1874,7 @@ describe('queryClient', () => { expect(orders).toEqual(['1start', '1end', '2start', '2end']) }) - test('should resumePausedMutations when coming online after having called resumePausedMutations while offline', async () => { + it('should resumePausedMutations when coming online after having called resumePausedMutations while offline', async () => { const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) onlineManager.setOnline(false) @@ -1901,7 +1898,7 @@ describe('queryClient', () => { expect(observer.getCurrentResult().status).toBe('success') }) - test('should resumePausedMutations when coming online after having restored cache (and resumed) while offline', async () => { + it('should resumePausedMutations when coming online after having restored cache (and resumed) while offline', async () => { const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) onlineManager.setOnline(false) @@ -1945,7 +1942,7 @@ describe('queryClient', () => { newQueryClient.unmount() }) - test('should notify queryCache after resumePausedMutations has finished when coming online', async () => { + it('should notify queryCache after resumePausedMutations has finished when coming online', async () => { const key = queryKey() let count = 0 @@ -2030,7 +2027,7 @@ describe('queryClient', () => { unsubscribe() }) - test('should notify queryCache and mutationCache after multiple mounts and single unmount', async () => { + it('should notify queryCache and mutationCache after multiple mounts and single unmount', async () => { const testClient = new QueryClient() testClient.mount() testClient.mount() @@ -2067,7 +2064,7 @@ describe('queryClient', () => { onlineManager.setOnline(true) }) - test('should not notify queryCache and mutationCache after multiple mounts/unmounts', () => { + it('should not notify queryCache and mutationCache after multiple mounts/unmounts', () => { const testClient = new QueryClient() testClient.mount() testClient.mount() @@ -2104,7 +2101,7 @@ describe('queryClient', () => { }) describe('setMutationDefaults', () => { - test('should update existing mutation defaults', () => { + it('should update existing mutation defaults', () => { const key = queryKey() const mutationOptions1 = { mutationFn: () => Promise.resolve('data') } const mutationOptions2 = { retry: false } @@ -2115,7 +2112,7 @@ describe('queryClient', () => { ) }) - test('should return only matching defaults when multiple mutation defaults are set', () => { + it('should return only matching defaults when multiple mutation defaults are set', () => { const key1 = queryKey() const key2 = queryKey() const mutationOptions1 = { retry: 1 } diff --git a/packages/query-core/src/__tests__/queryObserver.test-d.tsx b/packages/query-core/src/__tests__/queryObserver.test-d.tsx index f248c393de2..5d929e40ad8 100644 --- a/packages/query-core/src/__tests__/queryObserver.test-d.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test-d.tsx @@ -122,7 +122,7 @@ describe('queryObserver', () => { } new QueryObserver(new QueryClient(), { - queryKey: ['key'], + queryKey: queryKey(), placeholderData: (_, previousQuery) => { if (previousQuery) { expectTypeOf( @@ -138,7 +138,7 @@ describe('queryObserver', () => { const queryData = { foo: 'bar' } as const new QueryObserver(new QueryClient(), { - queryKey: ['key'], + queryKey: queryKey(), queryFn: () => queryData, select: (data) => data.foo, placeholderData: (previousData) => { diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index 689dd8d2e19..93946f289a6 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -5,7 +5,6 @@ import { expect, expectTypeOf, it, - test, vi, } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' @@ -32,7 +31,7 @@ describe('queryObserver', () => { vi.useRealTimers() }) - test('should trigger a fetch when subscribed', () => { + it('should trigger a fetch when subscribed', () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -43,7 +42,7 @@ describe('queryObserver', () => { expect(queryFn).toHaveBeenCalledTimes(1) }) - test('should be able to read latest data after subscribing', () => { + it('should be able to read latest data after subscribing', () => { const key = queryKey() queryClient.setQueryData(key, 'data') const observer = new QueryObserver(queryClient, { @@ -84,7 +83,7 @@ describe('queryObserver', () => { }) }) - test('should not fetch on mount', () => { + it('should not fetch on mount', () => { const unsubscribe = observer.subscribe(vi.fn()) // Has not fetched and is not fetching since its disabled @@ -98,7 +97,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should not be re-fetched when invalidated with refetchType: all', async () => { + it('should not be re-fetched when invalidated with refetchType: all', async () => { const unsubscribe = observer.subscribe(vi.fn()) queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) @@ -116,7 +115,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should still trigger a fetch when refetch is called', async () => { + it('should still trigger a fetch when refetch is called', async () => { const unsubscribe = observer.subscribe(vi.fn()) expect(enabled).toBe(false) @@ -141,7 +140,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should fetch if unsubscribed, then enabled returns true, and then re-subscribed', async () => { + it('should fetch if unsubscribed, then enabled returns true, and then re-subscribed', async () => { let unsubscribe = observer.subscribe(vi.fn()) expect(observer.getCurrentResult()).toMatchObject({ status: 'pending', @@ -166,7 +165,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should not be re-fetched if not subscribed to after enabled was toggled to true (fetchStatus: "idle")', () => { + it('should not be re-fetched if not subscribed to after enabled was toggled to true (fetchStatus: "idle")', () => { const unsubscribe = observer.subscribe(vi.fn()) // Toggle enabled @@ -184,7 +183,7 @@ describe('queryObserver', () => { expect(count).toBe(0) }) - test('should not be re-fetched if not subscribed to after enabled was toggled to true (fetchStatus: "fetching")', async () => { + it('should not be re-fetched if not subscribed to after enabled was toggled to true (fetchStatus: "fetching")', async () => { const unsubscribe = observer.subscribe(vi.fn()) // Toggle enabled @@ -203,7 +202,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should handle that the enabled callback updates the return value', async () => { + it('should handle that the enabled callback updates the return value', async () => { const unsubscribe = observer.subscribe(vi.fn()) // Toggle enabled @@ -234,7 +233,7 @@ describe('queryObserver', () => { }) }) - test('should be able to read latest data when re-subscribing (but not re-fetching)', async () => { + it('should be able to read latest data when re-subscribing (but not re-fetching)', async () => { const key = queryKey() let count = 0 const observer = new QueryObserver(queryClient, { @@ -272,7 +271,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should notify when switching query', async () => { + it('should notify when switching query', async () => { const key1 = queryKey() const key2 = queryKey() const results: Array = [] @@ -294,7 +293,7 @@ describe('queryObserver', () => { expect(results[3]).toMatchObject({ data: 2, status: 'success' }) }) - test('should be able to fetch with a selector', async () => { + it('should be able to fetch with a selector', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -313,7 +312,7 @@ describe('queryObserver', () => { expect(observerResult).toMatchObject({ data: { myCount: 1 } }) }) - test('should be able to fetch with a selector using the fetch method', async () => { + it('should be able to fetch with a selector using the fetch method', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -327,7 +326,7 @@ describe('queryObserver', () => { expect(observerResult.data).toMatchObject({ myCount: 1 }) }) - test('should be able to fetch with a selector and object syntax', async () => { + it('should be able to fetch with a selector and object syntax', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -343,7 +342,7 @@ describe('queryObserver', () => { expect(observerResult).toMatchObject({ data: { myCount: 1 } }) }) - test('should run the selector again if the data changed', async () => { + it('should run the selector again if the data changed', async () => { const key = queryKey() let count = 0 const observer = new QueryObserver(queryClient, { @@ -361,7 +360,7 @@ describe('queryObserver', () => { expect(observerResult2.data).toMatchObject({ myCount: 1 }) }) - test('should run the selector again if the selector changed', async () => { + it('should run the selector again if the selector changed', async () => { const key = queryKey() let count = 0 const results: Array = [] @@ -419,7 +418,7 @@ describe('queryObserver', () => { }) }) - test('should not run the selector again if the data and selector did not change', async () => { + it('should not run the selector again if the data and selector did not change', async () => { const key = queryKey() let count = 0 const results: Array = [] @@ -468,7 +467,7 @@ describe('queryObserver', () => { }) }) - test('should not run the selector again if the data did not change', async () => { + it('should not run the selector again if the data did not change', async () => { const key = queryKey() let count = 0 const observer = new QueryObserver(queryClient, { @@ -486,13 +485,10 @@ describe('queryObserver', () => { expect(observerResult2.data).toMatchObject({ myCount: 1 }) }) - test('should always run the selector again if selector throws an error and selector is not referentially stable', async () => { + it('should always run the selector again if selector throws an error and selector is not referentially stable', async () => { const key = queryKey() const results: Array = [] - const queryFn = async () => { - await sleep(10) - return { count: 1 } - } + const queryFn = () => sleep(10).then(() => ({ count: 1 })) const observer = new QueryObserver(queryClient, { queryKey: key, queryFn, @@ -529,7 +525,7 @@ describe('queryObserver', () => { }) }) - test('should return stale data if selector throws an error', async () => { + it('should return stale data if selector throws an error', async () => { const key = queryKey() const results: Array = [] let shouldError = false @@ -537,10 +533,7 @@ describe('queryObserver', () => { const observer = new QueryObserver(queryClient, { queryKey: key, retry: 0, - queryFn: async () => { - await sleep(10) - return shouldError ? 2 : 1 - }, + queryFn: () => sleep(10).then(() => (shouldError ? 2 : 1)), select: (num) => { if (shouldError) { throw error @@ -584,7 +577,7 @@ describe('queryObserver', () => { }) }) - test('should structurally share the selector', async () => { + it('should structurally share the selector', async () => { const key = queryKey() let count = 0 const observer = new QueryObserver(queryClient, { @@ -598,7 +591,7 @@ describe('queryObserver', () => { expect(observerResult1.data).toBe(observerResult2.data) }) - test('should not trigger a fetch when subscribed and disabled', async () => { + it('should not trigger a fetch when subscribed and disabled', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -614,7 +607,7 @@ describe('queryObserver', () => { expect(queryFn).toHaveBeenCalledTimes(0) }) - test('should not trigger a fetch when subscribed and disabled by callback', async () => { + it('should not trigger a fetch when subscribed and disabled by callback', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -630,7 +623,7 @@ describe('queryObserver', () => { expect(queryFn).toHaveBeenCalledTimes(0) }) - test('should not trigger a fetch when not subscribed', async () => { + it('should not trigger a fetch when not subscribed', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -640,7 +633,7 @@ describe('queryObserver', () => { expect(queryFn).toHaveBeenCalledTimes(0) }) - test('should be able to watch a query without defining a query function', async () => { + it('should be able to watch a query without defining a query function', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -658,7 +651,7 @@ describe('queryObserver', () => { expect(callback).toHaveBeenCalledTimes(2) }) - test('should accept unresolved query config in update function', async () => { + it('should accept unresolved query config in update function', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -681,7 +674,7 @@ describe('queryObserver', () => { expect(results[1]).toMatchObject({ isStale: false, data: 'data' }) }) - test('should be able to handle multiple subscribers', async () => { + it('should be able to handle multiple subscribers', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -711,7 +704,7 @@ describe('queryObserver', () => { expect(results2[1]).toMatchObject({ data: 'data' }) }) - test('should stop retry when unsubscribing', async () => { + it('should stop retry when unsubscribing', async () => { const key = queryKey() let count = 0 const observer = new QueryObserver(queryClient, { @@ -730,7 +723,7 @@ describe('queryObserver', () => { expect(count).toBe(2) }) - test('should clear interval when unsubscribing to a refetchInterval query', async () => { + it('should clear interval when unsubscribing to a refetchInterval query', async () => { const key = queryKey() let count = 0 @@ -754,7 +747,87 @@ describe('queryObserver', () => { expect(count).toBe(2) }) - test('uses placeholderData as non-cache data when pending a query with no data', async () => { + it('should refetch at the interval returned when refetchInterval is a function', async () => { + const key = queryKey() + let count = 0 + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => { + count++ + return Promise.resolve('data') + }, + refetchInterval: () => 10, + }) + const unsubscribe = observer.subscribe(() => undefined) + expect(count).toBe(1) + await vi.advanceTimersByTimeAsync(10) + expect(count).toBe(2) + unsubscribe() + }) + + it('should call refetchInterval with the query when it is a function', async () => { + const key = queryKey() + const refetchInterval = vi.fn(() => 10) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + refetchInterval, + }) + const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(10) + expect(refetchInterval).toHaveBeenCalledWith( + queryClient.getQueryCache().find({ queryKey: key }), + ) + unsubscribe() + }) + + it('should notify listeners when notifyOnChangeProps is a function returning props that changed', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'data') + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'new data'), + staleTime: Infinity, + notifyOnChangeProps: () => ['data'], + }) + const listener = vi.fn() + + const unsubscribe = observer.subscribe(listener) + listener.mockClear() + + observer.refetch() + await vi.advanceTimersByTimeAsync(10) + expect(listener).toHaveBeenCalledTimes(1) + + unsubscribe() + }) + + it('should not notify listeners when notifyOnChangeProps is a function returning props that did not change', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'data') + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + notifyOnChangeProps: () => ['data'], + }) + const listener = vi.fn() + + const unsubscribe = observer.subscribe(listener) + listener.mockClear() + + observer.refetch() + await vi.advanceTimersByTimeAsync(10) + expect(listener).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('should use placeholderData as non-cache data when pending a query with no data', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -781,7 +854,7 @@ describe('queryObserver', () => { expect(results[1]).toMatchObject({ status: 'success', data: 'data' }) }) - test('should structurally share placeholder data', () => { + it('should structurally share placeholder data', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -799,7 +872,7 @@ describe('queryObserver', () => { expect(firstData).toBe(secondData) }) - test('should throw an error if enabled option type is not valid', () => { + it('should throw an error if enabled option type is not valid', () => { const key = queryKey() expect( @@ -810,10 +883,10 @@ describe('queryObserver', () => { // @ts-expect-error enabled: null, }), - ).toThrowError('Expected enabled to be a boolean') + ).toThrow('Expected enabled to be a boolean') }) - test('getCurrentQuery should return the current query', () => { + it('should return the current query from getCurrentQuery', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -824,7 +897,7 @@ describe('queryObserver', () => { expect(observer.getCurrentQuery().queryKey).toEqual(key) }) - test('should throw an error if throwOnError option is true', async () => { + it('should throw an error if throwOnError option is true', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -843,7 +916,7 @@ describe('queryObserver', () => { expect(error).toEqual('error') }) - test('should not refetch in background if refetchIntervalInBackground is false', async () => { + it('should not refetch in background if refetchIntervalInBackground is false', async () => { const key = queryKey() const queryFn = vi .fn<(...args: Array) => string>() @@ -867,7 +940,7 @@ describe('queryObserver', () => { focusManager.setFocused(true) }) - test('should not use replaceEqualDeep for select value when structuralSharing option is true', async () => { + it('should not use replaceEqualDeep for select value when structuralSharing option is true', async () => { const key = queryKey() const data = { value: 'data' } @@ -897,7 +970,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should not use replaceEqualDeep for select value when structuralSharing option is true and placeholderData is defined', () => { + it('should not use replaceEqualDeep for select value when structuralSharing option is true and placeholderData is defined', () => { const key = queryKey() const data = { value: 'data' } @@ -934,7 +1007,7 @@ describe('queryObserver', () => { expect(observer.getCurrentResult().data).toBe(selectedData2) }) - test('should pass the correct previous queryKey (from prevQuery) to placeholderData function params with select', async () => { + it('should pass the correct previous queryKey (from prevQuery) to placeholderData function params with select', async () => { const results: Array = [] const keys: Array | null> = [] @@ -1000,7 +1073,7 @@ describe('queryObserver', () => { }) // Successful fetch for new key }) - test('should pass the correct previous data to placeholderData function params when select function is used in conjunction', async () => { + it('should pass the correct previous data to placeholderData function params when select function is used in conjunction', async () => { const results: Array = [] const key1 = queryKey() @@ -1066,7 +1139,7 @@ describe('queryObserver', () => { expect(selectCount).toBe(3) }) - test('should use cached selectResult when switching between queries and placeholderData returns previousData', async () => { + it('should use cached selectResult when switching between queries and placeholderData returns previousData', async () => { const results: Array = [] const key1 = queryKey() @@ -1127,7 +1200,7 @@ describe('queryObserver', () => { expect(stableSelect.mock.calls[1]![0]).toEqual(data2) }) - test('setOptions should notify cache listeners', () => { + it('should notify cache listeners when setOptions is called', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -1147,7 +1220,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('disabled observers should not be stale', () => { + it('should not be stale for disabled observers', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -1159,17 +1232,15 @@ describe('queryObserver', () => { expect(result.isStale).toBe(false) }) - test('should allow staleTime as a function', async () => { + it('should allow staleTime as a function', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, - queryFn: async () => { - await sleep(5) - return { + queryFn: () => + sleep(5).then(() => ({ data: 'data', staleTime: 20, - } - }, + })), staleTime: (query) => query.state.data?.staleTime ?? 0, }) const results: Array> = [] @@ -1187,16 +1258,14 @@ describe('queryObserver', () => { unsubscribe() }) - test('should not see queries as stale is staleTime is Static', async () => { + it('should not see queries as stale is staleTime is Static', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, - queryFn: async () => { - await sleep(5) - return { + queryFn: () => + sleep(5).then(() => ({ data: 'data', - } - }, + })), staleTime: 'static', }) const result = observer.getCurrentResult() @@ -1215,7 +1284,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should return a promise that resolves when data is present', async () => { + it('should return a promise that resolves when data is present', async () => { const results: Array = [] const key = queryKey() let count = 0 @@ -1245,7 +1314,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should return a new promise after recovering from an error', async () => { + it('should return a new promise after recovering from an error', async () => { const results: Array = [] const key = queryKey() @@ -1305,25 +1374,105 @@ describe('queryObserver', () => { unsubscribe() }) - test('shouldFetchOnWindowFocus should respect refetchOnWindowFocus option', () => { + it('should return true from shouldFetchOnWindowFocus when refetchOnWindowFocus is true', () => { const key = queryKey() - const observer1 = new QueryObserver(queryClient, { + const observer = new QueryObserver(queryClient, { queryKey: key, queryFn: () => 'data', refetchOnWindowFocus: true, }) - expect(observer1.shouldFetchOnWindowFocus()).toBe(true) - const observer2 = new QueryObserver(queryClient, { + expect(observer.shouldFetchOnWindowFocus()).toBe(true) + }) + + it('should return false from shouldFetchOnWindowFocus when refetchOnWindowFocus is false', () => { + const key = queryKey() + + const observer = new QueryObserver(queryClient, { queryKey: key, queryFn: () => 'data', refetchOnWindowFocus: false, }) - expect(observer2.shouldFetchOnWindowFocus()).toBe(false) + + expect(observer.shouldFetchOnWindowFocus()).toBe(false) + }) + + it('should return true from shouldFetchOnWindowFocus when refetchOnWindowFocus is "always" even if the query is fresh', async () => { + const key = queryKey() + + queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + await vi.advanceTimersByTimeAsync(10) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + refetchOnWindowFocus: 'always', + }) + + expect(observer.shouldFetchOnWindowFocus()).toBe(true) }) - test('fetchOptimistic should fetch and return optimistic result', async () => { + it('should return true from shouldFetchOnWindowFocus when refetchOnWindowFocus is a function returning true', () => { + const key = queryKey() + const refetchOnWindowFocus = vi.fn(() => true) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => 'data', + refetchOnWindowFocus, + }) + + expect(observer.shouldFetchOnWindowFocus()).toBe(true) + expect(refetchOnWindowFocus).toHaveBeenCalledWith( + queryClient.getQueryCache().find({ queryKey: key }), + ) + }) + + it('should return false from shouldFetchOnWindowFocus when refetchOnWindowFocus is a function returning false', () => { + const key = queryKey() + const refetchOnWindowFocus = vi.fn(() => false) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => 'data', + refetchOnWindowFocus, + }) + + expect(observer.shouldFetchOnWindowFocus()).toBe(false) + expect(refetchOnWindowFocus).toHaveBeenCalledWith( + queryClient.getQueryCache().find({ queryKey: key }), + ) + }) + + it('should return true from shouldFetchOnWindowFocus when refetchOnWindowFocus is a function returning "always" even if the query is fresh', async () => { + const key = queryKey() + const refetchOnWindowFocus = vi.fn(() => 'always' as const) + + queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + await vi.advanceTimersByTimeAsync(10) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + refetchOnWindowFocus, + }) + + expect(observer.shouldFetchOnWindowFocus()).toBe(true) + expect(refetchOnWindowFocus).toHaveBeenCalledWith( + queryClient.getQueryCache().find({ queryKey: key }), + ) + }) + + it('should fetch and return optimistic result via fetchOptimistic', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -1339,7 +1488,7 @@ describe('queryObserver', () => { expect(result.data).toBe('data') }) - test('should track error prop when throwOnError is true', async () => { + it('should track error prop when throwOnError is true', async () => { const key = queryKey() const results: Array = [] const observer = new QueryObserver(queryClient, { @@ -1378,7 +1527,77 @@ describe('queryObserver', () => { unsubscribe() }) - test('should reject promise when experimental_prefetchInRender is disabled and thenable is pending', async () => { + it('should not track error prop when throwOnError is not set', async () => { + const key = queryKey() + const results: Array = [] + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => Promise.reject('error'), + retry: false, + }) + + const trackedResult = observer.trackResult( + observer.getCurrentResult(), + (prop) => { + if (prop === 'data') { + observer.trackProp(prop) + } + }, + ) + + trackedResult.data + + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + + await vi.advanceTimersByTimeAsync(0) + + // Without throwOnError, `error` is not auto-added to trackedProps. + // Since only `data` is tracked and it did not change (stayed undefined), + // the listener is not invoked even though `error` prop changed. + expect(results.length).toBe(0) + + unsubscribe() + }) + + it('should not refetch on mount when retryOnMount is false and query is in error state', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.reject('error')) + + // First observer causes query to fail + const firstObserver = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: false, + }) + const unsubscribeFirst = firstObserver.subscribe(vi.fn()) + + await vi.advanceTimersByTimeAsync(0) + + expect(queryFn).toHaveBeenCalledTimes(1) + expect(queryClient.getQueryState(key)?.status).toBe('error') + + unsubscribeFirst() + + // New observer with retryOnMount: false should not refetch + const secondObserver = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: false, + retryOnMount: false, + }) + const unsubscribeSecond = secondObserver.subscribe(vi.fn()) + + await vi.advanceTimersByTimeAsync(0) + + // queryFn should still have been called only once (no refetch) + expect(queryFn).toHaveBeenCalledTimes(1) + + unsubscribeSecond() + }) + + it('should reject promise when experimental_prefetchInRender is disabled and thenable is pending', async () => { const key = queryKey() const queryClient2 = new QueryClient({ defaultOptions: { @@ -1403,7 +1622,25 @@ describe('queryObserver', () => { queryClient2.clear() }) - test('should not refetchOnMount when set to "always" when staleTime is Static', async () => { + it('should not reject promise when experimental_prefetchInRender is enabled', async () => { + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + + const unsubscribe = observer.subscribe(() => undefined) + const tracked = observer.trackResult(observer.getCurrentResult()) + const promise = tracked.promise + + await vi.advanceTimersByTimeAsync(10) + + await expect(promise).resolves.toBe('data') + + unsubscribe() + }) + + it('should not refetchOnMount when set to "always" when staleTime is Static', async () => { const key = queryKey() const queryFn = vi.fn(() => 'data') queryClient.setQueryData(key, 'initial') @@ -1419,15 +1656,15 @@ describe('queryObserver', () => { unsubscribe() }) - test('should not refetchOnWindowFocus when staleTime is static and query has background error', async () => { + it('should not refetchOnWindowFocus when staleTime is static and query has background error', async () => { const key = queryKey() let callCount = 0 - const queryFn = vi.fn(async () => { + const queryFn = vi.fn(() => { callCount++ if (callCount === 1) { - return 'data' + return Promise.resolve('data') } - throw new Error('background error') + return Promise.reject(new Error('background error')) }) const observer = new QueryObserver(queryClient, { @@ -1458,18 +1695,18 @@ describe('queryObserver', () => { unsubscribe() }) - test('should refetchOnWindowFocus when query has background error and staleTime is not static', async () => { + it('should refetchOnWindowFocus when query has background error and staleTime is not static', async () => { const key = queryKey() let callCount = 0 - const queryFn = vi.fn(async () => { + const queryFn = vi.fn(() => { callCount++ if (callCount === 1) { - return 'data' + return Promise.resolve('data') } if (callCount === 2) { - throw new Error('background error') + return Promise.reject(new Error('background error')) } - return 'new data' + return Promise.resolve('new data') }) const observer = new QueryObserver(queryClient, { @@ -1500,7 +1737,7 @@ describe('queryObserver', () => { unsubscribe() }) - test('should set fetchStatus to idle when _optimisticResults is isRestoring', () => { + it('should set fetchStatus to idle when _optimisticResults is isRestoring', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -1512,7 +1749,7 @@ describe('queryObserver', () => { expect(result.fetchStatus).toBe('idle') }) - test('should return isEnabled depending on enabled being resolved', () => { + it('should return isEnabled depending on enabled being resolved', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -1524,7 +1761,7 @@ describe('queryObserver', () => { expect(result.isEnabled).toBe(false) }) - test('should return isEnabled as true per default', () => { + it('should return isEnabled as true per default', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -1535,7 +1772,7 @@ describe('queryObserver', () => { expect(result.isEnabled).toBe(true) }) - test('should update currentResult when getOptimisticResult is called with changed data', () => { + it('should update currentResult when getOptimisticResult is called with changed data', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -1564,10 +1801,7 @@ describe('queryObserver', () => { describe('StrictMode behavior', () => { it('should deduplicate calls to queryFn', async () => { const key = queryKey() - const queryFn = vi.fn(async () => { - await sleep(50) - return 'data' - }) + const queryFn = vi.fn(() => sleep(50).then(() => 'data')) const observer = new QueryObserver(queryClient, { queryKey: key, queryFn, @@ -1596,10 +1830,9 @@ describe('queryObserver', () => { it('should resolve with data when signal was consumed', async () => { const key = queryKey() - const queryFn = vi.fn(async ({ signal }) => { - await sleep(50) - return 'data' + String(signal) - }) + const queryFn = vi.fn(({ signal }) => + sleep(50).then(() => 'data' + String(signal)), + ) const observer = new QueryObserver(queryClient, { queryKey: key, queryFn, diff --git a/packages/query-core/src/__tests__/streamedQuery.test.tsx b/packages/query-core/src/__tests__/streamedQuery.test.tsx index 903f2c85344..6895609e53b 100644 --- a/packages/query-core/src/__tests__/streamedQuery.test.tsx +++ b/packages/query-core/src/__tests__/streamedQuery.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { streamedQuery } from '../streamedQuery' import { QueryClient, QueryObserver } from '..' @@ -29,7 +29,7 @@ describe('streamedQuery', () => { } } - test('should stream data from an AsyncIterable', async () => { + it('should stream data from an AsyncIterable', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -73,7 +73,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should allow Arrays to be returned from the stream', async () => { + it('should allow Arrays to be returned from the stream', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -128,7 +128,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should handle empty streams', async () => { + it('should handle empty streams', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -157,7 +157,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should replace on refetch', async () => { + it('should replace on refetch', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -211,7 +211,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should support refetchMode append', async () => { + it('should support refetchMode append', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -266,7 +266,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should support refetchMode replace', async () => { + it('should support refetchMode replace', async () => { const key = queryKey() let offset = 0 const observer = new QueryObserver(queryClient, { @@ -324,7 +324,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should abort ongoing stream when refetch happens', async () => { + it('should abort ongoing stream when refetch happens', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -384,7 +384,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should abort when unsubscribed', async () => { + it('should abort when unsubscribed', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -425,7 +425,7 @@ describe('streamedQuery', () => { }) }) - test('should not abort when signal not consumed', async () => { + it('should not abort when signal not consumed', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -461,7 +461,7 @@ describe('streamedQuery', () => { }) }) - test('should support custom reducer', async () => { + it('should support custom reducer', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { @@ -498,7 +498,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should support custom reducer with initialValue', async () => { + it('should support custom reducer with initialValue', async () => { const key = queryKey() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -539,7 +539,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should keep error state on reset refetch when initialData is defined', async () => { + it('should keep error state on reset refetch when initialData is defined', async () => { const key = queryKey() let shouldError = false const error = new Error('stream failed') @@ -550,6 +550,7 @@ describe('streamedQuery', () => { retry: false, queryFn: streamedQuery({ refetchMode: 'reset', + // eslint-disable-next-line @typescript-eslint/require-await streamFn: async function* () { if (shouldError) { throw error @@ -591,7 +592,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should treat a fetch after an initial error as a refetch for reset mode', async () => { + it('should treat a fetch after an initial error as a refetch for reset mode', async () => { const key = queryKey() let shouldError = true const error = new Error('stream failed') @@ -647,7 +648,7 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should reset to initialData on refetch after an initial error', async () => { + it('should reset to initialData on refetch after an initial error', async () => { const key = queryKey() let shouldError = true const error = new Error('stream failed') @@ -704,13 +705,14 @@ describe('streamedQuery', () => { unsubscribe() }) - test('should not call reducer twice when refetchMode is replace', async () => { + it('should not call reducer twice when refetchMode is replace', async () => { const key = queryKey() const arr: Array = [] const observer = new QueryObserver(queryClient, { queryKey: key, queryFn: streamedQuery({ + // eslint-disable-next-line @typescript-eslint/require-await streamFn: async function* () { const v = [1, 2, 3] yield* v diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index 9cee1b46424..9abd1bf265c 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '..' import { + addConsumeAwareSignal, addToEnd, addToStart, + ensureQueryFn, hashKey, hashQueryKeyByOptions, isPlainArray, @@ -13,27 +16,29 @@ import { replaceEqualDeep, shallowEqualObjects, shouldThrowError, + skipToken, } from '../utils' import { Mutation } from '../mutation' +import type { QueryFunctionContext } from '..' describe('core/utils', () => { describe('hashQueryKeyByOptions', () => { it('should use custom hash function when provided in options', () => { - const queryKey = ['test', { a: 1, b: 2 }] + const key = ['test', { a: 1, b: 2 }] const customHashFn = vi.fn(() => 'custom-hash') - const result = hashQueryKeyByOptions(queryKey, { + const result = hashQueryKeyByOptions(key, { queryKeyHashFn: customHashFn, }) - expect(customHashFn).toHaveBeenCalledWith(queryKey) + expect(customHashFn).toHaveBeenCalledWith(key) expect(result).toEqual('custom-hash') }) it('should use default hash function when no options provided', () => { - const queryKey = ['test', { a: 1, b: 2 }] - const defaultResult = hashKey(queryKey) - const result = hashQueryKeyByOptions(queryKey) + const key = ['test', { a: 1, b: 2 }] + const defaultResult = hashKey(key) + const result = hashQueryKeyByOptions(key) expect(result).toEqual(defaultResult) }) @@ -416,11 +421,34 @@ describe('core/utils', () => { expect(next).toBe(current) }) + + it('should stop structural sharing once the recursion depth exceeds the limit', () => { + const nest = (depth: number, leaf: number) => { + let value: any = { leaf } + for (let i = 0; i < depth; i++) { + value = { child: value } + } + return value + } + + const prev = nest(502, 1) + const next = nest(502, 2) + const result = replaceEqualDeep(prev, next) + + let resultNode = result + let nextNode = next + for (let i = 0; i < 502; i++) { + resultNode = resultNode.child + nextNode = nextNode.child + } + + expect(resultNode).toBe(nextNode) + }) }) describe('matchMutation', () => { it('should return false if mutationKey options is undefined', () => { - const filters = { mutationKey: ['key1'] } + const filters = { mutationKey: queryKey() } const queryClient = new QueryClient() const mutation = new Mutation({ client: queryClient, @@ -533,6 +561,55 @@ describe('core/utils', () => { }) }) + describe('ensureQueryFn', () => { + const context = {} as QueryFunctionContext + + it('should return a function that resolves to initialPromise when queryFn is missing and initialPromise is provided', async () => { + const initialPromise = Promise.resolve('initial-data') + + const resolved = ensureQueryFn( + { queryHash: '["key"]' }, + { initialPromise }, + ) + + await expect(resolved(context)).resolves.toBe('initial-data') + }) + + it('should return a function that rejects when initialPromise rejects', async () => { + const error = new Error('initial-promise-error') + const initialPromise = Promise.reject(error) + + const resolved = ensureQueryFn( + { queryHash: '["key"]' }, + { initialPromise }, + ) + + await expect(resolved(context)).rejects.toBe(error) + }) + + it('should return a function that rejects with missing queryFn error when queryFn is set to skipToken', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const resolved = ensureQueryFn({ + queryFn: skipToken, + queryHash: '["skip"]', + }) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Attempted to invoke queryFn when set to skipToken', + ), + ) + await expect(resolved(context)).rejects.toThrow( + 'Missing queryFn: \'["skip"]\'', + ) + + consoleErrorSpy.mockRestore() + }) + }) + describe('shouldThrowError', () => { it('should return the result of executing throwOnError if throwOnError parameter is a function', () => { const throwOnError = (error: Error) => error.message === 'test error' @@ -550,4 +627,66 @@ describe('core/utils', () => { expect(shouldThrowError(undefined, [new Error('test error')])).toBe(false) }) }) + + describe('addConsumeAwareSignal', () => { + it('should expose the signal on the query context while preserving its properties', () => { + const controller = new AbortController() + const key = queryKey() + const context = addConsumeAwareSignal( + { queryKey: key, meta: undefined }, + () => controller.signal, + vi.fn(), + ) + + expect(context.queryKey).toBe(key) + expect(context.signal).toBe(controller.signal) + }) + + it('should call onCancelled immediately when the signal is already aborted on first access', () => { + const controller = new AbortController() + controller.abort() + const onCancelled = vi.fn() + const object = addConsumeAwareSignal( + {}, + () => controller.signal, + onCancelled, + ) + + // Access the signal to consume it + void object.signal + + expect(onCancelled).toHaveBeenCalledTimes(1) + }) + + it('should flag cancellation when the consumed signal aborts, mirroring streamed/infinite queries', () => { + const controller = new AbortController() + let cancelled = false + const context = addConsumeAwareSignal( + { queryKey: queryKey() }, + () => controller.signal, + () => (cancelled = true), + ) + + void context.signal + expect(cancelled).toBe(false) + + controller.abort() + expect(cancelled).toBe(true) + }) + + it('should consume the signal only once across repeated accesses', () => { + const controller = new AbortController() + const addEventListener = vi.spyOn(controller.signal, 'addEventListener') + const context = addConsumeAwareSignal( + { queryKey: queryKey() }, + () => controller.signal, + vi.fn(), + ) + + expect(context.signal).toBe(controller.signal) + expect(context.signal).toBe(controller.signal) + + expect(addEventListener).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index c75d8ee332c..90868dd2623 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -48,6 +48,7 @@ interface DehydratedQuery { state: QueryState promise?: Promise meta?: QueryMeta + queryType?: 'infinite' // This is only optional because older versions of Query might have dehydrated // without it which we need to handle for backwards compatibility. // This should be changed to required in the future. @@ -117,6 +118,7 @@ function dehydrateQuery( promise: dehydratePromise(), }), ...(query.meta && { meta: query.meta }), + ...(query.queryType && { queryType: query.queryType }), } } @@ -209,7 +211,15 @@ export function hydrate( }) queries.forEach( - ({ queryKey, state, queryHash, meta, promise, dehydratedAt }) => { + ({ + queryKey, + state, + queryHash, + meta, + promise, + dehydratedAt, + queryType, + }) => { const syncData = promise ? tryResolveSync(promise) : undefined const rawData = state.data === undefined ? syncData?.data : state.data const data = rawData === undefined ? rawData : deserializeData(rawData) @@ -230,12 +240,24 @@ export function hydrate( state.dataUpdatedAt > query.state.dataUpdatedAt || hasNewerSyncData ) { - // omit fetchStatus from dehydrated state - // so that query stays in its current fetchStatus + // Omit fetchStatus from dehydrated state so that query stays in its current fetchStatus const { fetchStatus: _ignored, ...serializedState } = state query.setState({ ...serializedState, data, + // If the query was pending at the moment of dehydration, but resolved to have data + // before hydration, we can assume the query should be hydrated as successful. + // + // Since you can opt into dehydrating failed queries, and those can have data from + // previous successful fetches, we make sure we only do this for pending queries. + ...(state.status === 'pending' && + data !== undefined && { + status: 'success' as const, + // Preserve existing fetchStatus if the existing query is actively fetching. + ...(!existingQueryIsFetching && { + fetchStatus: 'idle' as const, + }), + }), }) } } else { @@ -248,6 +270,7 @@ export function hydrate( queryKey, queryHash, meta, + _type: queryType, }, // Reset fetch status to idle to avoid // query being stuck in fetching state upon hydration @@ -255,13 +278,21 @@ export function hydrate( ...state, data, fetchStatus: 'idle', - status: data !== undefined ? 'success' : state.status, + // Like above, if the query was pending at the moment of dehydration but has data, + // we can assume it should be hydrated as successful. + status: + state.status === 'pending' && data !== undefined + ? 'success' + : state.status, }, ) } if ( promise && + // If the data was synchronously available, there is no need to set up + // a retryer and thus no reason to call fetch + !syncData && !existingQueryIsPending && !existingQueryIsFetching && // Only hydrate if dehydration is newer than any existing data, @@ -270,8 +301,6 @@ export function hydrate( ) { // This doesn't actually fetch - it just creates a retryer // which will re-use the passed `initialPromise` - // Note that we need to call these even when data was synchronously - // available, as we still need to set up the retryer query .fetch(undefined, { // RSC transformed promises are not thenable diff --git a/packages/query-core/src/infiniteQueryBehavior.ts b/packages/query-core/src/infiniteQueryBehavior.ts index af9c50e5503..2d87db5a5a9 100644 --- a/packages/query-core/src/infiniteQueryBehavior.ts +++ b/packages/query-core/src/infiniteQueryBehavior.ts @@ -44,7 +44,7 @@ export function infiniteQueryBehavior( previous?: boolean, ): Promise> => { if (cancelled) { - return Promise.reject() + return Promise.reject(context.signal.reason) } if (param == null && data.pages.length) { diff --git a/packages/query-core/src/infiniteQueryObserver.ts b/packages/query-core/src/infiniteQueryObserver.ts index 1499b138169..1cdd32a8859 100644 --- a/packages/query-core/src/infiniteQueryObserver.ts +++ b/packages/query-core/src/infiniteQueryObserver.ts @@ -1,9 +1,5 @@ import { QueryObserver } from './queryObserver' -import { - hasNextPage, - hasPreviousPage, - infiniteQueryBehavior, -} from './infiniteQueryBehavior' +import { hasNextPage, hasPreviousPage } from './infiniteQueryBehavior' import type { Subscribable } from './subscribable' import type { DefaultError, @@ -93,10 +89,8 @@ export class InfiniteQueryObserver< TPageParam >, ): void { - super.setOptions({ - ...options, - behavior: infiniteQueryBehavior(), - }) + options._type = 'infinite' + super.setOptions(options) } getOptimisticResult( @@ -108,7 +102,7 @@ export class InfiniteQueryObserver< TPageParam >, ): InfiniteQueryObserverResult { - options.behavior = infiniteQueryBehavior() + options._type = 'infinite' return super.getOptimisticResult(options) as InfiniteQueryObserverResult< TData, TError diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 67dd088f9ae..4fcf8e5d41e 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -249,6 +249,17 @@ export class QueriesObserver< return input as any } + #shouldSkipCombine(): boolean { + return ( + this.#options?.combine !== undefined && + this.#observers.some((observer, index) => { + return ( + observer.options.suspense && this.#result[index]?.data === undefined + ) + }) + ) + } + #findMatchingObservers( queries: Array, ): Array { @@ -294,11 +305,14 @@ export class QueriesObserver< #notify(): void { if (this.hasListeners()) { - const previousResult = this.#combinedResult const newTracked = this.#trackResult(this.#result, this.#observerMatches) - const newResult = this.#combineResult(newTracked, this.#options?.combine) + const shouldSkipCombine = this.#shouldSkipCombine() + const previousResult = this.#combinedResult + const newResult = shouldSkipCombine + ? previousResult + : this.#combineResult(newTracked, this.#options?.combine) - if (previousResult !== newResult) { + if (shouldSkipCombine || previousResult !== newResult) { notifyManager.batch(() => { this.listeners.forEach((listener) => { listener(this.#result) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 7dfaa587721..62bc9a16082 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -2,7 +2,7 @@ import { ensureQueryFn, noop, replaceData, - resolveEnabled, + resolveQueryBoolean, resolveStaleTime, skipToken, timeUntilStale, @@ -10,6 +10,7 @@ import { import { notifyManager } from './notifyManager' import { CancelledError, canFetch, createRetryer } from './retryer' import { Removable } from './removable' +import { infiniteQueryBehavior } from './infiniteQueryBehavior' import type { QueryCache } from './queryCache' import type { QueryClient } from './queryClient' import type { @@ -137,7 +138,6 @@ interface ContinueAction { interface SetStateAction { type: 'setState' state: Partial> - setStateOptions?: SetStateOptions } export type Action = @@ -150,10 +150,6 @@ export type Action = | SetStateAction | SuccessAction -export interface SetStateOptions { - meta?: any -} - // CLASS export class Query< @@ -166,6 +162,7 @@ export class Query< queryHash: string options!: QueryOptions state: QueryState + #queryType?: 'infinite' #initialState: QueryState #revertState?: QueryState @@ -195,6 +192,10 @@ export class Query< return this.options.meta } + get queryType() { + return this.#queryType + } + get promise(): Promise | undefined { return this.#retryer?.promise } @@ -204,6 +205,10 @@ export class Query< ): void { this.options = { ...this.#defaultOptions, ...options } + if (options?._type) { + this.#queryType = options._type + } + this.updateGcTime(this.options.gcTime) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -241,11 +246,8 @@ export class Query< return data } - setState( - state: Partial>, - setStateOptions?: SetStateOptions, - ): void { - this.#dispatch({ type: 'setState', state, setStateOptions }) + setState(state: Partial>): void { + this.#dispatch({ type: 'setState', state }) } cancel(options?: CancelOptions): Promise { @@ -271,7 +273,8 @@ export class Query< isActive(): boolean { return this.observers.some( - (observer) => resolveEnabled(observer.options.enabled, this) !== false, + (observer) => + resolveQueryBoolean(observer.options.enabled, this) !== false, ) } @@ -510,7 +513,13 @@ export class Query< const context = createFetchContext() - this.options.behavior?.onFetch(context, this as unknown as Query) + const behavior = + this.#queryType === 'infinite' + ? (infiniteQueryBehavior( + (this.options as { pages?: number }).pages, + ) as QueryBehavior) + : this.options.behavior + behavior?.onFetch(context, this as unknown as Query) // Store state in case the current fetch needs to be reverted this.#revertState = this.state diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668aa..d82106c7375 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -12,7 +12,6 @@ import { MutationCache } from './mutationCache' import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { notifyManager } from './notifyManager' -import { infiniteQueryBehavior } from './infiniteQueryBehavior' import type { CancelOptions, DefaultError, @@ -30,7 +29,6 @@ import type { MutationKey, MutationObserverOptions, MutationOptions, - NoInfer, OmitKeyof, QueryClientConfig, QueryKey, @@ -395,12 +393,7 @@ export class QueryClient { TPageParam >, ): Promise> { - options.behavior = infiniteQueryBehavior< - TQueryFnData, - TError, - TData, - TPageParam - >(options.pages) + options._type = 'infinite' return this.fetchQuery(options as any) } @@ -437,12 +430,7 @@ export class QueryClient { TPageParam >, ): Promise> { - options.behavior = infiniteQueryBehavior< - TQueryFnData, - TError, - TData, - TPageParam - >(options.pages) + options._type = 'infinite' return this.ensureQueryData(options as any) } diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index a290c700b58..954c969d548 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -8,7 +8,7 @@ import { isValidTimeout, noop, replaceData, - resolveEnabled, + resolveQueryBoolean, resolveStaleTime, shallowEqualObjects, timeUntilStale, @@ -153,7 +153,7 @@ export class QueryObserver< this.options.enabled !== undefined && typeof this.options.enabled !== 'boolean' && typeof this.options.enabled !== 'function' && - typeof resolveEnabled(this.options.enabled, this.#currentQuery) !== + typeof resolveQueryBoolean(this.options.enabled, this.#currentQuery) !== 'boolean' ) { throw new Error( @@ -197,8 +197,8 @@ export class QueryObserver< if ( mounted && (this.#currentQuery !== prevQuery || - resolveEnabled(this.options.enabled, this.#currentQuery) !== - resolveEnabled(prevOptions.enabled, this.#currentQuery) || + resolveQueryBoolean(this.options.enabled, this.#currentQuery) !== + resolveQueryBoolean(prevOptions.enabled, this.#currentQuery) || resolveStaleTime(this.options.staleTime, this.#currentQuery) !== resolveStaleTime(prevOptions.staleTime, this.#currentQuery)) ) { @@ -211,8 +211,8 @@ export class QueryObserver< if ( mounted && (this.#currentQuery !== prevQuery || - resolveEnabled(this.options.enabled, this.#currentQuery) !== - resolveEnabled(prevOptions.enabled, this.#currentQuery) || + resolveQueryBoolean(this.options.enabled, this.#currentQuery) !== + resolveQueryBoolean(prevOptions.enabled, this.#currentQuery) || nextRefetchInterval !== this.#currentRefetchInterval) ) { this.#updateRefetchInterval(nextRefetchInterval) @@ -394,7 +394,7 @@ export class QueryObserver< if ( environmentManager.isServer() || - resolveEnabled(this.options.enabled, this.#currentQuery) === false || + resolveQueryBoolean(this.options.enabled, this.#currentQuery) === false || !isValidTimeout(this.#currentRefetchInterval) || this.#currentRefetchInterval === 0 ) { @@ -417,14 +417,14 @@ export class QueryObserver< } #clearStaleTimeout(): void { - if (this.#staleTimeoutId) { + if (this.#staleTimeoutId !== undefined) { timeoutManager.clearTimeout(this.#staleTimeoutId) this.#staleTimeoutId = undefined } } #clearRefetchInterval(): void { - if (this.#refetchIntervalId) { + if (this.#refetchIntervalId !== undefined) { timeoutManager.clearInterval(this.#refetchIntervalId) this.#refetchIntervalId = undefined } @@ -589,7 +589,7 @@ export class QueryObserver< isStale: isStale(query, options), refetch: this.refetch, promise: this.#currentThenable, - isEnabled: resolveEnabled(options.enabled, query) !== false, + isEnabled: resolveQueryBoolean(options.enabled, query) !== false, } const nextResult = result as QueryObserverResult @@ -750,9 +750,12 @@ function shouldLoadOnMount( options: QueryObserverOptions, ): boolean { return ( - resolveEnabled(options.enabled, query) !== false && + resolveQueryBoolean(options.enabled, query) !== false && query.state.data === undefined && - !(query.state.status === 'error' && options.retryOnMount === false) + !( + query.state.status === 'error' && + resolveQueryBoolean(options.retryOnMount, query) === false + ) ) } @@ -775,7 +778,7 @@ function shouldFetchOn( (typeof options)['refetchOnReconnect'], ) { if ( - resolveEnabled(options.enabled, query) !== false && + resolveQueryBoolean(options.enabled, query) !== false && resolveStaleTime(options.staleTime, query) !== 'static' ) { const value = typeof field === 'function' ? field(query) : field @@ -793,7 +796,7 @@ function shouldFetchOptionally( ): boolean { return ( (query !== prevQuery || - resolveEnabled(prevOptions.enabled, query) === false) && + resolveQueryBoolean(prevOptions.enabled, query) === false) && (!options.suspense || query.state.status !== 'error') && isStale(query, options) ) @@ -804,7 +807,7 @@ function isStale( options: QueryObserverOptions, ): boolean { return ( - resolveEnabled(options.enabled, query) !== false && + resolveQueryBoolean(options.enabled, query) !== false && query.isStaleByTime(resolveStaleTime(options.staleTime, query)) ) } diff --git a/packages/query-core/src/removable.ts b/packages/query-core/src/removable.ts index 68545f74383..62e524219ca 100644 --- a/packages/query-core/src/removable.ts +++ b/packages/query-core/src/removable.ts @@ -30,7 +30,7 @@ export abstract class Removable { } protected clearGcTimeout() { - if (this.#gcTimeout) { + if (this.#gcTimeout !== undefined) { timeoutManager.clearTimeout(this.#gcTimeout) this.#gcTimeout = undefined } diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index 97f0870eea2..6f74050b04c 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -28,9 +28,9 @@ export type TimeoutProvider = readonly clearInterval: (intervalId: TTimerId | undefined) => void } -export const defaultTimeoutProvider: TimeoutProvider< - ReturnType -> = { +type SystemTimerId = ReturnType + +export const defaultTimeoutProvider: TimeoutProvider = { // We need the wrapper function syntax below instead of direct references to // global setTimeout etc. // @@ -42,10 +42,12 @@ export const defaultTimeoutProvider: TimeoutProvider< // have a hard reference to the original implementation at the time when this // file was imported. setTimeout: (callback, delay) => setTimeout(callback, delay), - clearTimeout: (timeoutId) => clearTimeout(timeoutId), + clearTimeout: (timeoutId) => + clearTimeout(timeoutId as SystemTimerId | undefined), setInterval: (callback, delay) => setInterval(callback, delay), - clearInterval: (intervalId) => clearInterval(intervalId), + clearInterval: (intervalId) => + clearInterval(intervalId as SystemTimerId | undefined), } /** @@ -62,7 +64,8 @@ export const defaultTimeoutProvider: TimeoutProvider< export class TimeoutManager implements Omit { // We cannot have TimeoutManager as we must instantiate it with a concrete // type at app boot; and if we leave that type, then any new timer provider - // would need to support ReturnType, which is infeasible. + // would need to support the default provider's concrete timer ID, which is + // infeasible across environments. // // We settle for type safety for the TimeoutProvider type, and accept that // this class is unsafe internally to allow for extension. diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed20..b2e80f2a144 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -34,8 +34,6 @@ export type Override = { : TTargetA[AKey] } -export type NoInfer = [T][T extends any ? 0 : never] - export interface Register { // defaultError: Error // queryMeta: Record @@ -110,7 +108,7 @@ export type StaleTimeFunction< | StaleTime | ((query: Query) => StaleTime) -export type Enabled< +export type QueryBooleanOption< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, @@ -231,7 +229,7 @@ export interface QueryOptions< > { /** * If `false`, failed queries will not retry by default. - * If `true`, failed queries will retry infinitely., failureCount: num + * If `true`, failed queries will retry infinitely. * If set to an integer number, e.g. 3, failed queries will retry until the failed query count meets that number. * If set to a function `(failureCount, error) => boolean` failed queries will retry until the function returns false. */ @@ -246,11 +244,7 @@ export interface QueryOptions< */ gcTime?: number queryFn?: QueryFunction | SkipToken - persister?: QueryPersister< - NoInfer, - NoInfer, - NoInfer - > + persister?: QueryPersister, TPageParam> queryHash?: string queryKey?: TQueryKey queryKeyHashFn?: QueryKeyHashFunction @@ -266,6 +260,7 @@ export interface QueryOptions< | boolean | ((oldData: unknown | undefined, newData: unknown) => unknown) _defaulted?: boolean + _type?: 'infinite' /** * Additional payload to be stored on each query. * Use this property to pass information that can be used in other places. @@ -326,7 +321,7 @@ export interface QueryObserverOptions< * Accepts a boolean or function that returns a boolean. * Defaults to `true`. */ - enabled?: Enabled + enabled?: QueryBooleanOption /** * The time in milliseconds after data is considered stale. * If set to `Infinity`, the data will never be considered stale. @@ -368,7 +363,7 @@ export interface QueryObserverOptions< * If set to `false`, the query will not refetch on reconnect. * If set to `'always'`, the query will always refetch on reconnect. * If set to a function, the function will be executed with the latest data and query to compute the value. - * Defaults to the value of `networkOnline` (`true`) + * Defaults to `true` unless `networkMode` is `'always'`. */ refetchOnReconnect?: | boolean @@ -391,9 +386,10 @@ export interface QueryObserverOptions< ) => boolean | 'always') /** * If set to `false`, the query will not be retried on mount if it contains an error. + * If set to a function, the function will be executed with the query to compute the value. * Defaults to `true`. */ - retryOnMount?: boolean + retryOnMount?: QueryBooleanOption /** * If set, the component will only re-render if any of the listed properties change. * When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change. diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index b29e8ded456..b97b2cc5a33 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,10 +1,10 @@ import { timeoutManager } from './timeoutManager' import type { DefaultError, - Enabled, FetchStatus, MutationKey, MutationStatus, + QueryBooleanOption, QueryFunction, QueryKey, QueryOptions, @@ -126,16 +126,18 @@ export function resolveStaleTime< return typeof staleTime === 'function' ? staleTime(query) : staleTime } -export function resolveEnabled< +export function resolveQueryBoolean< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - enabled: undefined | Enabled, + option: + | undefined + | QueryBooleanOption, query: Query, ): boolean | undefined { - return typeof enabled === 'function' ? enabled(query) : enabled + return typeof option === 'function' ? option(query) : option } export function matchQuery( diff --git a/packages/query-core/tsconfig.json b/packages/query-core/tsconfig.json index bcd89cd0c8e..0cc454b2a77 100644 --- a/packages/query-core/tsconfig.json +++ b/packages/query-core/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "./dist-ts", "rootDir": "." }, - "include": ["src", "*.config.*", "package.json"] + "include": ["src", "*.config.ts", "*.config.js", "package.json"] } diff --git a/packages/query-core/tsconfig.prod.json b/packages/query-core/tsconfig.prod.json index 0f4c92da065..2bb29fdf02a 100644 --- a/packages/query-core/tsconfig.prod.json +++ b/packages/query-core/tsconfig.prod.json @@ -4,5 +4,7 @@ "incremental": false, "composite": false, "rootDir": "../../" - } + }, + "include": ["src"], + "exclude": ["src/__tests__"] } diff --git a/packages/query-core/vite.config.ts b/packages/query-core/vite.config.ts index a905035c6bd..750a402930c 100644 --- a/packages/query-core/vite.config.ts +++ b/packages/query-core/vite.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ watch: false, environment: 'jsdom', coverage: { - enabled: true, + enabled: !!process.env.CI, provider: 'istanbul', include: ['src/**/*'], exclude: ['src/__tests__/**'], diff --git a/packages/query-devtools/CHANGELOG.md b/packages/query-devtools/CHANGELOG.md index a8a6a9ee995..b6672a1f24a 100644 --- a/packages/query-devtools/CHANGELOG.md +++ b/packages/query-devtools/CHANGELOG.md @@ -1,5 +1,89 @@ # @tanstack/query-devtools +## 5.101.0 + +### Patch Changes + +- [#10772](https://github.com/TanStack/query/pull/10772) [`3042860`](https://github.com/TanStack/query/commit/3042860e3c8731c94ca4dec0e277e415d0484fce) - Avoid crashing devtools query rows when a cached query state is temporarily unavailable. + +- [#10750](https://github.com/TanStack/query/pull/10750) [`e631dc3`](https://github.com/TanStack/query/commit/e631dc3fa17bff71f413246b7a770a730016d346) - Resolve devtools query rows from their stable query hash so mutated object query keys do not break row rendering. + +## 5.100.14 + +## 5.100.13 + +## 5.100.12 + +## 5.100.11 + +## 5.100.10 + +### Patch Changes + +- fix(query-devtools): remove experimentalDts to prevent solid-js type leak ([#10694](https://github.com/TanStack/query/pull/10694)) + +## 5.100.9 + +### Patch Changes + +- Update the devtools panel `setOnClose` callback type to return `void`. ([#10607](https://github.com/TanStack/query/pull/10607)) + +## 5.100.8 + +## 5.100.7 + +## 5.100.6 + +## 5.100.5 + +## 5.100.4 + +### Patch Changes + +- fix(devtools): change onClose callback type from () => unknown to () => void ([#10118](https://github.com/TanStack/query/pull/10118)) + +## 5.100.3 + +## 5.100.2 + +## 5.100.1 + +## 5.100.0 + +## 5.99.2 + +## 5.99.1 + +## 5.99.0 + +## 5.98.0 + +## 5.97.0 + +## 5.96.2 + +## 5.96.1 + +## 5.96.0 + +## 5.95.2 + +## 5.95.1 + +## 5.95.0 + +## 5.94.5 + +### Patch Changes + +- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312)) + +## 5.94.4 + +### Patch Changes + +- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064)) + ## 5.93.0 ### Minor Changes diff --git a/packages/query-devtools/package.json b/packages/query-devtools/package.json index 16393ed1c45..f1e4ae25800 100644 --- a/packages/query-devtools/package.json +++ b/packages/query-devtools/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-devtools", - "version": "5.93.0", + "version": "5.101.0", "description": "Developer tools to interact with and visualize the TanStack Query cache", "author": "tannerlinsley", "license": "MIT", @@ -67,6 +67,7 @@ "@solid-primitives/keyed": "^1.2.2", "@solid-primitives/resize-observer": "^2.0.26", "@solid-primitives/storage": "^1.3.11", + "@solidjs/testing-library": "^0.8.10", "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/query-core": "workspace:*", "clsx": "^2.1.1", diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index ed8eed4534e..dfb66e496a7 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -89,7 +89,7 @@ interface ContentViewProps { localStore: StorageObject setLocalStore: StorageSetter showPanelViewOnly?: boolean - onClose?: () => unknown + onClose?: () => void } interface QueryStatusProps { @@ -1384,61 +1384,41 @@ const QueryRow: Component<{ query: Query }> = (props) => { const t = (light: string, dark: string) => (theme() === 'dark' ? dark : light) const queryState = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache().find({ - queryKey: props.query.queryKey, - })?.state, + (queryCache) => queryCache().get(props.query.queryHash)?.state, true, (e) => e.query.queryHash === props.query.queryHash, ) const isDisabled = createSubscribeToQueryCacheBatcher( (queryCache) => - queryCache() - .find({ - queryKey: props.query.queryKey, - }) - ?.isDisabled() ?? false, + queryCache().get(props.query.queryHash)?.isDisabled() ?? false, true, (e) => e.query.queryHash === props.query.queryHash, ) const isStatic = createSubscribeToQueryCacheBatcher( (queryCache) => - queryCache() - .find({ - queryKey: props.query.queryKey, - }) - ?.isStatic() ?? false, + queryCache().get(props.query.queryHash)?.isStatic() ?? false, true, (e) => e.query.queryHash === props.query.queryHash, ) const isStale = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .find({ - queryKey: props.query.queryKey, - }) - ?.isStale() ?? false, + (queryCache) => queryCache().get(props.query.queryHash)?.isStale() ?? false, true, (e) => e.query.queryHash === props.query.queryHash, ) const observers = createSubscribeToQueryCacheBatcher( (queryCache) => - queryCache() - .find({ - queryKey: props.query.queryKey, - }) - ?.getObserversCount() ?? 0, + queryCache().get(props.query.queryHash)?.getObserversCount() ?? 0, true, (e) => e.query.queryHash === props.query.queryHash, ) const color = createMemo(() => getQueryStatusColor({ - queryState: queryState()!, + queryState: queryState(), observerCount: observers(), isStale: isStale(), }), @@ -2098,7 +2078,10 @@ const QueryDetails = () => { type: 'INVALIDATE', queryHash: activeQuery()?.queryHash, }) - queryClient.invalidateQueries(activeQuery()) + queryClient.invalidateQueries({ + queryKey: activeQuery()?.queryKey, + exact: true, + }) }} disabled={queryStatus() === 'pending'} > @@ -2123,7 +2106,10 @@ const QueryDetails = () => { type: 'RESET', queryHash: activeQuery()?.queryHash, }) - queryClient.resetQueries(activeQuery()) + queryClient.resetQueries({ + queryKey: activeQuery()?.queryKey, + exact: true, + }) }} disabled={queryStatus() === 'pending'} > @@ -2148,7 +2134,10 @@ const QueryDetails = () => { type: 'REMOVE', queryHash: activeQuery()?.queryHash, }) - queryClient.removeQueries(activeQuery()) + queryClient.removeQueries({ + queryKey: activeQuery()?.queryKey, + exact: true, + }) setSelectedQueryHash(null) }} disabled={statusLabel() === 'fetching'} @@ -2228,7 +2217,9 @@ const QueryDetails = () => { type: 'RESTORE_ERROR', queryHash: activeQuery()?.queryHash, }) - queryClient.resetQueries(activeQuery()) + queryClient.resetQueries({ + queryKey: activeQuery()?.queryKey, + }) } }} disabled={queryStatus() === 'pending'} diff --git a/packages/query-devtools/src/TanstackQueryDevtoolsPanel.tsx b/packages/query-devtools/src/TanstackQueryDevtoolsPanel.tsx index 699a64dbc15..42ee5631961 100644 --- a/packages/query-devtools/src/TanstackQueryDevtoolsPanel.tsx +++ b/packages/query-devtools/src/TanstackQueryDevtoolsPanel.tsx @@ -18,7 +18,7 @@ import type { Signal } from 'solid-js' export interface TanstackQueryDevtoolsPanelConfig extends QueryDevtoolsProps { styleNonce?: string shadowDOMTarget?: ShadowRoot - onClose?: () => unknown + onClose?: () => void } class TanstackQueryDevtoolsPanel { @@ -34,7 +34,7 @@ class TanstackQueryDevtoolsPanel { #initialIsOpen: Signal #errorTypes: Signal | undefined> #hideDisabledQueries: Signal - #onClose: Signal<(() => unknown) | undefined> + #onClose: Signal<(() => void) | undefined> #Component: DevtoolsComponentType | undefined #theme: Signal #dispose?: () => void @@ -90,7 +90,7 @@ class TanstackQueryDevtoolsPanel { this.#client[1](client) } - setOnClose(onClose: () => unknown) { + setOnClose(onClose: () => void) { this.#onClose[1](() => onClose) } diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx new file mode 100644 index 00000000000..398bf61e316 --- /dev/null +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -0,0 +1,1479 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryObserver, onlineManager } from '@tanstack/query-core' +import { fireEvent, render } from '@solidjs/testing-library' +import { createLocalStorage } from '@solid-primitives/storage' +import { Devtools } from '../Devtools' +import { PiPProvider, QueryDevtoolsContext, ThemeContext } from '../contexts' +import type { QueryDevtoolsProps } from '../contexts' + +// `solid-transition-group` internally imports from +// `@solid-primitives/transition-group`, whose `exports` field points at +// `src/index.ts` (not published) under a `@solid-primitives/source` condition +// that Vite can't fall through, so we stub it with a transparent pass-through. +vi.mock('solid-transition-group', () => ({ + TransitionGroup: (props: { children: unknown }) => props.children, +})) + +// `goober` compiles every `css\`...\`` template literal at mount time +// (template parsing + class hashing + style serialization), which +// dominates mount cost and produces no value for label/role-based +// assertions, so we replace it with a no-op factory. +vi.mock('goober', () => { + let counter = 0 + const css = Object.assign(() => `tsqd-${++counter}`, { + bind: () => css, + }) + return { css, glob: () => {}, setup: () => {} } +}) + +describe('Devtools', () => { + const storage: { [key: string]: string } = {} + let queryClient: QueryClient + let previousRootFontSize = '' + + beforeEach(() => { + vi.useFakeTimers() + previousRootFontSize = document.documentElement.style.fontSize + vi.stubGlobal('localStorage', { + getItem: (key: string) => + Object.prototype.hasOwnProperty.call(storage, key) + ? storage[key] + : null, + setItem: (key: string, value: string) => { + storage[key] = value + }, + removeItem: (key: string) => { + delete storage[key] + }, + clear: () => { + Object.keys(storage).forEach((key) => delete storage[key]) + }, + }) + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ) + vi.stubGlobal( + 'ResizeObserver', + class { + callback: ResizeObserverCallback + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe = vi.fn((target: Element) => { + this.callback( + [ + { + target, + contentRect: { width: 1000, height: 500 } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) + }) + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + queryClient = new QueryClient() + document.documentElement.style.fontSize = '16px' + }) + + afterEach(() => { + vi.unstubAllGlobals() + Object.keys(storage).forEach((key) => delete storage[key]) + queryClient.clear() + onlineManager.setOnline(true) + document.documentElement.style.fontSize = previousRootFontSize + vi.useRealTimers() + }) + + function renderDevtools( + overrides: Partial = {}, + initialStorage: Record = {}, + ) { + Object.entries(initialStorage).forEach(([key, value]) => { + localStorage.setItem(key, value) + }) + return render(() => { + const [localStore, setLocalStore] = createLocalStorage({ + prefix: 'TanstackQueryDevtools', + }) + return ( + + + 'dark'}> + + + + + ) + }) + } + + describe('initial state', () => { + it('should render the open devtools button', () => { + const rendered = renderDevtools() + + expect( + rendered.getByLabelText('Open Tanstack query devtools'), + ).toBeInTheDocument() + }) + + it('should not render the panel by default', () => { + const rendered = renderDevtools() + + expect( + rendered.queryByLabelText('Tanstack query devtools'), + ).not.toBeInTheDocument() + }) + + it('should render the panel when "initialIsOpen" is "true"', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + expect( + rendered.getByLabelText('Tanstack query devtools'), + ).toBeInTheDocument() + }) + + it('should render the panel when "localStore.open" is "true"', () => { + const rendered = renderDevtools( + {}, + { 'TanstackQueryDevtools.open': 'true' }, + ) + + expect( + rendered.getByLabelText('Tanstack query devtools'), + ).toBeInTheDocument() + }) + + it('should not render the panel when "localStore.open" is "false" even if "initialIsOpen" is "true"', () => { + const rendered = renderDevtools( + { initialIsOpen: true }, + { 'TanstackQueryDevtools.open': 'false' }, + ) + + expect( + rendered.queryByLabelText('Tanstack query devtools'), + ).not.toBeInTheDocument() + }) + }) + + describe('open and close', () => { + it('should render the panel when the open button is clicked', () => { + const rendered = renderDevtools() + + fireEvent.click(rendered.getByLabelText('Open Tanstack query devtools')) + + expect( + rendered.getByLabelText('Tanstack query devtools'), + ).toBeInTheDocument() + }) + + it('should hide the open button when the panel is open', () => { + const rendered = renderDevtools() + + fireEvent.click(rendered.getByLabelText('Open Tanstack query devtools')) + + expect( + rendered.queryByLabelText('Open Tanstack query devtools'), + ).not.toBeInTheDocument() + }) + + it('should hide the panel when the close button is clicked', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Close tanstack query devtools')) + + expect( + rendered.queryByLabelText('Tanstack query devtools'), + ).not.toBeInTheDocument() + }) + + it('should render the open button after the panel is closed', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Close tanstack query devtools')) + + expect( + rendered.getByLabelText('Open Tanstack query devtools'), + ).toBeInTheDocument() + }) + + it('should persist "open" as "true" to "localStorage" when the open button is clicked', () => { + const rendered = renderDevtools() + + fireEvent.click(rendered.getByLabelText('Open Tanstack query devtools')) + + expect(localStorage.getItem('TanstackQueryDevtools.open')).toBe('true') + }) + + it('should persist "open" as "false" to "localStorage" when the close button is clicked', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Close tanstack query devtools')) + + expect(localStorage.getItem('TanstackQueryDevtools.open')).toBe('false') + }) + }) + + describe('query list', () => { + it('should render a row for each query in the cache', () => { + queryClient.setQueryData(['posts'], [{ id: 1 }]) + queryClient.setQueryData(['users', 'me'], { id: 'u1' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect( + rendered.getByLabelText(/Query key \["posts"\]/), + ).toBeInTheDocument() + expect( + rendered.getByLabelText(/Query key \["users","me"\]/), + ).toBeInTheDocument() + }) + + it('should reflect a newly added query reactively', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + expect( + rendered.queryByLabelText(/Query key \["new"\]/), + ).not.toBeInTheDocument() + + queryClient.setQueryData(['new'], 'hello') + + expect(rendered.getByLabelText(/Query key \["new"\]/)).toBeInTheDocument() + }) + + it('should filter queries by "queryHash"', () => { + queryClient.setQueryData(['posts'], []) + queryClient.setQueryData(['users'], []) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.input(rendered.getByLabelText('Filter queries by query key'), { + target: { value: 'posts' }, + }) + + expect( + rendered.getByLabelText(/Query key \["posts"\]/), + ).toBeInTheDocument() + expect( + rendered.queryByLabelText(/Query key \["users"\]/), + ).not.toBeInTheDocument() + }) + + it('should clear all queries when the clear cache button is clicked', () => { + queryClient.setQueryData(['posts'], []) + queryClient.setQueryData(['users'], []) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Clear query cache')) + + expect( + rendered.queryByLabelText(/Query key \["posts"\]/), + ).not.toBeInTheDocument() + expect( + rendered.queryByLabelText(/Query key \["users"\]/), + ).not.toBeInTheDocument() + }) + + it('should dispatch a "CLEAR_MUTATION_CACHE" event when clear cache is clicked in mutations view', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + fireEvent.click(rendered.getByText('Mutations')) + + const listener = vi.fn() + window.addEventListener('@tanstack/query-devtools-event', listener) + + try { + fireEvent.click(rendered.getByLabelText('Clear query cache')) + + const dispatched = listener.mock.calls.some( + ([e]) => (e as CustomEvent).detail.type === 'CLEAR_MUTATION_CACHE', + ) + expect(dispatched).toBe(true) + } finally { + window.removeEventListener('@tanstack/query-devtools-event', listener) + } + }) + }) + + describe('view toggle', () => { + it('should switch to mutations view when the mutations toggle is clicked', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByText('Mutations')) + + expect( + rendered.container.querySelector('.tsqd-mutations-container'), + ).not.toBeNull() + }) + + it('should render mutations in the mutations view', async () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByText('Mutations')) + + const mutation = queryClient.getMutationCache().build(queryClient, { + mutationKey: ['add-post'], + mutationFn: () => Promise.resolve('ok'), + }) + mutation.execute({}) + await vi.advanceTimersByTimeAsync(0) + + expect( + rendered.getByLabelText(/Mutation submitted at/), + ).toBeInTheDocument() + }) + + it('should render an idle mutation that has been built but not executed', async () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByText('Mutations')) + + queryClient.getMutationCache().build(queryClient, { + mutationKey: ['idle-mut'], + mutationFn: () => Promise.resolve('ok'), + }) + await vi.advanceTimersByTimeAsync(0) + + expect( + rendered.getByLabelText(/Mutation submitted at/), + ).toBeInTheDocument() + }) + }) + + describe('disabled and static queries', () => { + it('should mark a disabled query in the row label', () => { + const observer = queryClient.getQueryCache().build(queryClient, { + queryKey: ['disabled-q'], + queryFn: () => 'x', + }) + observer.setOptions({ + ...observer.options, + enabled: false, + } as typeof observer.options) + observer.setState({ ...observer.state, data: 'x' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByLabelText(/disabled/)).toBeInTheDocument() + }) + + it('should render a "static" indicator for a query with "staleTime: \'static\'"', () => { + const query = queryClient.getQueryCache().build(queryClient, { + queryKey: ['static-q'], + queryFn: () => 'x', + }) + const observer = new QueryObserver(queryClient, { + queryKey: ['static-q'], + queryFn: () => 'x', + staleTime: 'static', + }) + query.addObserver(observer) + query.setState({ ...query.state, data: 'x' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByText('static')).toBeInTheDocument() + expect(rendered.getByLabelText(/, static/)).toBeInTheDocument() + }) + + it('should render a query row when an object query key is mutated in place', () => { + const filters = { page: 1 } + queryClient.setQueryData(['mutable-key', filters], 'x') + filters.page = 2 + + const rendered = renderDevtools({ initialIsOpen: true }) + + expect( + rendered.getByLabelText(/Query key \["mutable-key",\{"page":1\}\]/), + ).toBeInTheDocument() + }) + }) + + describe('picture-in-picture', () => { + type FakePipWindow = Pick< + Window, + | 'document' + | 'innerWidth' + | 'innerHeight' + | 'addEventListener' + | 'removeEventListener' + | 'close' + > + + function stubPipWindow( + overrides: Partial< + Pick + > = {}, + ) { + const pipDocument = document.implementation.createHTMLDocument('PiP') + const listeners = new Map() + const fakeWindow: FakePipWindow = { + document: pipDocument, + innerWidth: 800, + innerHeight: 600, + addEventListener: vi.fn((event: string, handler: EventListener) => { + listeners.set(event, handler) + }), + removeEventListener: vi.fn((event: string) => { + listeners.delete(event) + }), + close: vi.fn(), + ...overrides, + } + const open = vi.fn(() => fakeWindow) + vi.stubGlobal('open', open) + return { + pipDocument, + fakeWindow, + open, + fire: (event: string) => { + listeners.get(event)?.(new Event(event)) + }, + } + } + + it('should open a PiP window when the picture-in-picture button is clicked', () => { + const { open } = stubPipWindow() + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click( + rendered.getByLabelText('Open in picture-in-picture mode'), + ) + + expect(open).toHaveBeenCalledWith( + '', + 'TSQD-Devtools-Panel', + expect.stringMatching(/^width=\d+,height=\d+,popup$/), + ) + expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe( + 'true', + ) + }) + + it('should hide the in-page panel while a PiP window is open', () => { + stubPipWindow() + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click( + rendered.getByLabelText('Open in picture-in-picture mode'), + ) + + expect( + rendered.container.querySelector('.tsqd-main-panel-container'), + ).toBeNull() + }) + + it('should restore the in-page panel when the PiP window is closed', () => { + const { fire } = stubPipWindow() + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click( + rendered.getByLabelText('Open in picture-in-picture mode'), + ) + fire('pagehide') + + expect( + rendered.getByLabelText('Open in picture-in-picture mode'), + ).toBeInTheDocument() + }) + + it('should render the PiP panel with the narrow layout when the PiP window is below the second breakpoint', () => { + // secondBreakpoint = 796; pick a width comfortably below it so the + // PiP panel evaluates its narrow (`flex-direction: column`) branch. + const { pipDocument } = stubPipWindow({ innerWidth: 500 }) + queryClient.setQueryData(['narrow-pip-query'], { hello: 'world' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click( + rendered.getByLabelText('Open in picture-in-picture mode'), + ) + + expect( + pipDocument.querySelector( + '[aria-label="Close Tanstack query devtools"]', + ), + ).not.toBeNull() + expect( + pipDocument.querySelector('[aria-label*="narrow-pip-query"]'), + ).not.toBeNull() + }) + }) + + describe('status counts', () => { + it('should render status count badges', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByLabelText(/Fresh: \d+/)).toBeInTheDocument() + expect(rendered.getByLabelText(/Stale: \d+/)).toBeInTheDocument() + expect(rendered.getByLabelText(/Fetching: \d+/)).toBeInTheDocument() + expect(rendered.getByLabelText(/Paused: \d+/)).toBeInTheDocument() + expect(rendered.getByLabelText(/Inactive: \d+/)).toBeInTheDocument() + }) + + it('should reflect the inactive count when a query is added without observers', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByLabelText('Inactive: 0')).toBeInTheDocument() + + queryClient.setQueryData(['posts'], [{ id: 1 }]) + + expect(rendered.getByLabelText('Inactive: 1')).toBeInTheDocument() + }) + }) + + describe('status tooltip', () => { + it('should show the tooltip on mouse enter and hide it on mouse leave when the panel is narrow', () => { + // Re-stub ResizeObserver with a narrow width (< secondBreakpoint = 796) + // so `showLabel()` is false and the tooltip is rendered conditionally on + // hover/focus. + vi.stubGlobal( + 'ResizeObserver', + class { + callback: ResizeObserverCallback + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe = vi.fn((target: Element) => { + this.callback( + [ + { + target, + contentRect: { width: 500, height: 500 } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) + }) + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + + const rendered = renderDevtools({ initialIsOpen: true }) + const fresh = rendered.getByLabelText('Fresh: 0') + + expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument() + + fireEvent.mouseEnter(fresh) + expect(rendered.getByRole('tooltip')).toBeInTheDocument() + + fireEvent.mouseLeave(fresh) + expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument() + }) + + it('should show the tooltip on focus and hide it on blur when the panel is narrow', () => { + vi.stubGlobal( + 'ResizeObserver', + class { + callback: ResizeObserverCallback + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe = vi.fn((target: Element) => { + this.callback( + [ + { + target, + contentRect: { width: 500, height: 500 } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) + }) + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + + const rendered = renderDevtools({ initialIsOpen: true }) + const fresh = rendered.getByLabelText('Fresh: 0') + + expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument() + + fireEvent.focus(fresh) + expect(rendered.getByRole('tooltip')).toBeInTheDocument() + + fireEvent.blur(fresh) + expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument() + }) + }) + + describe('query details', () => { + it('should open the query details panel when a query row is clicked', () => { + queryClient.setQueryData(['posts'], [{ id: 1 }]) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["posts"\]/)) + + expect(rendered.getByText('Query Details')).toBeInTheDocument() + }) + + it('should close the query details panel when the same row is clicked again', () => { + queryClient.setQueryData(['details-toggle'], [{ id: 1 }]) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["details-toggle"\]/)) + fireEvent.click(rendered.getByLabelText(/Query key \["details-toggle"\]/)) + + expect(rendered.queryByText('Query Details')).not.toBeInTheDocument() + }) + }) + + describe('query actions', () => { + it('should remove the query when the "Remove" button is clicked', () => { + queryClient.setQueryData(['action-remove'], [{ id: 1 }]) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["action-remove"\]/)) + fireEvent.click(rendered.getByText('Remove')) + + expect( + rendered.queryByLabelText(/Query key \["action-remove"\]/), + ).not.toBeInTheDocument() + }) + + it('should reset the query when the "Reset" button is clicked', () => { + queryClient.setQueryData(['action-reset'], [{ id: 1 }]) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["action-reset"\]/)) + fireEvent.click(rendered.getByText('Reset')) + + expect(queryClient.getQueryData(['action-reset'])).toBeUndefined() + }) + + it('should invalidate the query when the "Invalidate" button is clicked', () => { + queryClient.setQueryData(['action-invalidate'], [{ id: 1 }]) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click( + rendered.getByLabelText(/Query key \["action-invalidate"\]/), + ) + fireEvent.click(rendered.getByText('Invalidate')) + + expect( + queryClient.getQueryState(['action-invalidate'])?.isInvalidated, + ).toBe(true) + }) + + it('should dispatch a "REFETCH" event when "Refetch" is clicked', () => { + queryClient.setQueryData(['action-refetch'], [{ id: 1 }]) + const rendered = renderDevtools({ initialIsOpen: true }) + + const listener = vi.fn() + window.addEventListener('@tanstack/query-devtools-event', listener) + + try { + fireEvent.click( + rendered.getByLabelText(/Query key \["action-refetch"\]/), + ) + fireEvent.click(rendered.getByText('Refetch')) + + const dispatched = listener.mock.calls.some( + ([e]) => (e as CustomEvent).detail.type === 'REFETCH', + ) + expect(dispatched).toBe(true) + } finally { + window.removeEventListener('@tanstack/query-devtools-event', listener) + } + }) + + it('should set the query status to "error" when "Trigger Error" is clicked', () => { + queryClient.setQueryData(['action-error'], [{ id: 1 }]) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["action-error"\]/)) + fireEvent.click(rendered.getByText('Trigger Error')) + + expect(queryClient.getQueryState(['action-error'])?.status).toBe('error') + }) + + it('should restore the query status when "Restore Error" is clicked after "Trigger Error"', () => { + queryClient.setQueryData(['action-restore-error'], [{ id: 1 }]) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click( + rendered.getByLabelText(/Query key \["action-restore-error"\]/), + ) + fireEvent.click(rendered.getByText('Trigger Error')) + fireEvent.click(rendered.getByText('Restore Error')) + + expect(queryClient.getQueryState(['action-restore-error'])?.status).toBe( + 'pending', + ) + }) + + it('should restore the previous query options when "Restore Loading" is clicked after "Trigger Loading"', async () => { + const queryFn = vi.fn(() => Promise.resolve('original')) + queryClient.prefetchQuery({ + queryKey: ['action-restore-loading'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(1) + + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click( + rendered.getByLabelText(/Query key \["action-restore-loading"\]/), + ) + + // First click puts the query into a pending state with `data: undefined` + // and stashes the original options in `fetchMeta.__previousQueryOptions`. + fireEvent.click(rendered.getByText('Trigger Loading')) + expect( + queryClient.getQueryState(['action-restore-loading'])?.status, + ).toBe('pending') + + // Second click runs `restoreQueryAfterLoadingOrError`, which cancels the + // never-resolving fetch and refetches with the stashed options. + fireEvent.click(rendered.getByText('Restore Loading')) + await vi.advanceTimersByTimeAsync(0) + + expect(queryFn).toHaveBeenCalledTimes(2) + expect(queryClient.getQueryData(['action-restore-loading'])).toBe( + 'original', + ) + }) + }) + + describe('mutation details', () => { + it('should open the mutation details panel when a mutation row is clicked', async () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByText('Mutations')) + + const mutation = queryClient.getMutationCache().build(queryClient, { + mutationKey: ['mutation-detail'], + mutationFn: () => Promise.resolve('ok'), + }) + mutation.execute({}) + await vi.advanceTimersByTimeAsync(0) + + fireEvent.click(rendered.getByLabelText(/Mutation submitted at/)) + + expect(rendered.getByText('Mutation Details')).toBeInTheDocument() + }) + }) + + describe('mutation sort order', () => { + it('should toggle the mutation sort order in the mutations view', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + fireEvent.click(rendered.getByText('Mutations')) + + fireEvent.click(rendered.getByLabelText(/Sort order/)) + const afterFirstToggle = localStorage.getItem( + 'TanstackQueryDevtools.mutationSortOrder', + ) + expect(afterFirstToggle).not.toBeNull() + + fireEvent.click(rendered.getByLabelText(/Sort order/)) + const afterSecondToggle = localStorage.getItem( + 'TanstackQueryDevtools.mutationSortOrder', + ) + expect(afterSecondToggle).not.toBe(afterFirstToggle) + }) + }) + + describe('mutation filter', () => { + it('should filter mutations by their "mutationKey"', async () => { + const rendered = renderDevtools({ initialIsOpen: true }) + fireEvent.click(rendered.getByText('Mutations')) + + const matching = queryClient.getMutationCache().build(queryClient, { + mutationKey: ['filter-match'], + mutationFn: () => Promise.resolve('ok'), + }) + const other = queryClient.getMutationCache().build(queryClient, { + mutationKey: ['filter-other'], + mutationFn: () => Promise.resolve('ok'), + }) + matching.execute({}) + other.execute({}) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getAllByLabelText(/Mutation submitted at/)).toHaveLength( + 2, + ) + + fireEvent.input(rendered.getByLabelText('Filter queries by query key'), { + target: { value: 'filter-match' }, + }) + + expect(rendered.getAllByLabelText(/Mutation submitted at/)).toHaveLength( + 1, + ) + }) + }) + + describe('data edit', () => { + it('should switch to data editor when "Bulk Edit Data" is clicked', () => { + queryClient.setQueryData(['edit-data'], { name: 'a' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["edit-data"\]/)) + fireEvent.click(rendered.getByLabelText('Bulk Edit Data')) + + expect( + rendered.getByLabelText('Edit query data as JSON'), + ).toBeInTheDocument() + }) + + it('should save the edited data when the form is submitted', () => { + queryClient.setQueryData(['edit-save'], { name: 'a' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["edit-save"\]/)) + fireEvent.click(rendered.getByLabelText('Bulk Edit Data')) + + const textarea = rendered.getByLabelText('Edit query data as JSON') + fireEvent.input(textarea, { + target: { value: JSON.stringify({ name: 'b' }) }, + }) + fireEvent.submit(textarea.closest('form')!) + + expect(queryClient.getQueryData(['edit-save'])).toEqual({ name: 'b' }) + }) + + it('should set an error state when the edited data is invalid JSON', () => { + queryClient.setQueryData(['edit-invalid'], { name: 'a' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["edit-invalid"\]/)) + fireEvent.click(rendered.getByLabelText('Bulk Edit Data')) + + const textarea = rendered.getByLabelText('Edit query data as JSON') + fireEvent.input(textarea, { target: { value: 'not json' } }) + fireEvent.submit(textarea.closest('form')!) + + expect(rendered.getByText('Invalid Value')).toBeInTheDocument() + }) + + it('should clear the error state when the textarea is focused after a submit failure', () => { + queryClient.setQueryData(['edit-refocus'], { name: 'a' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["edit-refocus"\]/)) + fireEvent.click(rendered.getByLabelText('Bulk Edit Data')) + + const textarea = rendered.getByLabelText('Edit query data as JSON') + fireEvent.input(textarea, { target: { value: 'not json' } }) + fireEvent.submit(textarea.closest('form')!) + + expect(rendered.getByText('Invalid Value')).toBeInTheDocument() + + fireEvent.focus(textarea) + + expect(rendered.queryByText('Invalid Value')).toBeNull() + }) + + it('should return to the data view when the editor "Cancel" button is clicked', () => { + queryClient.setQueryData(['edit-cancel'], { name: 'a' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["edit-cancel"\]/)) + fireEvent.click(rendered.getByLabelText('Bulk Edit Data')) + + expect( + rendered.getByLabelText('Edit query data as JSON'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByText('Cancel')) + + expect(rendered.queryByLabelText('Edit query data as JSON')).toBeNull() + expect(rendered.getByLabelText('Bulk Edit Data')).toBeInTheDocument() + }) + }) + + describe('error type select', () => { + it('should render the error type select when "errorTypes" is provided', () => { + queryClient.setQueryData(['error-select'], [{ id: 1 }]) + const rendered = renderDevtools({ + initialIsOpen: true, + errorTypes: [ + { + name: 'NetworkError', + initializer: () => new Error('Network'), + }, + ], + }) + + fireEvent.click(rendered.getByLabelText(/Query key \["error-select"\]/)) + + expect( + rendered.getByLabelText('Select error type to trigger'), + ).toBeInTheDocument() + }) + + it('should trigger error when an error type is selected', () => { + queryClient.setQueryData(['error-select-trigger'], [{ id: 1 }]) + const rendered = renderDevtools({ + initialIsOpen: true, + errorTypes: [ + { + name: 'NetworkError', + initializer: () => new Error('Network'), + }, + ], + }) + + fireEvent.click( + rendered.getByLabelText(/Query key \["error-select-trigger"\]/), + ) + const select = rendered.getByLabelText('Select error type to trigger') + fireEvent.change(select, { target: { value: 'NetworkError' } }) + + expect(queryClient.getQueryState(['error-select-trigger'])?.status).toBe( + 'error', + ) + }) + }) + + describe('sort by', () => { + it('should change sort key when the sort dropdown is changed in queries view', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.change(rendered.getByLabelText('Sort queries by'), { + target: { value: 'last updated' }, + }) + + expect(localStorage.getItem('TanstackQueryDevtools.sort')).toBe( + 'last updated', + ) + }) + + it('should change sort key when the sort dropdown is changed in mutations view', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + fireEvent.click(rendered.getByText('Mutations')) + + fireEvent.change(rendered.getByLabelText('Sort mutations by'), { + target: { value: 'last updated' }, + }) + + expect(localStorage.getItem('TanstackQueryDevtools.mutationSort')).toBe( + 'last updated', + ) + }) + + it('should hide disabled queries when "hideDisabledQueries" is enabled in localStorage', () => { + const disabled = new QueryObserver(queryClient, { + queryKey: ['hide-test-disabled'], + queryFn: () => 'x', + enabled: false, + }) + const unsubscribe = disabled.subscribe(() => {}) + queryClient.setQueryData(['hide-test-disabled'], 'x') + queryClient.setQueryData(['hide-test-active'], 'y') + + try { + const rendered = renderDevtools( + { initialIsOpen: true }, + { 'TanstackQueryDevtools.hideDisabledQueries': 'true' }, + ) + + expect( + rendered.queryByLabelText(/Query key \["hide-test-disabled"\]/), + ).not.toBeInTheDocument() + expect( + rendered.getByLabelText(/Query key \["hide-test-active"\]/), + ).toBeInTheDocument() + } finally { + unsubscribe() + } + }) + }) + + describe('sort order', () => { + it('should toggle the sort order when the sort order button is clicked', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Sort order/)) + const afterFirstToggle = localStorage.getItem( + 'TanstackQueryDevtools.sortOrder', + ) + expect(['1', '-1']).toContain(afterFirstToggle) + + fireEvent.click(rendered.getByLabelText(/Sort order/)) + const afterSecondToggle = localStorage.getItem( + 'TanstackQueryDevtools.sortOrder', + ) + expect(afterSecondToggle).toBe(afterFirstToggle === '1' ? '-1' : '1') + }) + }) + + describe('settings menu', () => { + it('should show "Position" sub-trigger when the settings menu is opened', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.keyDown(rendered.getByLabelText('Open settings menu'), { + key: 'Enter', + }) + + // The menu is rendered through a Portal mounted on `document.body`, + // outside `rendered.container`, so look it up via `document` directly. + expect( + document.querySelector('.tsqd-settings-menu-sub-trigger-position'), + ).not.toBeNull() + }) + + it('should open "Position" sub-menu when the sub-trigger is activated', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.keyDown(rendered.getByLabelText('Open settings menu'), { + key: 'Enter', + }) + + const subTrigger = document.querySelector( + '.tsqd-settings-menu-sub-trigger-position', + ) + expect(subTrigger).not.toBeNull() + fireEvent.keyDown(subTrigger!, { key: 'ArrowRight' }) + + expect( + document.querySelector('[aria-label="Position settings"]'), + ).not.toBeNull() + }) + + it('should persist "position" when a position radio item is selected', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.keyDown(rendered.getByLabelText('Open settings menu'), { + key: 'Enter', + }) + + const subTrigger = document.querySelector( + '.tsqd-settings-menu-sub-trigger-position', + ) + expect(subTrigger).not.toBeNull() + fireEvent.keyDown(subTrigger!, { key: 'ArrowRight' }) + + const topItem = document.querySelector( + '.tsqd-settings-menu-position-btn-top', + ) + expect(topItem).not.toBeNull() + fireEvent.keyDown(topItem!, { key: 'Enter' }) + + expect(localStorage.getItem('TanstackQueryDevtools.position')).toBe('top') + }) + + it('should open "Theme" sub-menu when the sub-trigger is activated', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.keyDown(rendered.getByLabelText('Open settings menu'), { + key: 'Enter', + }) + + const themeTrigger = Array.from( + document.querySelectorAll( + '.tsqd-settings-menu-sub-trigger', + ), + ).find((el) => String(el.textContent).includes('Theme')) + expect(themeTrigger).not.toBeUndefined() + fireEvent.keyDown(themeTrigger!, { key: 'ArrowRight' }) + + expect( + document.querySelector('[aria-label="Theme preference"]'), + ).not.toBeNull() + }) + + it('should persist "theme_preference" when a theme radio item is selected', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.keyDown(rendered.getByLabelText('Open settings menu'), { + key: 'Enter', + }) + + const themeTrigger = Array.from( + document.querySelectorAll( + '.tsqd-settings-menu-sub-trigger', + ), + ).find((el) => String(el.textContent).includes('Theme')) + expect(themeTrigger).not.toBeUndefined() + fireEvent.keyDown(themeTrigger!, { key: 'ArrowRight' }) + + const themeMenu = document.querySelector( + '[aria-label="Theme preference"]', + ) + const lightItem = Array.from( + themeMenu?.querySelectorAll('[role="menuitemradio"]') ?? + [], + ).find((el) => String(el.textContent).includes('Light')) + expect(lightItem).not.toBeUndefined() + fireEvent.keyDown(lightItem!, { key: 'Enter' }) + + expect( + localStorage.getItem('TanstackQueryDevtools.theme_preference'), + ).toBe('light') + }) + + it('should open "Hide disabled queries" sub-menu when the sub-trigger is activated', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.keyDown(rendered.getByLabelText('Open settings menu'), { + key: 'Enter', + }) + + const hideTrigger = document.querySelector( + '.tsqd-settings-menu-sub-trigger-disabled-queries', + ) + expect(hideTrigger).not.toBeNull() + fireEvent.keyDown(hideTrigger!, { key: 'ArrowRight' }) + + expect( + document.querySelector('[aria-label="Hide disabled queries setting"]'), + ).not.toBeNull() + }) + + it('should persist "hideDisabledQueries" when a hide-disabled radio item is selected', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.keyDown(rendered.getByLabelText('Open settings menu'), { + key: 'Enter', + }) + + const hideTrigger = document.querySelector( + '.tsqd-settings-menu-sub-trigger-disabled-queries', + ) + expect(hideTrigger).not.toBeNull() + fireEvent.keyDown(hideTrigger!, { key: 'ArrowRight' }) + + const hideItem = document.querySelector( + '.tsqd-settings-menu-position-btn-hide', + ) + expect(hideItem).not.toBeNull() + fireEvent.keyDown(hideItem!, { key: 'Enter' }) + + expect( + localStorage.getItem('TanstackQueryDevtools.hideDisabledQueries'), + ).toBe('true') + }) + }) + + describe('resize handle', () => { + it('should increase height when "ArrowUp" is pressed on the resize handle in "bottom" position', () => { + const rendered = renderDevtools( + { position: 'bottom', initialIsOpen: true }, + { 'TanstackQueryDevtools.height': '500' }, + ) + + const handle = rendered.getByLabelText('Resize devtools panel') + fireEvent.keyDown(handle, { key: 'ArrowUp' }) + + expect( + Number(localStorage.getItem('TanstackQueryDevtools.height')), + ).toBeGreaterThan(500) + }) + + it('should decrease height when "ArrowDown" is pressed on the resize handle in "bottom" position', () => { + const rendered = renderDevtools( + { position: 'bottom', initialIsOpen: true }, + { 'TanstackQueryDevtools.height': '500' }, + ) + + const handle = rendered.getByLabelText('Resize devtools panel') + fireEvent.keyDown(handle, { key: 'ArrowDown' }) + + // Assert the value exists before parsing — `Number(null)` is `0`, + // which would falsely satisfy `toBeLessThan(500)` if the write was missing. + const nextHeight = localStorage.getItem('TanstackQueryDevtools.height') + expect(nextHeight).not.toBeNull() + expect(Number(nextHeight)).toBeLessThan(500) + }) + + it('should increase width when "ArrowLeft" is pressed on the resize handle in "right" position', () => { + const rendered = renderDevtools( + { position: 'right', initialIsOpen: true }, + { 'TanstackQueryDevtools.width': '500' }, + ) + + const handle = rendered.getByLabelText('Resize devtools panel') + fireEvent.keyDown(handle, { key: 'ArrowLeft' }) + + expect( + Number(localStorage.getItem('TanstackQueryDevtools.width')), + ).toBeGreaterThan(500) + }) + + it('should decrease width when "ArrowRight" is pressed on the resize handle in "right" position', () => { + const rendered = renderDevtools( + { position: 'right', initialIsOpen: true }, + { 'TanstackQueryDevtools.width': '500' }, + ) + + const handle = rendered.getByLabelText('Resize devtools panel') + fireEvent.keyDown(handle, { key: 'ArrowRight' }) + + const nextWidth = localStorage.getItem('TanstackQueryDevtools.width') + expect(nextWidth).not.toBeNull() + expect(Number(nextWidth)).toBeLessThan(500) + }) + + it('should increase height while dragging up in "bottom" position', () => { + const initialHeight = 500 + const rendered = renderDevtools( + { position: 'bottom', initialIsOpen: true }, + { 'TanstackQueryDevtools.height': String(initialHeight) }, + ) + + const handle = rendered.getByLabelText('Resize devtools panel') + // jsdom returns zeros for `getBoundingClientRect`; stub the panel size so + // that drag math starts from `initialHeight` instead of 0. + // Only `height` is read by the production code; other fields are unused. + const panel = handle.parentElement + expect(panel).toBeInstanceOf(HTMLElement) + vi.spyOn(panel!, 'getBoundingClientRect').mockReturnValue({ + height: initialHeight, + width: 0, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + }) + + // Move the cursor up by 50px (`clientY` 100 → 50), which adds 50px to the + // drag base of `initialHeight`. + fireEvent.mouseDown(handle, { clientX: 0, clientY: 100 }) + fireEvent( + document, + new MouseEvent('mousemove', { clientX: 0, clientY: 50 }), + ) + fireEvent(document, new MouseEvent('mouseup')) + + expect( + Number(localStorage.getItem('TanstackQueryDevtools.height')), + ).toBeGreaterThan(initialHeight) + }) + + it('should increase width while dragging left in "right" position', () => { + const initialWidth = 500 + const rendered = renderDevtools( + { position: 'right', initialIsOpen: true }, + { 'TanstackQueryDevtools.width': String(initialWidth) }, + ) + + const handle = rendered.getByLabelText('Resize devtools panel') + // `width` is read twice during drag: once as the base size, and again to + // detect when the panel hits its minimum. Returning the same value both + // times keeps the "minimum reached" branch from firing. + const panel = handle.parentElement + expect(panel).toBeInstanceOf(HTMLElement) + vi.spyOn(panel!, 'getBoundingClientRect').mockReturnValue({ + height: 0, + width: initialWidth, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + }) + + // Move the cursor left by 50px (`clientX` 100 → 50); in `right` position, + // moving left grows the panel by 50px from the drag base of `initialWidth`. + fireEvent.mouseDown(handle, { clientX: 100, clientY: 0 }) + fireEvent( + document, + new MouseEvent('mousemove', { clientX: 50, clientY: 0 }), + ) + fireEvent(document, new MouseEvent('mouseup')) + + expect( + Number(localStorage.getItem('TanstackQueryDevtools.width')), + ).toBeGreaterThan(initialWidth) + }) + + it('should clamp the width to the minimum when dragging shrinks the panel below the minimum width', () => { + const initialWidth = 200 + const rendered = renderDevtools( + { position: 'left', initialIsOpen: true }, + { 'TanstackQueryDevtools.width': String(initialWidth) }, + ) + + const handle = rendered.getByLabelText('Resize devtools panel') + const panel = handle.parentElement + expect(panel).toBeInstanceOf(HTMLElement) + // `width` is read twice during drag: once as the base size, and again + // after the clamp to detect when the panel has hit its minimum. The + // first call returns `initialWidth`; the second returns `0` so the + // `localStore.width < newWidth` restore branch stays inactive and only + // the `newSize < minWidth` clamp is observed. + const getBoundingClientRect = vi + .spyOn(panel!, 'getBoundingClientRect') + .mockReturnValueOnce({ + height: 0, + width: initialWidth, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + }) + getBoundingClientRect.mockReturnValue({ + height: 0, + width: 0, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + }) + + // In `left` position, dragging the cursor left (`clientX` 100 → 0) + // shrinks the panel by 100px, which lands well under the 192px minimum. + fireEvent.mouseDown(handle, { clientX: 100, clientY: 0 }) + fireEvent( + document, + new MouseEvent('mousemove', { clientX: 0, clientY: 0 }), + ) + fireEvent(document, new MouseEvent('mouseup')) + + expect(Number(localStorage.getItem('TanstackQueryDevtools.width'))).toBe( + 192, + ) + }) + + it('should restore "width" to the rendered minimum when the panel is dragged below its content width', () => { + const initialWidth = 400 + // The panel uses `min-width: min-content`, so the rendered width may + // exceed the 192px `minWidth` constant when its children are wider; any + // value > 192 here triggers the `localStore.width < newWidth` restore + // branch. + const renderedMinWidth = 250 + const rendered = renderDevtools( + { position: 'left', initialIsOpen: true }, + { 'TanstackQueryDevtools.width': String(initialWidth) }, + ) + + const handle = rendered.getByLabelText('Resize devtools panel') + const panel = handle.parentElement + expect(panel).toBeInstanceOf(HTMLElement) + const getBoundingClientRect = vi + .spyOn(panel!, 'getBoundingClientRect') + .mockReturnValueOnce({ + height: 0, + width: initialWidth, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + }) + getBoundingClientRect.mockReturnValue({ + height: 0, + width: renderedMinWidth, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + }) + + // Drag 300px left shrinks the panel to 100 — below `minWidth`, so the + // clamp + restore branches both fire. + fireEvent.mouseDown(handle, { clientX: 300, clientY: 0 }) + fireEvent( + document, + new MouseEvent('mousemove', { clientX: 0, clientY: 0 }), + ) + fireEvent(document, new MouseEvent('mouseup')) + + expect(Number(localStorage.getItem('TanstackQueryDevtools.width'))).toBe( + renderedMinWidth, + ) + }) + + it('should close the query details panel when dragging shrinks the panel below the minimum height', () => { + queryClient.setQueryData(['shrink-below-min-height'], [{ id: 1 }]) + const rendered = renderDevtools({ + position: 'bottom', + initialIsOpen: true, + }) + + // Open the query details so `selectedQueryHash` is set. + fireEvent.click( + rendered.getByLabelText(/Query key \["shrink-below-min-height"\]/), + ) + expect(rendered.getByText('Query Details')).toBeInTheDocument() + + const handle = rendered.getByLabelText('Resize devtools panel') + const panel = handle.parentElement + expect(panel).toBeInstanceOf(HTMLElement) + // Stub the base size to a value just above the 56px (`3.5rem`) minimum so + // a small downward drag pushes `newSize` below `minHeight` and triggers + // the clamp branch that also resets `selectedQueryHash`. + vi.spyOn(panel!, 'getBoundingClientRect').mockReturnValue({ + height: 60, + width: 0, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + }) + + // In `bottom` position, dragging the cursor down (`clientY` 100 → 200) + // shrinks the panel by 100px, which is well under the 56px minimum. + fireEvent.mouseDown(handle, { clientX: 0, clientY: 100 }) + fireEvent( + document, + new MouseEvent('mousemove', { clientX: 0, clientY: 200 }), + ) + fireEvent(document, new MouseEvent('mouseup')) + + expect(rendered.queryByText('Query Details')).not.toBeInTheDocument() + }) + }) + + describe('online toggle', () => { + it('should swap the toggle label after the offline button is clicked', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Mock offline behavior')) + + expect( + rendered.getByLabelText('Unset offline mocking behavior'), + ).toBeInTheDocument() + }) + }) + + describe('logo close', () => { + it('should hide the panel when the TanStack logo is clicked', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Close Tanstack query devtools')) + + expect( + rendered.queryByLabelText('Tanstack query devtools'), + ).not.toBeInTheDocument() + }) + }) +}) diff --git a/packages/query-devtools/src/__tests__/DevtoolsComponent.test.tsx b/packages/query-devtools/src/__tests__/DevtoolsComponent.test.tsx new file mode 100644 index 00000000000..75dcf60f1db --- /dev/null +++ b/packages/query-devtools/src/__tests__/DevtoolsComponent.test.tsx @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, onlineManager } from '@tanstack/query-core' +import { render } from '@solidjs/testing-library' +import DevtoolsComponent from '../DevtoolsComponent' + +// `solid-transition-group` internally imports from +// `@solid-primitives/transition-group`, whose `exports` field points at +// `src/index.ts` (not published) under a `@solid-primitives/source` condition +// that Vite can't fall through, so we stub it with a transparent pass-through. +vi.mock('solid-transition-group', () => ({ + TransitionGroup: (props: { children: unknown }) => props.children, +})) + +describe('DevtoolsComponent', () => { + const storage: { [key: string]: string } = {} + let queryClient: QueryClient + + beforeEach(() => { + vi.stubGlobal('localStorage', { + getItem: (key: string) => + Object.prototype.hasOwnProperty.call(storage, key) + ? storage[key] + : null, + setItem: (key: string, value: string) => { + storage[key] = value + }, + removeItem: (key: string) => { + delete storage[key] + }, + clear: () => { + Object.keys(storage).forEach((key) => delete storage[key]) + }, + }) + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ) + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + queryClient = new QueryClient() + }) + + afterEach(() => { + vi.unstubAllGlobals() + Object.keys(storage).forEach((key) => delete storage[key]) + queryClient.clear() + }) + + it('should render without throwing when only required props are provided', () => { + expect(() => + render(() => ( + + )), + ).not.toThrow() + }) + + it('should render without throwing when all props are provided', () => { + expect(() => + render(() => ( + new Error('Network') }, + ]} + hideDisabledQueries={true} + theme="dark" + /> + )), + ).not.toThrow() + }) +}) diff --git a/packages/query-devtools/src/__tests__/DevtoolsPanelComponent.test.tsx b/packages/query-devtools/src/__tests__/DevtoolsPanelComponent.test.tsx new file mode 100644 index 00000000000..ee7d3c02155 --- /dev/null +++ b/packages/query-devtools/src/__tests__/DevtoolsPanelComponent.test.tsx @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, onlineManager } from '@tanstack/query-core' +import { render } from '@solidjs/testing-library' +import DevtoolsPanelComponent from '../DevtoolsPanelComponent' + +// `solid-transition-group` internally imports from +// `@solid-primitives/transition-group`, whose `exports` field points at +// `src/index.ts` (not published) under a `@solid-primitives/source` condition +// that Vite can't fall through, so we stub it with a transparent pass-through. +vi.mock('solid-transition-group', () => ({ + TransitionGroup: (props: { children: unknown }) => props.children, +})) + +// `goober` compiles every `css\`...\`` template literal at mount time +// (template parsing + class hashing + style serialization), which +// dominates mount cost and produces no value for label/role-based +// assertions, so we replace it with a no-op factory. +vi.mock('goober', () => { + let counter = 0 + const css = Object.assign(() => `tsqd-${++counter}`, { + bind: () => css, + }) + return { css, glob: () => {}, setup: () => {} } +}) + +describe('DevtoolsPanelComponent', () => { + const storage: { [key: string]: string } = {} + let queryClient: QueryClient + let previousRootFontSize = '' + + beforeEach(() => { + previousRootFontSize = document.documentElement.style.fontSize + vi.stubGlobal('localStorage', { + getItem: (key: string) => + Object.prototype.hasOwnProperty.call(storage, key) + ? storage[key] + : null, + setItem: (key: string, value: string) => { + storage[key] = value + }, + removeItem: (key: string) => { + delete storage[key] + }, + clear: () => { + Object.keys(storage).forEach((key) => delete storage[key]) + }, + }) + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ) + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + queryClient = new QueryClient() + document.documentElement.style.fontSize = '16px' + }) + + afterEach(() => { + vi.unstubAllGlobals() + Object.keys(storage).forEach((key) => delete storage[key]) + queryClient.clear() + document.documentElement.style.fontSize = previousRootFontSize + }) + + it('should render the panel without throwing when only required props are provided', () => { + expect(() => + render(() => ( + + )), + ).not.toThrow() + }) + + it('should render the panel without throwing when all props are provided', () => { + expect(() => + render(() => ( + new Error('Network') }, + ]} + hideDisabledQueries={true} + theme="dark" + onClose={() => {}} + /> + )), + ).not.toThrow() + }) + + it('should not render the open devtools button in panel-only mode', () => { + const rendered = render(() => ( + + )) + + expect( + rendered.queryByLabelText('Open Tanstack query devtools'), + ).not.toBeInTheDocument() + }) + + it('should call "onClose" when the close button is clicked', () => { + const onClose = vi.fn() + const rendered = render(() => ( + + )) + + rendered.getByLabelText('Close Tanstack query devtools').click() + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/query-devtools/src/__tests__/Explorer.test.tsx b/packages/query-devtools/src/__tests__/Explorer.test.tsx new file mode 100644 index 00000000000..2ffc7cd1d93 --- /dev/null +++ b/packages/query-devtools/src/__tests__/Explorer.test.tsx @@ -0,0 +1,633 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, within } from '@solidjs/testing-library' +import { QueryClient, onlineManager } from '@tanstack/query-core' +import Explorer from '../Explorer' +import { QueryDevtoolsContext, ThemeContext } from '../contexts' +import type { Query } from '@tanstack/query-core' + +// `goober` compiles every `css\`...\`` template literal at mount time; +// replace it with a no-op factory so label/role-based assertions stay fast. +vi.mock('goober', () => { + let counter = 0 + const css = Object.assign(() => `tsqd-${++counter}`, { + bind: () => css, + }) + return { css, glob: () => {}, setup: () => {} } +}) + +describe('Explorer', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient() + }) + + afterEach(() => { + queryClient.clear() + vi.useRealTimers() + }) + + function renderExplorer( + props: Parameters[0], + options: { theme?: 'dark' | 'light' } = {}, + ) { + const theme = options.theme ?? 'dark' + return render(() => ( + + theme}> + + + + )) + } + + describe('primitive values', () => { + it('should render a "label: value" row for a string value', () => { + const rendered = renderExplorer({ label: 'name', value: 'Anna' }) + + expect(rendered.getByText('name:')).toBeInTheDocument() + expect(rendered.getByText('"Anna"')).toBeInTheDocument() + }) + + it('should render a "label: value" row for a number value', () => { + const rendered = renderExplorer({ label: 'count', value: 42 }) + + expect(rendered.getByText('count:')).toBeInTheDocument() + expect(rendered.getByText('42')).toBeInTheDocument() + }) + + it('should render a "label: value" row for a boolean value', () => { + const rendered = renderExplorer({ label: 'active', value: true }) + + expect(rendered.getByText('active:')).toBeInTheDocument() + expect(rendered.getByText('true')).toBeInTheDocument() + }) + + it('should render a "label: value" row for a "null" value', () => { + const rendered = renderExplorer({ label: 'missing', value: null }) + + expect(rendered.getByText('missing:')).toBeInTheDocument() + expect(rendered.getByText('null')).toBeInTheDocument() + }) + }) + + describe('arrays and objects', () => { + it('should render an empty object as a primitive row (no expander)', () => { + const rendered = renderExplorer({ label: 'data', value: {} }) + + expect(rendered.getByText('data:')).toBeInTheDocument() + expect(rendered.queryByRole('button', { expanded: false })).toBeNull() + }) + + it('should render an array with an expander showing the item count', () => { + const rendered = renderExplorer({ + label: 'list', + value: ['a', 'b', 'c'], + }) + + const expander = rendered.getByRole('button', { expanded: false }) + expect(expander).toBeInTheDocument() + expect(expander.textContent).toContain('list') + expect(expander.textContent).toContain('3 items') + }) + + it('should render children under their index labels when the array expander is clicked', () => { + const rendered = renderExplorer({ + label: 'list', + value: ['a', 'b'], + }) + + fireEvent.click(rendered.getByRole('button', { expanded: false })) + + expect(rendered.getByText('0:')).toBeInTheDocument() + expect(rendered.getByText('1:')).toBeInTheDocument() + expect(rendered.getByText('"a"')).toBeInTheDocument() + expect(rendered.getByText('"b"')).toBeInTheDocument() + }) + + it('should render object entries under their keys when expanded', () => { + const rendered = renderExplorer({ + label: 'user', + value: { name: 'Anna', age: 30 }, + }) + + fireEvent.click(rendered.getByRole('button', { expanded: false })) + + expect(rendered.getByText('name:')).toBeInTheDocument() + expect(rendered.getByText('"Anna"')).toBeInTheDocument() + expect(rendered.getByText('age:')).toBeInTheDocument() + expect(rendered.getByText('30')).toBeInTheDocument() + }) + }) + + describe('Map and iterable values', () => { + it('should preserve "Map" keys as labels when expanded', () => { + const rendered = renderExplorer({ + label: 'm', + value: new Map([ + ['first', 1], + ['second', 2], + ]), + }) + + fireEvent.click(rendered.getByRole('button', { expanded: false })) + + expect(rendered.getByText('first:')).toBeInTheDocument() + expect(rendered.getByText('second:')).toBeInTheDocument() + }) + + it('should mark an iterable value with an "(Iterable)" prefix on the expander', () => { + const rendered = renderExplorer({ + label: 's', + value: new Set(['x', 'y']), + }) + + expect( + rendered.getByRole('button', { expanded: false }).textContent, + ).toContain('(Iterable)') + }) + + it('should render iterable children under their numeric index when expanded', () => { + const rendered = renderExplorer({ + label: 's', + value: new Set(['x', 'y']), + }) + + fireEvent.click(rendered.getByRole('button', { expanded: false })) + + expect(rendered.getByText('0:')).toBeInTheDocument() + expect(rendered.getByText('1:')).toBeInTheDocument() + }) + }) + + describe('"defaultExpanded"', () => { + it('should render children eagerly when the label is in "defaultExpanded"', () => { + const rendered = renderExplorer({ + label: 'list', + value: ['a'], + defaultExpanded: ['list'], + }) + + expect( + rendered.getByRole('button', { expanded: true }), + ).toBeInTheDocument() + expect(rendered.getByText('0:')).toBeInTheDocument() + }) + }) + + describe('action menu', () => { + it('should copy the serialized value to the clipboard when the copy button is clicked', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + queryClient.setQueryData(['data'], { name: 'Anna' }) + + const rendered = renderExplorer({ + label: 'data', + value: { name: 'Anna' }, + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + fireEvent.click(rendered.getByLabelText('Copy object to clipboard')) + + expect(writeText).toHaveBeenCalledTimes(1) + const [arg] = writeText.mock.calls[0]! + expect(JSON.parse(arg as string)).toMatchObject({ + json: { name: 'Anna' }, + }) + }) + + it('should switch the copy button to an error state when clipboard write fails', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + queryClient.setQueryData(['data'], { name: 'Anna' }) + + const rendered = renderExplorer({ + label: 'data', + value: { name: 'Anna' }, + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + fireEvent.click(rendered.getByLabelText('Copy object to clipboard')) + await vi.advanceTimersByTimeAsync(0) + + expect( + rendered.getByLabelText('Error copying object to clipboard'), + ).toBeInTheDocument() + expect(consoleError).toHaveBeenCalledWith( + 'Failed to copy: ', + expect.any(Error), + ) + }) + + it('should reset the copy button to the idle state 1500ms after a successful copy', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + queryClient.setQueryData(['data'], { name: 'Anna' }) + + const rendered = renderExplorer({ + label: 'data', + value: { name: 'Anna' }, + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + fireEvent.click(rendered.getByLabelText('Copy object to clipboard')) + await vi.advanceTimersByTimeAsync(0) + + expect( + rendered.getByLabelText('Object copied to clipboard'), + ).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1500) + + expect( + rendered.getByLabelText('Copy object to clipboard'), + ).toBeInTheDocument() + }) + + it('should reset the copy button to the idle state 1500ms after a failed copy', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + queryClient.setQueryData(['data'], { name: 'Anna' }) + + const rendered = renderExplorer({ + label: 'data', + value: { name: 'Anna' }, + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + fireEvent.click(rendered.getByLabelText('Copy object to clipboard')) + await vi.advanceTimersByTimeAsync(0) + + expect( + rendered.getByLabelText('Error copying object to clipboard'), + ).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1500) + + expect( + rendered.getByLabelText('Copy object to clipboard'), + ).toBeInTheDocument() + }) + + it('should clear array items via "setQueryData" when the clear-array button is clicked', () => { + queryClient.setQueryData(['data'], ['a', 'b', 'c']) + + const rendered = renderExplorer({ + label: 'list', + value: ['a', 'b', 'c'], + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + fireEvent.click(rendered.getByLabelText('Remove all items')) + + expect(queryClient.getQueryData(['data'])).toEqual([]) + }) + + it('should delete the entry at the current "dataPath" when the delete button is clicked', () => { + queryClient.setQueryData(['data'], ['a', 'b', 'c']) + + const rendered = renderExplorer({ + label: 'list', + value: ['a', 'b', 'c'], + editable: true, + itemsDeletable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + dataPath: ['1'], + }) + + fireEvent.click(rendered.getByLabelText('Delete item')) + + expect(queryClient.getQueryData(['data'])).toEqual(['a', 'c']) + }) + + it('should toggle a boolean value via "setQueryData" when the toggle button is clicked', () => { + queryClient.setQueryData(['data'], { flag: true }) + + const rendered = renderExplorer({ + label: 'flag', + value: true, + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + dataPath: ['flag'], + }) + + fireEvent.click(rendered.getByLabelText('Toggle value')) + + expect(queryClient.getQueryData(['data'])).toEqual({ flag: false }) + }) + + it('should not render action buttons when "editable" is false', () => { + queryClient.setQueryData(['data'], ['a']) + + const rendered = renderExplorer({ + label: 'list', + value: ['a'], + editable: false, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + expect(rendered.queryByLabelText('Copy object to clipboard')).toBeNull() + expect(rendered.queryByLabelText('Remove all items')).toBeNull() + }) + + it('should not render "ClearArrayButton" when value is not an array', () => { + queryClient.setQueryData(['data'], { name: 'Anna' }) + + const rendered = renderExplorer({ + label: 'user', + value: { name: 'Anna' }, + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + expect(rendered.queryByLabelText('Remove all items')).toBeNull() + expect( + rendered.getByLabelText('Copy object to clipboard'), + ).toBeInTheDocument() + }) + }) + + describe('pagination', () => { + it('should group entries into 100-item pages when the array has more than 100 entries', () => { + const rendered = renderExplorer({ + label: 'big', + value: Array.from({ length: 101 }, (_, i) => i), + }) + + fireEvent.click(rendered.getByRole('button', { expanded: false })) + + expect(rendered.getByText('[0...99]')).toBeInTheDocument() + expect(rendered.getByText('[100...199]')).toBeInTheDocument() + }) + + it('should keep the items of a page hidden until the page header is clicked', () => { + const rendered = renderExplorer({ + label: 'big', + value: Array.from({ length: 101 }, (_, i) => `item-${i}`), + }) + + fireEvent.click(rendered.getByRole('button', { expanded: false })) + + expect(rendered.queryByText('0:')).toBeNull() + }) + + it('should reveal the items of a page when the page header is clicked', () => { + const rendered = renderExplorer({ + label: 'big', + value: Array.from({ length: 101 }, (_, i) => `item-${i}`), + }) + + fireEvent.click(rendered.getByRole('button', { expanded: false })) + fireEvent.click(rendered.getByText('[0...99]')) + + expect(rendered.getByText('0:')).toBeInTheDocument() + expect(rendered.getByText('"item-0"')).toBeInTheDocument() + }) + + it('should independently toggle two pages when their headers are clicked', () => { + const rendered = renderExplorer({ + label: 'big', + value: Array.from({ length: 200 }, (_, i) => `item-${i}`), + }) + + fireEvent.click(rendered.getByRole('button', { expanded: false })) + fireEvent.click(rendered.getByText('[0...99]')) + fireEvent.click(rendered.getByText('[100...199]')) + + expect(rendered.getByText('"item-0"')).toBeInTheDocument() + expect(rendered.getByText('"item-100"')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('[0...99]')) + + expect(rendered.queryByText('"item-0"')).toBeNull() + expect(rendered.getByText('"item-100"')).toBeInTheDocument() + }) + + it('should render action buttons for items inside a paginated page', () => { + const value: Array> = Array.from( + { length: 200 }, + (_, i) => [i], + ) + queryClient.setQueryData(['data'], value) + + const rendered = renderExplorer({ + label: 'Data', + value, + defaultExpanded: ['Data'], + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + fireEvent.click(rendered.getByText('[0...99]')) + + expect( + rendered.getAllByLabelText('Remove all items').length, + ).toBeGreaterThan(1) + }) + }) + + describe('inline edit', () => { + it('should write the new string value via "setQueryData" when a text input is changed', () => { + queryClient.setQueryData(['data'], { name: 'Anna' }) + + const rendered = renderExplorer({ + label: 'name', + value: 'Anna', + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + dataPath: ['name'], + }) + + const input = rendered.getByLabelText('name:') + expect(input).toHaveAttribute('type', 'text') + + fireEvent.change(input, { target: { value: 'Bob' } }) + + expect(queryClient.getQueryData(['data'])).toEqual({ name: 'Bob' }) + }) + + it('should write the new number value via "setQueryData" when a number input is changed', () => { + queryClient.setQueryData(['data'], { count: 1 }) + + const rendered = renderExplorer({ + label: 'count', + value: 1, + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + dataPath: ['count'], + }) + + const input = rendered.getByLabelText('count:') + expect(input).toHaveAttribute('type', 'number') + + fireEvent.change(input, { + target: { value: '42', valueAsNumber: 42 }, + }) + + expect(queryClient.getQueryData(['data'])).toEqual({ count: 42 }) + }) + + it('should render "ToggleValueButton" inline for a boolean primitive row', () => { + queryClient.setQueryData(['data'], { flag: false }) + + const rendered = renderExplorer({ + label: 'flag', + value: false, + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + dataPath: ['flag'], + }) + + expect(rendered.getByLabelText('Toggle value')).toBeInTheDocument() + }) + + it('should render "DeleteItemButton" inline when a primitive row has "itemsDeletable"', () => { + queryClient.setQueryData(['data'], { name: 'Anna' }) + + const rendered = renderExplorer({ + label: 'name', + value: 'Anna', + editable: true, + itemsDeletable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + dataPath: ['name'], + }) + + expect(rendered.getByLabelText('Delete item')).toBeInTheDocument() + }) + + it('should delete fields from the active query when their inline delete buttons are clicked', () => { + const value = { name: 'Anna', age: 30 } + queryClient.setQueryData(['data'], value) + + const rendered = renderExplorer({ + label: 'Data', + value, + defaultExpanded: ['Data'], + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }) + + const ageRow = rendered.getByText('age:').parentElement! + fireEvent.click(within(ageRow).getByLabelText('Delete item')) + + expect(queryClient.getQueryData(['data'])).toEqual({ name: 'Anna' }) + + const nameRow = rendered.getByText('name:').parentElement! + fireEvent.click(within(nameRow).getByLabelText('Delete item')) + + expect(queryClient.getQueryData(['data'])).toEqual({}) + }) + }) + + describe('theme', () => { + it('should render without throwing under the "light" theme', () => { + const value = { items: ['a'], flag: true } + queryClient.setQueryData(['data'], value) + + expect(() => + renderExplorer( + { + label: 'Data', + value, + defaultExpanded: ['Data'], + editable: true, + activeQuery: queryClient + .getQueryCache() + .find({ queryKey: ['data'] }) as Query, + }, + { theme: 'light' }, + ), + ).not.toThrow() + }) + }) + + describe('"shadowDOMTarget"', () => { + it('should render without throwing when a "shadowDOMTarget" is provided', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const shadowRoot = host.attachShadow({ mode: 'open' }) + const value = { items: ['a'], flag: true } + queryClient.setQueryData(['data'], value) + + try { + expect(() => + render(() => ( + + 'dark'}> + + + + )), + ).not.toThrow() + } finally { + host.remove() + } + }) + }) +}) diff --git a/packages/query-devtools/src/__tests__/TanstackQueryDevtools.test.tsx b/packages/query-devtools/src/__tests__/TanstackQueryDevtools.test.tsx new file mode 100644 index 00000000000..cef4475d839 --- /dev/null +++ b/packages/query-devtools/src/__tests__/TanstackQueryDevtools.test.tsx @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { QueryClient, onlineManager } from '@tanstack/query-core' +import { TanstackQueryDevtools } from '..' + +describe('TanstackQueryDevtools', () => { + let devtools: TanstackQueryDevtools + + beforeEach(() => { + devtools = new TanstackQueryDevtools({ + client: new QueryClient(), + queryFlavor: 'TanStack Query', + version: '5', + onlineManager, + }) + }) + + describe('mount', () => { + it('should mount devtools to the provided element', () => { + const el = document.createElement('div') + + expect(() => devtools.mount(el)).not.toThrow() + + devtools.unmount() + }) + + it('should throw if mount is called twice without unmount', () => { + const el = document.createElement('div') + devtools.mount(el) + + expect(() => devtools.mount(el)).toThrow('Devtools is already mounted') + + devtools.unmount() + }) + }) + + describe('unmount', () => { + it('should unmount devtools and allow remounting', () => { + const el = document.createElement('div') + devtools.mount(el) + + expect(() => devtools.unmount()).not.toThrow() + expect(() => devtools.mount(el)).not.toThrow() + + devtools.unmount() + }) + + it('should throw if unmount is called before mount', () => { + expect(() => devtools.unmount()).toThrow('Devtools is not mounted') + }) + + it('should throw if unmount is called twice', () => { + const el = document.createElement('div') + devtools.mount(el) + devtools.unmount() + + expect(() => devtools.unmount()).toThrow('Devtools is not mounted') + }) + }) + + describe('setters', () => { + describe('before mount', () => { + it('should not throw when "setButtonPosition" is called', () => { + expect(() => devtools.setButtonPosition('top-left')).not.toThrow() + }) + + it('should not throw when "setPosition" is called', () => { + expect(() => devtools.setPosition('left')).not.toThrow() + }) + + it('should not throw when "setInitialIsOpen" is called', () => { + expect(() => devtools.setInitialIsOpen(true)).not.toThrow() + }) + + it('should not throw when "setErrorTypes" is called', () => { + expect(() => + devtools.setErrorTypes([ + { name: 'NetworkError', initializer: () => new Error('Network') }, + ]), + ).not.toThrow() + }) + + it('should not throw when "setClient" is called', () => { + expect(() => devtools.setClient(new QueryClient())).not.toThrow() + }) + + it('should not throw when "setTheme" is called', () => { + expect(() => devtools.setTheme('dark')).not.toThrow() + expect(() => devtools.setTheme('light')).not.toThrow() + expect(() => devtools.setTheme('system')).not.toThrow() + expect(() => devtools.setTheme(undefined)).not.toThrow() + }) + }) + + describe('after mount', () => { + it('should not throw when setters are called on a mounted instance', () => { + const el = document.createElement('div') + devtools.mount(el) + + try { + expect(() => devtools.setButtonPosition('top-left')).not.toThrow() + expect(() => devtools.setPosition('left')).not.toThrow() + expect(() => devtools.setInitialIsOpen(true)).not.toThrow() + expect(() => + devtools.setErrorTypes([ + { name: 'NetworkError', initializer: () => new Error('Network') }, + ]), + ).not.toThrow() + expect(() => devtools.setClient(new QueryClient())).not.toThrow() + expect(() => devtools.setTheme('dark')).not.toThrow() + } finally { + devtools.unmount() + } + }) + }) + }) +}) diff --git a/packages/query-devtools/src/__tests__/TanstackQueryDevtoolsPanel.test.tsx b/packages/query-devtools/src/__tests__/TanstackQueryDevtoolsPanel.test.tsx new file mode 100644 index 00000000000..f7407ad5ff0 --- /dev/null +++ b/packages/query-devtools/src/__tests__/TanstackQueryDevtoolsPanel.test.tsx @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { QueryClient, onlineManager } from '@tanstack/query-core' +import { TanstackQueryDevtoolsPanel } from '..' + +describe('TanstackQueryDevtoolsPanel', () => { + let devtools: TanstackQueryDevtoolsPanel + + beforeEach(() => { + devtools = new TanstackQueryDevtoolsPanel({ + client: new QueryClient(), + queryFlavor: 'TanStack Query', + version: '5', + onlineManager, + }) + }) + + describe('mount', () => { + it('should mount devtools to the provided element', () => { + const el = document.createElement('div') + + expect(() => devtools.mount(el)).not.toThrow() + + devtools.unmount() + }) + + it('should throw if mount is called twice without unmount', () => { + const el = document.createElement('div') + devtools.mount(el) + + expect(() => devtools.mount(el)).toThrow('Devtools is already mounted') + + devtools.unmount() + }) + }) + + describe('unmount', () => { + it('should unmount devtools and allow remounting', () => { + const el = document.createElement('div') + devtools.mount(el) + + expect(() => devtools.unmount()).not.toThrow() + expect(() => devtools.mount(el)).not.toThrow() + + devtools.unmount() + }) + + it('should throw if unmount is called before mount', () => { + expect(() => devtools.unmount()).toThrow('Devtools is not mounted') + }) + + it('should throw if unmount is called twice', () => { + const el = document.createElement('div') + devtools.mount(el) + devtools.unmount() + + expect(() => devtools.unmount()).toThrow('Devtools is not mounted') + }) + }) + + describe('setters', () => { + describe('before mount', () => { + it('should not throw when "setButtonPosition" is called', () => { + expect(() => devtools.setButtonPosition('top-left')).not.toThrow() + }) + + it('should not throw when "setPosition" is called', () => { + expect(() => devtools.setPosition('left')).not.toThrow() + }) + + it('should not throw when "setInitialIsOpen" is called', () => { + expect(() => devtools.setInitialIsOpen(true)).not.toThrow() + }) + + it('should not throw when "setErrorTypes" is called', () => { + expect(() => + devtools.setErrorTypes([ + { name: 'NetworkError', initializer: () => new Error('Network') }, + ]), + ).not.toThrow() + }) + + it('should not throw when "setClient" is called', () => { + expect(() => devtools.setClient(new QueryClient())).not.toThrow() + }) + + it('should not throw when "setOnClose" is called', () => { + expect(() => devtools.setOnClose(() => {})).not.toThrow() + }) + + it('should not throw when "setTheme" is called', () => { + expect(() => devtools.setTheme('dark')).not.toThrow() + expect(() => devtools.setTheme('light')).not.toThrow() + expect(() => devtools.setTheme('system')).not.toThrow() + expect(() => devtools.setTheme(undefined)).not.toThrow() + }) + }) + + describe('after mount', () => { + it('should not throw when setters are called on a mounted instance', () => { + const el = document.createElement('div') + devtools.mount(el) + + try { + expect(() => devtools.setButtonPosition('top-left')).not.toThrow() + expect(() => devtools.setPosition('left')).not.toThrow() + expect(() => devtools.setInitialIsOpen(true)).not.toThrow() + expect(() => + devtools.setErrorTypes([ + { name: 'NetworkError', initializer: () => new Error('Network') }, + ]), + ).not.toThrow() + expect(() => devtools.setClient(new QueryClient())).not.toThrow() + expect(() => devtools.setOnClose(() => {})).not.toThrow() + expect(() => devtools.setTheme('dark')).not.toThrow() + } finally { + devtools.unmount() + } + }) + }) + }) +}) diff --git a/packages/query-devtools/src/__tests__/contexts/PiPContext.test.tsx b/packages/query-devtools/src/__tests__/contexts/PiPContext.test.tsx new file mode 100644 index 00000000000..2eea2323f39 --- /dev/null +++ b/packages/query-devtools/src/__tests__/contexts/PiPContext.test.tsx @@ -0,0 +1,455 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render } from '@solidjs/testing-library' +import { createEffect } from 'solid-js' +import { createLocalStorage } from '@solid-primitives/storage' +import { PiPProvider, usePiPWindow } from '../../contexts' + +type FakePipWindowOverrides = { + document?: Document + innerWidth?: number + innerHeight?: number +} + +function stubPipWindow(overrides: FakePipWindowOverrides = {}) { + const pipDocument = + overrides.document ?? document.implementation.createHTMLDocument('PiP') + const fakeWindow = { + document: pipDocument, + innerWidth: overrides.innerWidth ?? 800, + innerHeight: overrides.innerHeight ?? 600, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + close: vi.fn(), + } + const open = vi.fn(() => fakeWindow) + vi.stubGlobal('open', open) + return { pipDocument, fakeWindow, open } +} + +describe('PiPContext', () => { + afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() + }) + + function renderAndAct( + action: (pip: ReturnType) => void, + options: { + disabled?: boolean + initialStorage?: Record + } = {}, + ) { + Object.entries(options.initialStorage ?? {}).forEach(([key, value]) => { + localStorage.setItem(key, value) + }) + render(() => { + const [localStore, setLocalStore] = createLocalStorage({ + prefix: 'TanstackQueryDevtools', + }) + return ( + + + + ) + }) + } + + function PiPActor(props: { + run: (pip: ReturnType) => void + }) { + const pip = usePiPWindow() + let hasRun = false + createEffect(() => { + pip() + if (!hasRun) { + hasRun = true + props.run(pip) + } + }) + return null + } + + describe('usePiPWindow', () => { + it('should throw when used outside a "PiPProvider"', () => { + function PiPProbe() { + usePiPWindow() + return null + } + + expect(() => render(() => )).toThrow( + 'usePiPWindow must be used within a PiPProvider', + ) + }) + }) + + describe('"requestPipWindow"', () => { + it('should call "window.open" with the expected target and features', () => { + const { open } = stubPipWindow() + + renderAndAct((pip) => pip().requestPipWindow(640, 480)) + + expect(open).toHaveBeenCalledWith( + '', + 'TSQD-Devtools-Panel', + 'width=640,height=480,popup', + ) + }) + + it('should set the "pipWindow" signal to the opened window', () => { + const { fakeWindow } = stubPipWindow() + let observed: Window | null = null + + renderAndAct((pip) => { + pip().requestPipWindow(640, 480) + observed = pip().pipWindow + }) + + expect(observed).toBe(fakeWindow) + }) + + it('should persist "pip_open" as "true" to "localStore" after a successful open', () => { + stubPipWindow() + + renderAndAct((pip) => pip().requestPipWindow(640, 480)) + + expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe( + 'true', + ) + }) + + it('should set the PiP document title to "TanStack Query Devtools"', () => { + const { pipDocument } = stubPipWindow() + + renderAndAct((pip) => pip().requestPipWindow(640, 480)) + + expect(pipDocument.title).toBe('TanStack Query Devtools') + }) + + it('should reset the PiP document body margin to "0"', () => { + const { pipDocument } = stubPipWindow() + + renderAndAct((pip) => pip().requestPipWindow(640, 480)) + + expect(pipDocument.body.style.margin).toMatch(/^0(px)?$/) + }) + + it('should clear any existing nodes in the PiP document "head"', () => { + const { pipDocument } = stubPipWindow() + pipDocument.head.appendChild(pipDocument.createElement('meta')) + + renderAndAct((pip) => pip().requestPipWindow(640, 480)) + + expect(pipDocument.head.querySelector('meta')).toBeNull() + }) + + it('should clear any existing nodes in the PiP document "body"', () => { + const { pipDocument } = stubPipWindow() + const leftover = pipDocument.createElement('div') + leftover.id = 'leftover' + pipDocument.body.appendChild(leftover) + + renderAndAct((pip) => pip().requestPipWindow(640, 480)) + + expect(pipDocument.body.querySelector('#leftover')).toBeNull() + }) + }) + + describe('styleSheet propagation', () => { + type FakeCssRule = { readonly cssText: string } + type FakeStyleSheet = { + readonly cssRules?: ArrayLike + readonly href?: string | null + readonly type?: string + readonly media?: { toString: () => string } + readonly ownerNode?: Element | null + } + + function makeCssRules(...cssTexts: Array): ArrayLike { + return cssTexts.map((cssText) => ({ cssText })) + } + + function stubParentStyleSheet(sheet: FakeStyleSheet) { + return vi + .spyOn(document, 'styleSheets', 'get') + .mockReturnValue([sheet] as unknown as StyleSheetList) + } + + it('should copy parent stylesheets as "