From 099c77018ca8bbe3808329566c1ce13247414b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Fri, 2 Oct 2026 22:06:08 +0200 Subject: [PATCH] feat: production readiness fixes and test coverage - Map defects without double wrapping: EffectQueryDefect exposes `defect`, `interrupted` and the original `defectCause`; both error classes get a real `name`, a short `message` and the standard `cause` - Turn Effect functions that throw synchronously into defects - Keep getters on options objects live instead of reading them once - Rebuild a Layer whose build died so retries can recover; expose `runtime` and `dispose()` on every adapter - Only log defects, not typed failures or interruptions - React QueryErrorBoundary decides ownership once per caught error - Boundaries hash queries with their own queryKeyHashFn (BigInt keys) - Solid QueryErrorBoundary handles errors restored during SSR hydration - Add a node `core` test project, rewrite the React query and infinite query tests, add boundary, hydration, getter and runtime-handle type tests - Add CI workflow and run checks before publishing; sideEffects: false Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 51 +++ .github/workflows/release.yml | 13 + README.md | 40 +- packages/effect-query/package.json | 1 + packages/effect-query/src/core/errors.ts | 64 ++- packages/effect-query/src/core/options.ts | 97 +++- packages/effect-query/src/core/query-error.ts | 41 +- packages/effect-query/src/core/runner.ts | 103 ++++- .../src/react/query-error-boundary.ts | 42 +- packages/effect-query/src/react/types.ts | 3 +- .../src/solid/query-error-boundary.ts | 58 ++- packages/effect-query/src/solid/types.ts | 3 +- packages/effect-query/src/svelte/types.ts | 3 +- .../src/vue/query-error-boundary.ts | 26 +- packages/effect-query/src/vue/types.ts | 3 +- .../effect-query/test/core/errors.test.ts | 121 +++++ .../effect-query/test/core/runner.test.ts | 424 ++++++++++++++++++ packages/effect-query/test/react/_helpers.tsx | 10 + .../react/infinite-query-options.test.tsx | 372 ++++++--------- .../test/react/query-error-boundary.test.tsx | 97 ++++ .../test/react/query-options.test.tsx | 338 ++++++++------ .../test/react/runtime-handle.test-d.tsx | 70 +++ .../effect-query/test/solid/hydration.test.ts | 145 ++++++ .../test/solid/query-error-boundary.test.ts | 38 ++ .../test/solid/runtime-handle.test-d.ts | 70 +++ .../effect-query/test/solid/runtime.test.ts | 18 + .../test/svelte/runtime-handle.test-d.ts | 70 +++ .../test/vue/query-error-boundary.test.ts | 44 ++ .../test/vue/runtime-handle.test-d.ts | 70 +++ packages/effect-query/vitest.config.mts | 7 + 30 files changed, 1991 insertions(+), 451 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 packages/effect-query/test/core/errors.test.ts create mode 100644 packages/effect-query/test/core/runner.test.ts create mode 100644 packages/effect-query/test/react/runtime-handle.test-d.tsx create mode 100644 packages/effect-query/test/solid/hydration.test.ts create mode 100644 packages/effect-query/test/solid/runtime-handle.test-d.ts create mode 100644 packages/effect-query/test/svelte/runtime-handle.test-d.ts create mode 100644 packages/effect-query/test/vue/runtime-handle.test-d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f0f3650 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Build + run: pnpm build + + - name: Typecheck + run: pnpm typecheck + + - name: Install Playwright Chromium + working-directory: packages/effect-query + run: pnpm exec playwright install --with-deps chromium + + - name: Test + run: pnpm test + + - name: Lint the published package + working-directory: packages/effect-query + run: | + pnpm dlx publint --strict + pnpm dlx @arethetypeswrong/cli --pack . --profile node16 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 916d1f5..5cdc9cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,9 +28,22 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Build run: pnpm build + - name: Typecheck + run: pnpm typecheck + + - name: Install Playwright Chromium + working-directory: packages/effect-query + run: pnpm exec playwright install --with-deps chromium + + - name: Test + run: pnpm test + - name: Determine npm tag id: determine_npm_tag shell: bash diff --git a/README.md b/README.md index f5cc1a5..672083c 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,23 @@ const runtime = ManagedRuntime.make(GreetingApiLive); export const eqFromRuntime = createEffectQueryFromManagedRuntime(runtime); ``` +## Runtime lifecycle + +`createEffectQuery(layer)` builds the layer lazily, once, on the first query and shares it between every query and mutation. Each factory also exposes the runtime: + +```ts +// Run an Effect outside of a query, e.g. in a router loader. +await eq.runtime.runPromise(effect); + +// Run the finalizers of the scoped services of the layer (sockets, RPC clients, fibers...). +await eq.dispose(); +``` + +- **Dispose when you are done with it.** Call `dispose()` when the factories are no longer used. Typical cases are an HMR update (`import.meta.hot?.dispose(() => eq.dispose())`) or the end of a server request when the factories are created per request. Queries that start after `dispose()` fail with an `EffectQueryDefect`. +- **A failed layer build is retried.** If building the layer dies (e.g. a transient network error while acquiring a resource), the failed runtime is discarded. The next query builds the layer again, so TanStack Query's retries can recover. +- **A runtime you pass in is never replaced.** `createEffectQueryFromManagedRuntime` keeps the runtime you passed in as it is, and `dispose()` disposes it. +- **Create the factories per request on the server.** A factory created at module level shares one runtime between every request. + # Query Example ```tsx @@ -303,6 +320,26 @@ if (error instanceof EffectQueryFailure) { } ``` +| | `EffectQueryFailure` | `EffectQueryDefect` | +| --- | --- | --- | +| When | The Effect failed with a typed failure | The Effect died, or was interrupted | +| `name` / `_tag` | `"EffectQueryFailure"` | `"EffectQueryDefect"` | +| `message` | Short summary (the failure's `message` or `_tag`), safe to show in a UI | Short summary of the defect | +| `cause` (standard `Error` property) | The failure | The defect | +| Payload | `failure`: the first failure, `failureCause`: the full `Cause` | `defect`: the value passed to `Effect.die` or the exception thrown, `defectCause`: the full `Cause`, `interrupted`: `true` if the Effect was interrupted rather than dying | + +Use `Cause.pretty(error.failureCause)` or `Cause.pretty(error.defectCause)` for a full, multi-line report including stack traces. Only show it in development, never in a UI. + +If an Effect function throws instead of returning an Effect, the exception becomes an `EffectQueryDefect` too. + +These errors are class instances and do not survive serialization (`structuredClone`, `JSON`, or SSR payloads): after a round trip, `match`, `failure` and `instanceof` are gone. TanStack Query does not dehydrate failed queries by default, so this only matters if you opt into it with `shouldDehydrateQuery`. In that case, map errors to plain data before they cross the boundary. + +## Cancellation and logging + +- **Cancelling interrupts the Effect.** Every query runs its Effect with TanStack Query's `AbortSignal`, so when a query is cancelled the Effect is interrupted and its finalizers run. A query is cancelled by `queryClient.cancelQueries`, or when its last observer unmounts while it is still fetching. When the last observer unmounts mid-fetch, the query goes back to its previous state rather than finishing in the background. +- **Only defects are logged.** Defects are logged at the `Error` level through the logger of your runtime, so provide a `Logger` layer to route or silence them. Typed failures are expected and reach you through TanStack Query, so they are not logged. Interruptions are not logged either. +- **Spans are named after the key.** Each query and mutation runs in a span named after the first element of its `queryKey` / `mutationKey`. + ## Mutation Error Handling The same pattern works for mutations, allowing you to handle errors in callbacks: @@ -352,7 +389,8 @@ function UserPage({ id }: { id: string }) { - Only errors produced by the given queries are handled. Anything else is rethrown to the next error boundary up the tree, so the `fallback` type is always accurate. - `reset` refetches the failed queries (it uses TanStack Query's `QueryErrorResetBoundary`). -- The boundary resets on its own when the `queryKey` of `query` changes, e.g. when navigating between ids. +- The boundary resets on its own when the `queryKey` of `query` changes, e.g. when navigating between ids. Each query is hashed with its own `queryKeyHashFn`, so keys that need a custom hash (e.g. containing a `BigInt`) work. +- With Solid SSR, a boundary that rendered its fallback on the server keeps it while hydrating. Its `fallback` then receives the typed error from the client's cache if there is one, otherwise an `EffectQueryDefect` wrapping the deserialized server error (`error.defect`). `QueryErrorBoundary` is also exported from `effect-query/solid` (wraps Solid's `ErrorBoundary`, set `throwOnError: true` on the queries) and `effect-query/vue` (uses `onErrorCaptured`; the error is passed to the `#fallback="{ error, reset }"` slot; set `throwOnError: true` so `await query.suspense()` rejects). diff --git a/packages/effect-query/package.json b/packages/effect-query/package.json index 1257a5a..5fc2c5e 100644 --- a/packages/effect-query/package.json +++ b/packages/effect-query/package.json @@ -9,6 +9,7 @@ }, "license": "MIT", "author": "Dominik V\u00edt ", + "sideEffects": false, "exports": { ".": { "import": { diff --git a/packages/effect-query/src/core/errors.ts b/packages/effect-query/src/core/errors.ts index fb5e14b..af0eb02 100644 --- a/packages/effect-query/src/core/errors.ts +++ b/packages/effect-query/src/core/errors.ts @@ -18,10 +18,35 @@ type EffectQueryErrorMatcher< } : Record); +/** A short, single line description of a failure or defect, safe to show in a UI or log line. */ +const describe = (value: unknown): string => { + if (value instanceof Error) { + return value.message === "" + ? value.name + : `${value.name}: ${value.message}`; + } + if (typeof value === "object" && value !== null) { + if ("message" in value && typeof value.message === "string") { + return value.message; + } + if ("_tag" in value && typeof value._tag === "string") { + return value._tag; + } + } + return String(value); +}; + +/** + * The error TanStack Query sees when the Effect failed with an expected, typed failure. + * + * `failure` is the first failure of the Effect, `failureCause` the full `Cause`. `cause` (the + * standard `Error` property) is set to `failure`, so devtools show it as the underlying error. + */ export class EffectQueryFailure< TFailure extends { _tag: string } | never = never, > extends Error { readonly _tag: typeof EffectQueryFailureTag; + override readonly name = EffectQueryFailureTag; readonly failure: TFailure; readonly failureCause: Cause.Cause; constructor( @@ -29,12 +54,19 @@ export class EffectQueryFailure< failure: TFailure, cause: Cause.Cause ) { - super(message); + super(message, { cause: failure }); this._tag = EffectQueryFailureTag; this.failure = failure; this.failureCause = cause; } + static fromCause( + failure: F, + cause: Cause.Cause + ): EffectQueryFailure { + return new EffectQueryFailure(describe(failure), failure, cause); + } + match( matcher: EffectQueryErrorMatcher< TFailure extends { _tag: string } ? TFailure : never, @@ -58,13 +90,35 @@ export class EffectQueryFailure< } } -export class EffectQueryDefect extends Error { +/** + * The error TanStack Query sees when the Effect died (a defect) or was interrupted. + * + * `defectCause` is the `Cause` the Effect ended with. `defect` is the squashed defect, i.e. the + * value passed to `Effect.die` or the exception thrown, which is also the standard `cause`. + */ +export class EffectQueryDefect extends Error { readonly _tag: typeof EffectQueryDefectTag; + override readonly name = EffectQueryDefectTag; + readonly defect: unknown; readonly defectCause: Cause.Cause; - constructor(message: string, defect: TDefect) { - super(message); + constructor(message: string, cause: Cause.Cause) { + const defect = Cause.squash(cause); + super(message, { cause: defect }); this._tag = EffectQueryDefectTag; - this.defectCause = Cause.die(defect); + this.defect = defect; + this.defectCause = cause; + } + + static fromCause(cause: Cause.Cause): EffectQueryDefect { + const message = Cause.hasInterruptsOnly(cause) + ? "The Effect was interrupted" + : describe(Cause.squash(cause)); + return new EffectQueryDefect(message, cause); + } + + /** Whether the Effect was interrupted (e.g. the query was cancelled) rather than dying. */ + get interrupted(): boolean { + return Cause.hasInterruptsOnly(this.defectCause); } match( diff --git a/packages/effect-query/src/core/options.ts b/packages/effect-query/src/core/options.ts index dea55ce..66c3c92 100644 --- a/packages/effect-query/src/core/options.ts +++ b/packages/effect-query/src/core/options.ts @@ -1,4 +1,4 @@ -import { Cause, type Effect, Exit, ManagedRuntime, Option } from "effect"; +import { Cause, Effect, Exit, type ManagedRuntime, Option } from "effect"; import type { Layer } from "effect/Layer"; import { EffectQueryDefect, EffectQueryFailure } from "./errors"; import { EffectQueryRunner } from "./runner"; @@ -43,13 +43,12 @@ export const toEffectQueryError = ( ): EffectQueryFailure | EffectQueryDefect => { const failure = Cause.findErrorOption(cause); if (Option.isSome(failure)) { - return new EffectQueryFailure( - Cause.pretty(cause), + return EffectQueryFailure.fromCause( failure.value as never, cause as Cause.Cause - ); + ) as EffectQueryFailure; } - return new EffectQueryDefect(Cause.pretty(cause), cause); + return EffectQueryDefect.fromCause(cause as Cause.Cause); }; const runToPromise = async ( @@ -74,7 +73,8 @@ const wrapQueryFn = (runner: EffectQueryRunner, queryFn: unknown): unknown => { return (context: QueryFunctionContextLike) => runToPromise( runner, - effectFn(context as never), + // A function that throws instead of returning an Effect surfaces as an `EffectQueryDefect`. + Effect.suspend(() => effectFn(context as never)), spanNameFromKey(context.queryKey, DEFAULT_QUERY_SPAN), context.signal ); @@ -91,11 +91,57 @@ const wrapMutationFn = ( return (variables: unknown, context?: MutationFunctionContextLike) => runToPromise( runner, - effectFn(variables as never, context as never), + Effect.suspend(() => effectFn(variables as never, context as never)), spanNameFromKey(context?.mutationKey, DEFAULT_MUTATION_SPAN) ); }; +/** Keeps the wrapped function stable while the getter keeps returning the same function. */ +const memoizeLast = ( + wrap: (fn: unknown) => unknown, + read: () => unknown +): (() => unknown) => { + let last: { readonly input: unknown; readonly output: unknown } | undefined; + return () => { + const input = read(); + if (last === undefined || last.input !== input) { + last = { input, output: wrap(input) }; + } + return last.output; + }; +}; + +/** + * Copies `options`, replacing `key` with `wrap(options[key])`. + * + * Property descriptors are copied rather than spread, so getters (e.g. Solid's + * `get enabled() { return id() !== undefined }`) stay live instead of being read once. A getter + * for `key` itself stays a getter, wrapped on every access. + */ +const withWrapped = ( + options: T, + key: "mutationFn" | "queryFn", + wrap: (fn: unknown) => unknown +): T => { + const descriptors: PropertyDescriptorMap = + Object.getOwnPropertyDescriptors(options); + const descriptor = descriptors[key]; + const wrapped: PropertyDescriptor = descriptor?.get + ? { + configurable: true, + enumerable: true, + get: memoizeLast(wrap, descriptor.get.bind(options)), + } + : { + configurable: true, + enumerable: true, + value: wrap((options as Record)[key]), + writable: true, + }; + descriptors[key] = wrapped; + return Object.defineProperties({}, descriptors) as T; +}; + /** * Runtime implementation shared by every framework adapter. TanStack Query's own * `queryOptions` helpers are identity functions, so the only work left is turning the @@ -107,27 +153,44 @@ export interface EffectQueryOptionFactories { readonly queryOptions: (options: T) => T; } +/** The runtime handle every adapter's `createEffectQuery` result exposes. */ +export interface EffectQueryRuntimeHandle { + /** + * Disposes the underlying `ManagedRuntime`, running the finalizers of the scoped services of + * the layer. Call it when the factories are no longer used, e.g. on HMR or at the end of a + * server request. Queries started afterwards die with an `EffectQueryDefect`. + */ + readonly dispose: () => Promise; + /** + * The `ManagedRuntime` the Effects run on, e.g. to run an Effect outside of a query with + * `runtime.runPromise`. For `createEffectQuery` this is replaced when building the layer dies. + */ + readonly runtime: ManagedRuntime.ManagedRuntime; +} + export const makeOptionFactories = ( runner: EffectQueryRunner -): EffectQueryOptionFactories => { - const queryOptions = (options: T): T => ({ - ...options, - queryFn: wrapQueryFn(runner, options.queryFn), - }); +): EffectQueryOptionFactories & EffectQueryRuntimeHandle => { + const queryOptions = (options: T): T => + withWrapped(options, "queryFn", (queryFn) => wrapQueryFn(runner, queryFn)); return { + dispose: () => runner.dispose(), infiniteQueryOptions: queryOptions, - mutationOptions: (options) => ({ - ...options, - mutationFn: wrapMutationFn(runner, options.mutationFn), - }), + mutationOptions: (options) => + withWrapped(options, "mutationFn", (mutationFn) => + wrapMutationFn(runner, mutationFn) + ), queryOptions, + get runtime() { + return runner.runtime; + }, }; }; export const runnerFromLayer = ( layer: Layer -): EffectQueryRunner => new EffectQueryRunner(ManagedRuntime.make(layer)); +): EffectQueryRunner => EffectQueryRunner.fromLayer(layer); export const runnerFromManagedRuntime = ( runtime: ManagedRuntime.ManagedRuntime diff --git a/packages/effect-query/src/core/query-error.ts b/packages/effect-query/src/core/query-error.ts index e4ec322..1d68392 100644 --- a/packages/effect-query/src/core/query-error.ts +++ b/packages/effect-query/src/core/query-error.ts @@ -13,15 +13,50 @@ export interface QueryClientLike { export interface QueryKeyOwner { readonly queryKey: readonly unknown[]; + readonly queryKeyHashFn?: + | ((queryKey: readonly unknown[]) => string) + | undefined; } /** Normalises the `query` prop of the error boundaries, which accepts one or many options. */ +export const toQueryKeyOwners = ( + query: QueryKeyOwner | readonly QueryKeyOwner[] +): readonly QueryKeyOwner[] => + Array.isArray(query) ? query : [query as QueryKeyOwner]; + +/** The query keys of the `query` prop of the error boundaries. */ export const toQueryKeys = ( query: QueryKeyOwner | readonly QueryKeyOwner[] ): readonly (readonly unknown[])[] => - Array.isArray(query) - ? query.map((options: QueryKeyOwner) => options.queryKey) - : [(query as QueryKeyOwner).queryKey]; + toQueryKeyOwners(query).map((options) => options.queryKey); + +/** + * A stable identity for the guarded queries, used to reset a boundary when it starts guarding + * other queries. Each query is hashed with its own `queryKeyHashFn`, like TanStack Query does, so + * keys that the default hash cannot handle (e.g. containing a `BigInt`) work. + */ +export const toQueryHash = ( + owners: readonly QueryKeyOwner[], + defaultHash: (queryKey: readonly unknown[]) => string +): string => + owners + .map((options) => (options.queryKeyHashFn ?? defaultHash)(options.queryKey)) + .join("\n"); + +/** The error currently stored on the first of the given queries that has one. */ +export const findErrorOfQueries = ( + queryClient: QueryClientLike, + queryKeys: readonly (readonly unknown[])[] +): unknown => { + for (const queryKey of queryKeys) { + const error = queryClient.getQueryCache().find({ exact: true, queryKey }) + ?.state.error; + if (error !== null && error !== undefined) { + return error; + } + } + return undefined; +}; /** * Whether `error` is the error currently stored on one of the given queries. diff --git a/packages/effect-query/src/core/runner.ts b/packages/effect-query/src/core/runner.ts index bcb3235..c647d3f 100644 --- a/packages/effect-query/src/core/runner.ts +++ b/packages/effect-query/src/core/runner.ts @@ -1,29 +1,94 @@ -import { Effect, type Exit } from "effect"; -import type { ManagedRuntime } from "effect/ManagedRuntime"; - -export class EffectQueryRunner< - // biome-ignore lint/suspicious/noExplicitAny: generic - TManagedRuntime extends ManagedRuntime = ManagedRuntime< - // biome-ignore lint/suspicious/noExplicitAny: generic - any, - never - >, -> { - readonly runtime: TManagedRuntime; - constructor(runtime: TManagedRuntime) { - this.runtime = runtime; +import { Cause, Effect, Exit, ManagedRuntime } from "effect"; +import type { Layer } from "effect/Layer"; + +// biome-ignore lint/suspicious/noExplicitAny: generic +type AnyManagedRuntime = ManagedRuntime.ManagedRuntime; + +export interface EffectQueryRunOptions { + readonly signal?: AbortSignal | undefined; +} + +/** + * Runs the Effects of queries and mutations on a `ManagedRuntime`. + * + * A runner created from a `Layer` owns its runtime: when building the layer dies (e.g. a + * transient network error while acquiring a resource), the failed runtime is discarded and the + * next run builds the layer again, so TanStack Query's retries can recover. A runtime passed in + * by the user is never replaced. + */ +export class EffectQueryRunner { + private currentRuntime: AnyManagedRuntime; + private readonly makeRuntime: (() => AnyManagedRuntime) | undefined; + private disposed = false; + + constructor( + runtime: AnyManagedRuntime, + makeRuntime?: () => AnyManagedRuntime + ) { + this.currentRuntime = runtime; + this.makeRuntime = makeRuntime; + } + + static fromLayer( + layer: Layer + ): EffectQueryRunner { + const makeRuntime = () => ManagedRuntime.make(layer); + return new EffectQueryRunner(makeRuntime(), makeRuntime); + } + + get runtime(): AnyManagedRuntime { + return this.currentRuntime; + } + + /** Disposes the runtime, running the finalizers of every scoped service of the layer. */ + dispose(): Promise { + this.disposed = true; + return this.currentRuntime.dispose(); } async run( effect: Effect.Effect, span: string, - options: { signal?: AbortSignal } = {} + options: EffectQueryRunOptions = {} ): Promise> { + const runtime = this.currentRuntime; + if (runtime.cachedContext === undefined && this.makeRuntime) { + const built = await Effect.runPromiseExit(runtime.contextEffect, { + signal: options.signal, + }); + if (Exit.isFailure(built)) { + this.discardFailedRuntime(runtime, built.cause); + return built as Exit.Exit; + } + } + // Typed failures are expected and handled through TanStack Query, interruptions are routine + // cancellations. Only defects are logged, through the logger of the runtime. const runnable = Effect.scoped( - effect.pipe(Effect.withSpan(span), Effect.tapCause(Effect.logError)) + effect.pipe( + Effect.withSpan(span), + Effect.tapDefect((defect) => + Effect.logError("effect-query: unexpected defect", defect) + ) + ) ); - return await this.runtime.runPromiseExit(runnable, { - signal: options.signal, - }); + return await runtime.runPromiseExit(runnable, { signal: options.signal }); + } + + private discardFailedRuntime( + runtime: AnyManagedRuntime, + cause: Cause.Cause + ): void { + // An interrupted build (the query was cancelled) is still shared by concurrent runs, only a + // build that actually died is replaced. Concurrent runs that saw the same failure only swap once. + if ( + Cause.hasInterruptsOnly(cause) || + this.disposed || + this.currentRuntime !== runtime || + !this.makeRuntime + ) { + return; + } + this.currentRuntime = this.makeRuntime(); + runtime.dispose().catch(() => undefined); } } diff --git a/packages/effect-query/src/react/query-error-boundary.ts b/packages/effect-query/src/react/query-error-boundary.ts index e710eb2..68985ab 100644 --- a/packages/effect-query/src/react/query-error-boundary.ts +++ b/packages/effect-query/src/react/query-error-boundary.ts @@ -8,7 +8,13 @@ import { useQueryErrorResetBoundary, } from "@tanstack/react-query"; import { Component, createElement, type ReactNode } from "react"; -import { isErrorOfQueries, toQueryKeys } from "../core/query-error"; +import { + isErrorOfQueries, + type QueryKeyOwner, + toQueryHash, + toQueryKeyOwners, + toQueryKeys, +} from "../core/query-error"; /** Anything carrying a `queryKey`, e.g. the options returned by `eq.queryOptions`. */ export interface QueryOptionsWithKey { @@ -104,23 +110,36 @@ class Boundary extends Component { return { caught: null, resetKey: props.resetKey }; } + // Whether a caught error belongs to the guarded queries is decided once, on the first render + // after catching it, while the error is still stored on the query. Afterwards the query may be + // garbage collected, refetched or reset while the fallback is shown, which must not turn the + // error into someone else's. + private readonly owned = new WeakMap(); + readonly reset = (): void => { this.props.resetErrorBoundary(); this.setState({ caught: null }); }; + private isOwned(caught: { readonly error: unknown }): boolean { + let owned = this.owned.get(caught); + if (owned === undefined) { + owned = isErrorOfQueries( + this.props.queryClient, + this.props.queryKeys, + caught.error + ); + this.owned.set(caught, owned); + } + return owned; + } + override render(): ReactNode { const { caught } = this.state; if (caught === null) { return this.props.children; } - if ( - !isErrorOfQueries( - this.props.queryClient, - this.props.queryKeys, - caught.error - ) - ) { + if (!this.isOwned(caught)) { // Not ours: rethrowing from render hands the error to the next boundary up the tree. throw caught.error; } @@ -153,7 +172,10 @@ export function QueryErrorBoundary( ): ReactNode { const queryClient = useQueryClient(); const { reset } = useQueryErrorResetBoundary(); - const queryKeys = toQueryKeys(props.query); + const owners = toQueryKeyOwners( + props.query as QueryKeyOwner | readonly QueryKeyOwner[] + ); + const queryKeys = owners.map((options) => options.queryKey); return createElement( Boundary, @@ -162,7 +184,7 @@ export function QueryErrorBoundary( queryClient, queryKeys, resetErrorBoundary: reset, - resetKey: hashKey(queryKeys), + resetKey: toQueryHash(owners, hashKey), }, props.children ); diff --git a/packages/effect-query/src/react/types.ts b/packages/effect-query/src/react/types.ts index 0cc6599..40a88e2 100644 --- a/packages/effect-query/src/react/types.ts +++ b/packages/effect-query/src/react/types.ts @@ -1,8 +1,9 @@ +import type { EffectQueryRuntimeHandle } from "../core/options"; import type { EffectInfiniteQueryOptionsFunction } from "./infinite-query-options"; import type { EffectMutationOptionsFunction } from "./mutation-options"; import type { EffectQueryOptionsFunction } from "./query-options"; -export interface EffectQuery { +export interface EffectQuery extends EffectQueryRuntimeHandle { infiniteQueryOptions: EffectInfiniteQueryOptionsFunction; mutationOptions: EffectMutationOptionsFunction; queryOptions: EffectQueryOptionsFunction; diff --git a/packages/effect-query/src/solid/query-error-boundary.ts b/packages/effect-query/src/solid/query-error-boundary.ts index 876c67d..9f6ec8e 100644 --- a/packages/effect-query/src/solid/query-error-boundary.ts +++ b/packages/effect-query/src/solid/query-error-boundary.ts @@ -6,15 +6,26 @@ import { type QueryKey, useQueryClient, } from "@tanstack/solid-query"; +import { Cause } from "effect"; import { + catchError, createComponent, createEffect, createMemo, ErrorBoundary, type JSX, on, + sharedConfig, } from "solid-js"; -import { isErrorOfQueries, toQueryKeys } from "../core/query-error"; +import { EffectQueryDefect } from "../core/errors"; +import { + findErrorOfQueries, + isErrorOfQueries, + type QueryKeyOwner, + toQueryHash, + toQueryKeyOwners, + toQueryKeys, +} from "../core/query-error"; /** Anything carrying a `queryKey`, e.g. the options returned by `eq.queryOptions`. */ export interface QueryOptionsWithKey { @@ -70,6 +81,18 @@ export interface QueryErrorBoundaryProps { readonly query: TQuery; } +/** + * A deserialized error has lost its class, so `error.match` would not exist. Prefer the typed + * error the client's query holds, otherwise expose the server error as a defect. + */ +const reviveHydratedError = ( + queryClient: QueryClient, + queryKeys: readonly (readonly unknown[])[], + error: unknown +): unknown => + findErrorOfQueries(queryClient, queryKeys) ?? + EffectQueryDefect.fromCause(Cause.die(error)); + /** * An `ErrorBoundary` bound to the query options it guards, so `fallback` receives the typed * `EffectQueryFailure | EffectQueryDefect` of that query. @@ -93,13 +116,21 @@ export function QueryErrorBoundary( props: QueryErrorBoundaryProps ): JSX.Element { const queryClient = useQueryClient(); - const queryKeys = createMemo(() => toQueryKeys(props.query)); + const owners = createMemo(() => + toQueryKeyOwners(props.query as QueryKeyOwner | readonly QueryKeyOwner[]) + ); + const queryKeys = createMemo(() => + owners().map((options) => options.queryKey) + ); let resetCaught: (() => void) | undefined; + // Every error raised on the client reaches the boundary through its children. While hydrating, + // `ErrorBoundary` can also start out with the error the boundary caught on the server. + const fromChildren = new Set(); // Moving to a different query (e.g. navigating from one id to another) starts over. createEffect( on( - () => hashKey(queryKeys()), + () => toQueryHash(owners(), hashKey), () => resetCaught?.(), { defer: true } ) @@ -107,19 +138,34 @@ export function QueryErrorBoundary( return createComponent(ErrorBoundary, { get children() { - return props.children; + return catchError( + () => props.children, + (error: unknown) => { + fromChildren.add(error); + throw error; + } + ); }, fallback: (error: unknown, reset: () => void) => { - if (!isErrorOfQueries(queryClient, queryKeys(), error)) { + const owned = isErrorOfQueries(queryClient, queryKeys(), error); + // An error that did not come from the children is the one this boundary caught on the server, + // deserialized while hydrating. It is never the error stored on the client's query, but the + // server already rethrew the errors it did not own, so it is ours. + const hydrated = + !owned && Boolean(sharedConfig.context) && !fromChildren.has(error); + if (!(owned || hydrated)) { // Not ours: the fallback runs outside of this boundary, so the error reaches the next one. throw error; } resetCaught = () => { resetCaught = undefined; + fromChildren.clear(); reset(); }; return props.fallback({ - error: error as InferQueryOptionsError, + error: (hydrated + ? reviveHydratedError(queryClient, queryKeys(), error) + : error) as InferQueryOptionsError, reset: resetCaught, }); }, diff --git a/packages/effect-query/src/solid/types.ts b/packages/effect-query/src/solid/types.ts index 0cc6599..40a88e2 100644 --- a/packages/effect-query/src/solid/types.ts +++ b/packages/effect-query/src/solid/types.ts @@ -1,8 +1,9 @@ +import type { EffectQueryRuntimeHandle } from "../core/options"; import type { EffectInfiniteQueryOptionsFunction } from "./infinite-query-options"; import type { EffectMutationOptionsFunction } from "./mutation-options"; import type { EffectQueryOptionsFunction } from "./query-options"; -export interface EffectQuery { +export interface EffectQuery extends EffectQueryRuntimeHandle { infiniteQueryOptions: EffectInfiniteQueryOptionsFunction; mutationOptions: EffectMutationOptionsFunction; queryOptions: EffectQueryOptionsFunction; diff --git a/packages/effect-query/src/svelte/types.ts b/packages/effect-query/src/svelte/types.ts index f7b2293..2faf1ae 100644 --- a/packages/effect-query/src/svelte/types.ts +++ b/packages/effect-query/src/svelte/types.ts @@ -1,8 +1,9 @@ +import type { EffectQueryRuntimeHandle } from "../core/options"; import type { EffectInfiniteQueryOptionsFunction } from "./infinite-query-options"; import type { EffectMutationOptionsFunction } from "./mutation-options"; import type { EffectQueryOptionsFunction } from "./query-options"; -export interface EffectQuery { +export interface EffectQuery extends EffectQueryRuntimeHandle { infiniteQueryOptions: EffectInfiniteQueryOptionsFunction; mutationOptions: EffectMutationOptionsFunction; queryOptions: EffectQueryOptionsFunction; diff --git a/packages/effect-query/src/vue/query-error-boundary.ts b/packages/effect-query/src/vue/query-error-boundary.ts index 5e731b5..46f0f68 100644 --- a/packages/effect-query/src/vue/query-error-boundary.ts +++ b/packages/effect-query/src/vue/query-error-boundary.ts @@ -16,13 +16,15 @@ import { type SetupContext, type SlotsType, shallowRef, + unref, type VNodeChild, watch, } from "vue"; import { isErrorOfQueries, type QueryKeyOwner, - toQueryKeys, + toQueryHash, + toQueryKeyOwners, } from "../core/query-error"; /** Anything carrying a `queryKey`, e.g. the options returned by `eq.queryOptions`. */ @@ -73,12 +75,21 @@ const unwrapQueryKey = (value: unknown): unknown => { return value; }; +/** The guarded queries with their keys resolved, see {@link unwrapQueryKey}. */ +const resolveQueryOwners = ( + query: QueryErrorSource +): readonly QueryKeyOwner[] => + toQueryKeyOwners(query as QueryKeyOwner | readonly QueryKeyOwner[]).map( + (options) => ({ + queryKey: unwrapQueryKey(options.queryKey) as readonly unknown[], + queryKeyHashFn: unref(options.queryKeyHashFn), + }) + ); + const resolveQueryKeys = ( query: QueryErrorSource ): readonly (readonly unknown[])[] => - toQueryKeys(query as QueryKeyOwner | readonly QueryKeyOwner[]).map( - (queryKey) => unwrapQueryKey(queryKey) as readonly unknown[] - ); + resolveQueryOwners(query).map((options) => options.queryKey); /** * Narrows `error` to the error type of `query` when it is the error currently stored on that query @@ -135,7 +146,10 @@ export const QueryErrorBoundary = defineComponent( }: SetupContext>> ) => { const queryClient = useQueryClient(); - const queryKeys = computed(() => resolveQueryKeys(props.query)); + const owners = computed(() => resolveQueryOwners(props.query)); + const queryKeys = computed(() => + owners.value.map((options) => options.queryKey) + ); const caught = shallowRef<{ readonly error: unknown } | null>(null); const reset = (): void => { @@ -143,7 +157,7 @@ export const QueryErrorBoundary = defineComponent( }; // Moving to a different query (e.g. navigating from one id to another) starts over. - watch(() => hashKey(queryKeys.value), reset); + watch(() => toQueryHash(owners.value, hashKey), reset); onErrorCaptured((error) => { if (!isErrorOfQueries(queryClient, queryKeys.value, error)) { diff --git a/packages/effect-query/src/vue/types.ts b/packages/effect-query/src/vue/types.ts index 0cc6599..40a88e2 100644 --- a/packages/effect-query/src/vue/types.ts +++ b/packages/effect-query/src/vue/types.ts @@ -1,8 +1,9 @@ +import type { EffectQueryRuntimeHandle } from "../core/options"; import type { EffectInfiniteQueryOptionsFunction } from "./infinite-query-options"; import type { EffectMutationOptionsFunction } from "./mutation-options"; import type { EffectQueryOptionsFunction } from "./query-options"; -export interface EffectQuery { +export interface EffectQuery extends EffectQueryRuntimeHandle { infiniteQueryOptions: EffectInfiniteQueryOptionsFunction; mutationOptions: EffectMutationOptionsFunction; queryOptions: EffectQueryOptionsFunction; diff --git a/packages/effect-query/test/core/errors.test.ts b/packages/effect-query/test/core/errors.test.ts new file mode 100644 index 0000000..cdf5102 --- /dev/null +++ b/packages/effect-query/test/core/errors.test.ts @@ -0,0 +1,121 @@ +import { Cause, Data } from "effect"; +import { describe, expect, test } from "vitest"; +import { EffectQueryDefect, EffectQueryFailure } from "../../src/core/errors"; +import { toEffectQueryError } from "../../src/core/options"; + +class NotFound extends Data.TaggedError("NotFound")<{ id: string }> {} +class Forbidden extends Data.TaggedError("Forbidden")<{ + message: string; +}> {} + +describe("toEffectQueryError", () => { + test("a typed failure becomes an EffectQueryFailure", () => { + const failure = new NotFound({ id: "1" }); + const cause = Cause.fail(failure); + const error = toEffectQueryError(cause); + + expect(error).toBeInstanceOf(EffectQueryFailure); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe("EffectQueryFailure"); + expect(error._tag).toBe("EffectQueryFailure"); + const typed = error as EffectQueryFailure; + expect(typed.failure).toBe(failure); + expect(typed.failureCause).toBe(cause); + expect(typed.cause).toBe(failure); + }); + + test("the failure message is short and never contains a stack trace", () => { + const tagOnly = toEffectQueryError(Cause.fail(new NotFound({ id: "1" }))); + const withMessage = toEffectQueryError( + Cause.fail(new Forbidden({ message: "no access" })) + ); + + expect(tagOnly.message).toBe("NotFound"); + expect(withMessage.message).toBe("Forbidden: no access"); + expect(tagOnly.message).not.toContain("\n"); + }); + + test("a defect keeps the original defect", () => { + const boom = new Error("boom"); + const cause = Cause.die(boom); + const error = toEffectQueryError(cause); + + expect(error).toBeInstanceOf(EffectQueryDefect); + expect(error.name).toBe("EffectQueryDefect"); + expect(error.message).toBe("Error: boom"); + const defect = error as EffectQueryDefect; + expect(defect.defect).toBe(boom); + expect(defect.cause).toBe(boom); + expect(defect.defectCause).toBe(cause); + expect(Cause.squash(defect.defectCause)).toBe(boom); + expect(defect.interrupted).toBe(false); + }); + + test("a non Error defect is described by its value", () => { + expect(toEffectQueryError(Cause.die("plain string")).message).toBe( + "plain string" + ); + }); + + test("an interruption becomes an interrupted EffectQueryDefect", () => { + const cause = Cause.interrupt(1); + const error = toEffectQueryError(cause) as EffectQueryDefect; + + expect(error).toBeInstanceOf(EffectQueryDefect); + expect(error.interrupted).toBe(true); + expect(error.message).toBe("The Effect was interrupted"); + expect(Cause.hasInterrupts(error.defectCause)).toBe(true); + }); + + test("a failure wins over a concurrent defect", () => { + const failure = new NotFound({ id: "1" }); + const error = toEffectQueryError( + Cause.combine(Cause.fail(failure), Cause.die(new Error("boom"))) + ) as EffectQueryFailure; + + expect(error).toBeInstanceOf(EffectQueryFailure); + expect(error.failure).toBe(failure); + expect(Cause.hasDies(error.failureCause)).toBe(true); + }); +}); + +describe("match", () => { + const notFound = EffectQueryFailure.fromCause( + new NotFound({ id: "1" }) as NotFound | Forbidden, + Cause.fail(new NotFound({ id: "1" }) as NotFound | Forbidden) + ); + + test("calls the handler of the failure's tag", () => { + expect( + notFound.match({ + NotFound: (failure) => `missing ${failure.id}`, + OrElse: () => "other", + }) + ).toBe("missing 1"); + }); + + test("falls back to OrElse with the cause when the tag has no handler", () => { + const result = notFound.match({ + Forbidden: () => "forbidden", + OrElse: (cause) => cause, + }); + expect(result).toBe(notFound.failureCause); + }); + + test("falls back to OrElse for an untagged failure at runtime", () => { + const untagged = new EffectQueryFailure( + "untagged", + "oops" as unknown as NotFound, + Cause.fail("oops" as unknown as NotFound) + ); + expect( + untagged.match({ NotFound: () => "missing", OrElse: () => "other" }) + ).toBe("other"); + }); + + test("a defect always matches OrElse with its own cause", () => { + const cause = Cause.die(new Error("boom")); + const defect = EffectQueryDefect.fromCause(cause); + expect(defect.match({ OrElse: (received) => received })).toBe(cause); + }); +}); diff --git a/packages/effect-query/test/core/runner.test.ts b/packages/effect-query/test/core/runner.test.ts new file mode 100644 index 0000000..06bee3d --- /dev/null +++ b/packages/effect-query/test/core/runner.test.ts @@ -0,0 +1,424 @@ +import { + Cause, + Context, + Data, + Effect, + Layer, + Logger, + ManagedRuntime, +} from "effect"; +import { describe, expect, test, vi } from "vitest"; +import { EffectQueryDefect, EffectQueryFailure } from "../../src/core/errors"; +import { + makeOptionFactories, + runnerFromLayer, + runnerFromManagedRuntime, +} from "../../src/core/options"; + +class NotFound extends Data.TaggedError("NotFound")<{ id: string }> {} + +class Db extends Context.Service< + Db, + { readonly get: (id: string) => Effect.Effect } +>()("test/core/Db") {} + +const dbImpl: Db["Service"] = { + get: (id) => + id === "missing" + ? Effect.fail(new NotFound({ id })) + : Effect.succeed(`row:${id}`), +}; + +const getRow = (id: string) => + Effect.gen(function* () { + const db = yield* Db; + return yield* db.get(id); + }); + +type QueryFn = (context: { + queryKey: readonly unknown[]; + signal: AbortSignal; +}) => Promise; +type MutationFn = ( + variables: unknown, + context?: { mutationKey?: readonly unknown[] } +) => Promise; + +const runQuery = ( + options: { queryFn?: unknown; queryKey?: readonly unknown[] }, + signal = new AbortController().signal +) => + (options.queryFn as QueryFn)({ + queryKey: options.queryKey ?? ["test"], + signal, + }); + +/** A Db layer that counts how often it is built and can be made to die while building. */ +const countedDb = (dieOnBuild: (build: number) => boolean = () => false) => { + const counter = { builds: 0, releases: 0 }; + const layer = Layer.effect(Db)( + Effect.acquireRelease( + Effect.suspend(() => { + counter.builds += 1; + return dieOnBuild(counter.builds) + ? Effect.die(new Error(`build ${counter.builds} failed`)) + : Effect.succeed(dbImpl); + }), + () => + Effect.sync(() => { + counter.releases += 1; + }) + ) + ); + return { counter, layer }; +}; + +describe("query functions", () => { + const eq = makeOptionFactories(runnerFromLayer(Layer.succeed(Db)(dbImpl))); + + test("resolves with the value of the Effect", async () => { + const options = eq.queryOptions({ + queryFn: () => getRow("1"), + queryKey: ["row", "1"], + }); + await expect(runQuery(options)).resolves.toBe("row:1"); + }); + + test("rejects with an EffectQueryFailure for a typed failure", async () => { + const options = eq.queryOptions({ + queryFn: () => getRow("missing"), + queryKey: ["row", "missing"], + }); + const error = await runQuery(options).catch((e: unknown) => e); + expect(error).toBeInstanceOf(EffectQueryFailure); + expect((error as EffectQueryFailure).failure.id).toBe("missing"); + }); + + test("a function that throws instead of returning an Effect rejects with a defect", async () => { + const thrown = new Error("sync throw"); + const options = eq.queryOptions({ + queryFn: () => { + throw thrown; + }, + queryKey: ["throws"], + }); + const error = await runQuery(options).catch((e: unknown) => e); + expect(error).toBeInstanceOf(EffectQueryDefect); + expect((error as EffectQueryDefect).defect).toBe(thrown); + }); + + test("a mutation function that throws rejects with a defect", async () => { + const thrown = new Error("sync throw"); + const options = eq.mutationOptions({ + mutationFn: (_: string) => { + throw thrown; + }, + }); + const error = await (options.mutationFn as MutationFn)("x").catch( + (e: unknown) => e + ); + expect(error).toBeInstanceOf(EffectQueryDefect); + expect((error as EffectQueryDefect).defect).toBe(thrown); + }); + + test("aborting the signal interrupts the Effect and runs its finalizers", async () => { + const finalized = vi.fn(); + const controller = new AbortController(); + const options = eq.queryOptions({ + queryFn: () => Effect.never.pipe(Effect.ensuring(Effect.sync(finalized))), + queryKey: ["never"], + }); + const pending = runQuery(options, controller.signal).catch( + (e: unknown) => e + ); + controller.abort(); + const error = await pending; + + expect(finalized).toHaveBeenCalledOnce(); + expect(error).toBeInstanceOf(EffectQueryDefect); + expect((error as EffectQueryDefect).interrupted).toBe(true); + }); + + test("names the span after the head of the query key", async () => { + const spanName = () => Effect.map(Effect.currentSpan, (span) => span.name); + await expect( + runQuery(eq.queryOptions({ queryFn: spanName, queryKey: ["users", 1] })) + ).resolves.toBe("users"); + await expect( + runQuery(eq.queryOptions({ queryFn: spanName, queryKey: [1, "users"] })) + ).resolves.toBe("effect-query"); + }); + + test("names the mutation span after the mutation key", async () => { + const options = eq.mutationOptions({ + mutationFn: (_: string) => + Effect.map(Effect.currentSpan, (span) => span.name), + }); + const mutationFn = options.mutationFn as unknown as MutationFn; + await expect( + mutationFn("x", { mutationKey: ["rename-user"] }) + ).resolves.toBe("rename-user"); + await expect(mutationFn("x")).resolves.toBe("effect-query-mutation"); + }); + + test("passes a non function queryFn and mutationFn through", () => { + const skip = Symbol("skip"); + expect(eq.queryOptions({ queryFn: skip }).queryFn).toBe(skip); + expect(eq.mutationOptions({ mutationFn: undefined }).mutationFn).toBe( + undefined + ); + }); +}); + +describe("options objects", () => { + const eq = makeOptionFactories(runnerFromLayer(Layer.empty)); + + test("keeps every other option untouched", () => { + const select = (value: string) => value.length; + const options = eq.queryOptions({ + queryFn: () => Effect.succeed("value"), + queryKey: ["untouched"], + retry: 3, + select, + staleTime: 1000, + }); + expect(options).toMatchObject({ + queryKey: ["untouched"], + retry: 3, + select, + staleTime: 1000, + }); + }); + + test("keeps getters live instead of reading them once", () => { + let id: string | undefined; + const options = eq.queryOptions({ + get enabled() { + return id !== undefined; + }, + queryFn: () => Effect.succeed(id), + get queryKey() { + return ["user", id] as const; + }, + }); + + expect(options.enabled).toBe(false); + expect(options.queryKey).toEqual(["user", undefined]); + id = "1"; + expect(options.enabled).toBe(true); + expect(options.queryKey).toEqual(["user", "1"]); + }); + + test("wraps a queryFn getter on access and keeps the wrapper stable", async () => { + const skip = Symbol("skip"); + let id: string | undefined; + const fetchUser = () => Effect.succeed(`user:${id}`); + const options = eq.queryOptions({ + get queryFn() { + return id === undefined ? skip : fetchUser; + }, + queryKey: ["user"], + }); + + expect(options.queryFn).toBe(skip); + id = "1"; + const wrapped = options.queryFn; + expect(typeof wrapped).toBe("function"); + expect(options.queryFn).toBe(wrapped); + await expect(runQuery(options)).resolves.toBe("user:1"); + }); + + test("does not mutate the options it is given", () => { + const queryFn = () => Effect.succeed(1); + const input = { queryFn, queryKey: ["immutable"] }; + eq.queryOptions(input); + expect(input.queryFn).toBe(queryFn); + }); +}); + +describe("runtime lifecycle", () => { + test("builds the layer once for concurrent queries", async () => { + const { counter, layer } = countedDb(); + const eq = makeOptionFactories(runnerFromLayer(layer)); + const options = eq.queryOptions({ + queryFn: () => getRow("1"), + queryKey: ["row"], + }); + + await Promise.all([ + runQuery(options), + runQuery(options), + runQuery(options), + ]); + await runQuery(options); + expect(counter.builds).toBe(1); + }); + + test("rebuilds the layer after building it died, so retries can recover", async () => { + const { counter, layer } = countedDb((build) => build === 1); + const eq = makeOptionFactories(runnerFromLayer(layer)); + const firstRuntime = eq.runtime; + const options = eq.queryOptions({ + queryFn: () => getRow("1"), + queryKey: ["row"], + }); + + const error = await runQuery(options).catch((e: unknown) => e); + expect(error).toBeInstanceOf(EffectQueryDefect); + expect(((error as EffectQueryDefect).defect as Error).message).toBe( + "build 1 failed" + ); + + await expect(runQuery(options)).resolves.toBe("row:1"); + expect(counter.builds).toBe(2); + expect(eq.runtime).not.toBe(firstRuntime); + }); + + test("concurrent queries sharing a dying build only rebuild once", async () => { + const { counter, layer } = countedDb((build) => build === 1); + const eq = makeOptionFactories(runnerFromLayer(layer)); + const options = eq.queryOptions({ + queryFn: () => getRow("1"), + queryKey: ["row"], + }); + + const results = await Promise.allSettled([ + runQuery(options), + runQuery(options), + ]); + expect(results.map((result) => result.status)).toEqual([ + "rejected", + "rejected", + ]); + await expect(runQuery(options)).resolves.toBe("row:1"); + expect(counter.builds).toBe(2); + }); + + test("cancelling a query while the layer builds does not discard the build", async () => { + let builds = 0; + const slowLayer = Layer.effect(Db)( + Effect.suspend(() => { + builds += 1; + return Effect.as(Effect.sleep("30 millis"), dbImpl); + }) + ); + const eq = makeOptionFactories(runnerFromLayer(slowLayer)); + const options = eq.queryOptions({ + queryFn: () => getRow("1"), + queryKey: ["row"], + }); + + const controller = new AbortController(); + const cancelled = runQuery(options, controller.signal).catch( + (e: unknown) => e + ); + const other = runQuery(options); + controller.abort(); + + expect(await cancelled).toBeInstanceOf(EffectQueryDefect); + await expect(other).resolves.toBe("row:1"); + expect(builds).toBe(1); + }); + + test("never replaces a runtime passed in by the user", async () => { + let builds = 0; + const runtime = ManagedRuntime.make( + Layer.effect(Db)( + Effect.suspend(() => { + builds += 1; + return Effect.die(new Error("down")); + }) + ) + ); + const eq = makeOptionFactories(runnerFromManagedRuntime(runtime)); + const options = eq.queryOptions({ + queryFn: () => getRow("1"), + queryKey: ["row"], + }); + + await expect(runQuery(options)).rejects.toBeInstanceOf(EffectQueryDefect); + await expect(runQuery(options)).rejects.toBeInstanceOf(EffectQueryDefect); + expect(eq.runtime).toBe(runtime); + expect(builds).toBe(1); + }); + + test("exposes the runtime to run Effects outside of queries", async () => { + const eq = makeOptionFactories(runnerFromLayer(Layer.succeed(Db)(dbImpl))); + await expect( + eq.runtime.runPromise(getRow("2") as Effect.Effect) + ).resolves.toBe("row:2"); + }); + + test("dispose runs the finalizers of the layer and stops later queries", async () => { + const { counter, layer } = countedDb(); + const eq = makeOptionFactories(runnerFromLayer(layer)); + const options = eq.queryOptions({ + queryFn: () => getRow("1"), + queryKey: ["row"], + }); + + await runQuery(options); + await eq.dispose(); + expect(counter.releases).toBe(1); + + await expect(runQuery(options)).rejects.toBeInstanceOf(EffectQueryDefect); + await expect(runQuery(options)).rejects.toBeInstanceOf(EffectQueryDefect); + expect(counter.builds).toBe(1); + }); +}); + +describe("logging", () => { + const withLogs = () => { + const logs: { level: string; message: unknown }[] = []; + const logger = Logger.make(({ logLevel, message }) => { + logs.push({ level: logLevel, message }); + }); + const eq = makeOptionFactories( + runnerFromLayer( + Layer.merge(Layer.succeed(Db)(dbImpl), Logger.layer([logger])) + ) + ); + return { eq, logs }; + }; + + test("does not log typed failures, they are handled through TanStack Query", async () => { + const { eq, logs } = withLogs(); + await runQuery( + eq.queryOptions({ + queryFn: () => Effect.fail(new NotFound({ id: "1" })), + queryKey: ["fail"], + }) + ).catch(() => undefined); + expect(logs).toEqual([]); + }); + + test("does not log interruptions", async () => { + const { eq, logs } = withLogs(); + const controller = new AbortController(); + const pending = runQuery( + eq.queryOptions({ queryFn: () => Effect.never, queryKey: ["never"] }), + controller.signal + ).catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + await pending; + expect(logs).toEqual([]); + }); + + test("logs defects at the error level", async () => { + const { eq, logs } = withLogs(); + const boom = new Error("boom"); + await runQuery( + eq.queryOptions({ queryFn: () => Effect.die(boom), queryKey: ["die"] }) + ).catch(() => undefined); + expect(logs).toHaveLength(1); + expect(logs[0]?.level).toBe("Error"); + expect(logs[0]?.message).toEqual(["effect-query: unexpected defect", boom]); + }); +}); + +test("Cause helpers used by the runner behave as expected", () => { + // Guards the assumptions of `discardFailedRuntime` against changes in Effect. + expect(Cause.hasInterruptsOnly(Cause.interrupt(1))).toBe(true); + expect(Cause.hasInterruptsOnly(Cause.die("x"))).toBe(false); +}); diff --git a/packages/effect-query/test/react/_helpers.tsx b/packages/effect-query/test/react/_helpers.tsx index 3746041..76b07af 100644 --- a/packages/effect-query/test/react/_helpers.tsx +++ b/packages/effect-query/test/react/_helpers.tsx @@ -34,3 +34,13 @@ export function afterQueryFinish( { timeout: 1000 } ); } + +/** A wrapper around a fresh `QueryClient`, so tests never read each other's cache entries. */ +export function createWrapper( + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +) { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return { queryClient: client, wrapper: Wrapper }; +} diff --git a/packages/effect-query/test/react/infinite-query-options.test.tsx b/packages/effect-query/test/react/infinite-query-options.test.tsx index 6421fa1..3f6f56b 100644 --- a/packages/effect-query/test/react/infinite-query-options.test.tsx +++ b/packages/effect-query/test/react/infinite-query-options.test.tsx @@ -1,272 +1,178 @@ import { - infiniteQueryOptions, skipToken, useInfiniteQuery, useSuspenseInfiniteQuery, } from "@tanstack/react-query"; -import { Effect, Layer } from "effect"; -import { describe, expect, test } from "vitest"; +import { Data, Effect, Layer } from "effect"; +import { describe, expect, test, vi } from "vitest"; import { renderHook } from "vitest-browser-react"; -import { createEffectQuery } from "../../src"; -import { afterQueryFinish, HooksWrapper } from "./_helpers"; +import { createEffectQuery, EffectQueryFailure } from "../../src"; +import { createWrapper } from "./_helpers"; -describe("infiniteQueryOptions", () => { - const testContext = () => { - const eq = createEffectQuery(Layer.empty); - return { eq }; - }; - - test("should work with defined initial data", async () => { - const { eq } = testContext(); +class PageNotFound extends Data.TaggedError("PageNotFound")<{ + page: number; +}> {} - // Default implementation - const defaultOptions = infiniteQueryOptions({ - getNextPageParam: (lastPage) => lastPage.nextCursor, - initialData: () => ({ - pageParams: [0], - pages: [ - { - data: "test", - nextCursor: 0, - }, - ], - }), - initialPageParam: 0, - queryFn: async () => ({ - data: "test", - nextCursor: 1, - }), - queryKey: ["test"], - }); +interface Page { + readonly items: readonly string[]; + readonly next: number | null; +} - // EffectQuery implementation - const effectQueryOptions = eq.infiniteQueryOptions({ - getNextPageParam: (lastPage) => lastPage.nextCursor, - initialData: () => ({ - pageParams: [0], - pages: [ - { - data: "test", - nextCursor: 0, - }, - ], - }), - initialPageParam: 0, - queryFn: () => - // biome-ignore lint/correctness/useYield: test - Effect.gen(function* () { - return { - data: "test", - nextCursor: 1, - }; - }), - queryKey: ["test"], - }); +const LAST_PAGE = 2; - const { result: defaultResult } = await renderHook( - () => useInfiniteQuery(defaultOptions), - { - wrapper: HooksWrapper, - } - ); - const { result: effectQueryResult } = await renderHook( - () => useInfiniteQuery(effectQueryOptions), - { - wrapper: HooksWrapper, - } - ); +interface PagesData { + readonly pageParams: readonly unknown[]; + readonly pages: readonly Page[]; +} - await afterQueryFinish( - () => { - expect(defaultResult.current.data).toEqual( - effectQueryResult.current.data - ); - }, - defaultResult, - effectQueryResult - ); - }); +const pagesOf = (data: PagesData | undefined): readonly Page[] => + data?.pages ?? []; - test("skip token - passes skipToken through and does not fetch", async () => { - const { eq } = testContext(); +const pageParamsOf = (data: PagesData | undefined): readonly unknown[] => + data?.pageParams ?? []; - const effectQueryOptions = eq.infiniteQueryOptions({ - getNextPageParam: () => 1, - initialPageParam: 0, - queryFn: skipToken, - queryKey: ["infinite-skip-token"], - }); +const fetchPage = (page: number): Effect.Effect => + page > LAST_PAGE + ? Effect.fail(new PageNotFound({ page })) + : Effect.succeed({ + items: [`item-${page}-a`, `item-${page}-b`], + next: page < LAST_PAGE ? page + 1 : null, + }); - expect(effectQueryOptions.queryFn).toBe(skipToken); +describe("infiniteQueryOptions", () => { + const eq = createEffectQuery(Layer.empty); + test("passes the page param to the Effect and fetches the next pages", async () => { + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + fetchPage(pageParam) + ); + const { wrapper } = createWrapper(); const { result } = await renderHook( - () => useInfiniteQuery(effectQueryOptions), - { wrapper: HooksWrapper } + () => + useInfiniteQuery( + eq.infiniteQueryOptions({ + getNextPageParam: (lastPage) => lastPage.next, + initialPageParam: 0, + queryFn, + queryKey: ["pages"], + }) + ), + { wrapper } ); - expect(result.current.status).toBe("pending"); - expect(result.current.fetchStatus).toBe("idle"); - }); - test("skip token - wraps the effect into a promise returning queryFn", () => { - const { eq } = testContext(); + await vi.waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(pagesOf(result.current.data).map((page) => page.items[0])).toEqual([ + "item-0-a", + ]); + expect(result.current.hasNextPage).toBe(true); - const effectQueryOptions = eq.infiniteQueryOptions({ - getNextPageParam: () => 1, - initialPageParam: 0, - queryFn: () => Effect.succeed("test"), - queryKey: ["test"], - }); - - expect(typeof effectQueryOptions.queryFn).toBe("function"); - expect(effectQueryOptions.enabled).toBeUndefined(); - }); - - test("should work with unused skip token", async () => { - const { eq } = testContext(); - const shouldSkip = Math.random() < 0.5; - // Default implementation - const defaultOptions = infiniteQueryOptions({ - getNextPageParam: (lastPage) => lastPage.nextCursor, - initialPageParam: 0, - queryFn: shouldSkip - ? skipToken - : ({ pageParam }: { pageParam: number }) => ({ - data: "test", - nextCursor: pageParam + 1, - }), - queryKey: ["test"], - }); - const effectQueryOptions = eq.infiniteQueryOptions({ - getNextPageParam: (lastPage) => lastPage.nextCursor, - initialPageParam: 0, - queryFn: shouldSkip - ? skipToken - : ({ pageParam }: { pageParam: number }) => - // biome-ignore lint/correctness/useYield: test - Effect.gen(function* () { - return { - data: "test", - nextCursor: pageParam + 1, - }; - }), - queryKey: ["test"], - }); - - const { result: defaultResult } = await renderHook( - () => useInfiniteQuery(defaultOptions), - { - wrapper: HooksWrapper, - } + await result.current.fetchNextPage(); + await vi.waitFor(() => + expect(pagesOf(result.current.data)).toHaveLength(2) ); - const { result: effectQueryResult } = await renderHook( - () => useInfiniteQuery(effectQueryOptions), - { - wrapper: HooksWrapper, - } + await result.current.fetchNextPage(); + await vi.waitFor(() => + expect(pagesOf(result.current.data)).toHaveLength(3) ); - await afterQueryFinish( - () => { - expect(defaultResult.current.data).toEqual( - effectQueryResult.current.data - ); - }, - defaultResult, - effectQueryResult - ); + expect(pageParamsOf(result.current.data)).toEqual([0, 1, 2]); + expect(result.current.hasNextPage).toBe(false); + expect(queryFn.mock.calls.map(([context]) => context.pageParam)).toEqual([ + 0, 1, 2, + ]); }); - test("should work with undefined initial data", async () => { - const { eq } = testContext(); - // Default implementation - const defaultOptions = infiniteQueryOptions({ - getNextPageParam: (lastPage) => lastPage.nextCursor, - initialPageParam: 0, - queryFn: ({ pageParam }: { pageParam: number }) => ({ - data: "test", - nextCursor: pageParam + 1, - }), - queryKey: ["test"], - }); + test("a failing page becomes a typed EffectQueryFailure", async () => { + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useInfiniteQuery( + eq.infiniteQueryOptions({ + getNextPageParam: () => LAST_PAGE + 1, + initialPageParam: LAST_PAGE + 1, + queryFn: ({ pageParam }) => fetchPage(pageParam), + queryKey: ["missing-page"], + }) + ), + { wrapper } + ); - // EffectQuery implementation - const effectQueryOptions = eq.infiniteQueryOptions({ - getNextPageParam: (lastPage) => lastPage.nextCursor, - initialPageParam: 0, - queryFn: ({ pageParam }: { pageParam: number }) => - Effect.succeed({ - data: "test", - nextCursor: pageParam + 1, - }), - queryKey: ["test"], - }); + await vi.waitFor(() => expect(result.current.isError).toBe(true)); + const { error } = result.current; + expect(error).toBeInstanceOf(EffectQueryFailure); + expect( + error?.match({ + OrElse: () => -1, + PageNotFound: (failure) => failure.page, + }) + ).toBe(LAST_PAGE + 1); + }); - const { result: defaultResult } = await renderHook( - () => useInfiniteQuery(defaultOptions), - { - wrapper: HooksWrapper, - } + test("initialData is used without running the Effect", async () => { + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + fetchPage(pageParam) ); - const { result: effectQueryResult } = await renderHook( - () => useInfiniteQuery(effectQueryOptions), - { - wrapper: HooksWrapper, - } + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useInfiniteQuery( + eq.infiniteQueryOptions({ + getNextPageParam: (lastPage) => lastPage.next, + initialData: { + pageParams: [0], + pages: [{ items: ["seeded"], next: null }], + }, + initialPageParam: 0, + queryFn, + queryKey: ["seeded"], + staleTime: Number.POSITIVE_INFINITY, + }) + ), + { wrapper } ); - await afterQueryFinish( - () => { - expect(defaultResult.current.data).toEqual( - effectQueryResult.current.data - ); - }, - defaultResult, - effectQueryResult - ); + expect(result.current.data.pages[0]?.items).toEqual(["seeded"]); + expect(queryFn).not.toHaveBeenCalled(); }); - test("should work with suspenseQuery", async () => { - const { eq } = testContext(); - const defaultOptions = infiniteQueryOptions({ - getNextPageParam: (lastPage) => lastPage.nextCursor, + test("skipToken is passed through and nothing is fetched", async () => { + const options = eq.infiniteQueryOptions({ + getNextPageParam: () => null, initialPageParam: 0, - queryFn: ({ pageParam }: { pageParam: number }) => ({ - data: "test", - nextCursor: pageParam + 1, - }), - queryKey: ["test"], + queryFn: skipToken, + queryKey: ["skip-pages"], }); - const effectQueryOptions = eq.infiniteQueryOptions({ - getNextPageParam: (lastPage) => lastPage.nextCursor, - initialPageParam: 0, - queryFn: ({ pageParam }: { pageParam: number }) => - Effect.succeed({ - data: "test", - nextCursor: pageParam + 1, - }), - queryKey: ["test"], + expect(options.queryFn).toBe(skipToken); + + const { wrapper } = createWrapper(); + const { result } = await renderHook(() => useInfiniteQuery(options), { + wrapper, }); - const { result: defaultResult } = await renderHook( - () => useSuspenseInfiniteQuery(defaultOptions), - { - wrapper: HooksWrapper, - } - ); - const { result: effectQueryResult } = await renderHook( - () => useSuspenseInfiniteQuery(effectQueryOptions), - { - wrapper: HooksWrapper, - } + expect(result.current.status).toBe("pending"); + expect(result.current.fetchStatus).toBe("idle"); + }); + + test("works with useSuspenseInfiniteQuery", async () => { + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useSuspenseInfiniteQuery( + eq.infiniteQueryOptions({ + getNextPageParam: (lastPage) => lastPage.next, + initialPageParam: 1, + queryFn: ({ pageParam }: { pageParam: number }) => + fetchPage(pageParam), + queryKey: ["suspense-pages"], + }) + ), + { wrapper } ); - await afterQueryFinish( - () => { - expect(defaultResult.current.data).toEqual( - effectQueryResult.current.data - ); - }, - defaultResult, - effectQueryResult + await vi.waitFor(() => + expect(result.current.data.pages[0]?.items).toEqual([ + "item-1-a", + "item-1-b", + ]) ); }); }); diff --git a/packages/effect-query/test/react/query-error-boundary.test.tsx b/packages/effect-query/test/react/query-error-boundary.test.tsx index 795d662..9907248 100644 --- a/packages/effect-query/test/react/query-error-boundary.test.tsx +++ b/packages/effect-query/test/react/query-error-boundary.test.tsx @@ -218,6 +218,64 @@ describe("QueryErrorBoundary", () => { await screen.getByText("switch").click(); await expect.element(screen.getByText("user:Ripley")).toBeInTheDocument(); }); + + // While the fallback is shown the failed query has no observers. Removing, garbage collecting or + // refetching it must not turn the error that was caught into an error of someone else. + const renderRerenderable = async () => { + recovered = false; + const Rerenderable = () => { + const [renders, setRenders] = useState(0); + return ( + + +

inner fallback

} + query={userOptions("missing")} + > + loading

}> + +
+
+
+ ); + }; + return await renderWithClient(); + }; + + test("keeps the fallback when the failed query is removed from the cache", async () => { + const { queryClient, screen } = await renderRerenderable(); + await expect + .element(screen.getByText("inner fallback")) + .toBeInTheDocument(); + + queryClient.removeQueries({ queryKey: userOptions("missing").queryKey }); + await screen.getByText("rerender 0").click(); + + await expect.element(screen.getByText("rerender 1")).toBeInTheDocument(); + await expect + .element(screen.getByText("inner fallback")) + .toBeInTheDocument(); + }); + + test("keeps the fallback when the failed query is refetched with a new error", async () => { + const { queryClient, screen } = await renderRerenderable(); + await expect + .element(screen.getByText("inner fallback")) + .toBeInTheDocument(); + const options = userOptions("missing"); + const caught = queryClient.getQueryState(options.queryKey)?.error; + + await queryClient.fetchQuery(options).catch(() => undefined); + expect(queryClient.getQueryState(options.queryKey)?.error).not.toBe(caught); + await screen.getByText("rerender 0").click(); + + await expect.element(screen.getByText("rerender 1")).toBeInTheDocument(); + await expect + .element(screen.getByText("inner fallback")) + .toBeInTheDocument(); + }); }); describe("isQueryError", () => { @@ -233,3 +291,42 @@ describe("isQueryError", () => { expect(isQueryError(queryClient, options, new Error("nope"))).toBe(false); }); }); + +// TanStack Query's default hash cannot serialize a `BigInt`, such keys need a `queryKeyHashFn`. +const bigintHash = (key: readonly unknown[]) => + JSON.stringify(key, (_, value: unknown) => + typeof value === "bigint" ? `${value}n` : value + ); + +const bigUserOptions = eq.queryOptions({ + queryFn: () => Effect.fail(new NotFound({ id: "big" })), + queryKey: ["big-user", 1n], + queryKeyHashFn: bigintHash, +}); + +const BigUser = () => { + const { data } = useSuspenseQuery(bigUserOptions); + return

{String(data)}

; +}; + +describe("QueryErrorBoundary with a custom queryKeyHashFn", () => { + test("guards queries whose keys the default hash cannot serialize", async () => { + const { screen } = await renderWithClient( + + error.match({ + NotFound: (notFound) =>

big: {notFound.id}

, + OrElse: () =>

other

, + }) + } + query={bigUserOptions} + > + loading

}> + +
+
+ ); + + await expect.element(screen.getByText("big: big")).toBeInTheDocument(); + }); +}); diff --git a/packages/effect-query/test/react/query-options.test.tsx b/packages/effect-query/test/react/query-options.test.tsx index 72dac12..4dfb2ee 100644 --- a/packages/effect-query/test/react/query-options.test.tsx +++ b/packages/effect-query/test/react/query-options.test.tsx @@ -1,186 +1,230 @@ import { - queryOptions, + keepPreviousData, skipToken, useQuery, useSuspenseQuery, } from "@tanstack/react-query"; -import { Effect, Layer } from "effect"; -import { describe, expect, test } from "vitest"; +import { Context, Effect, Layer, ManagedRuntime } from "effect"; +import { describe, expect, test, vi } from "vitest"; import { renderHook } from "vitest-browser-react"; -import { createEffectQuery } from "../../src"; -import { afterQueryFinish, HooksWrapper } from "./_helpers"; +import { + createEffectQuery, + createEffectQueryFromManagedRuntime, + EffectQueryDefect, +} from "../../src"; +import { createWrapper } from "./_helpers"; + +class Counter extends Context.Service< + Counter, + { readonly next: Effect.Effect } +>()("test/react/Counter") {} + +const counterLayer = () => { + let value = 0; + return Layer.succeed(Counter)({ + next: Effect.sync(() => { + value += 1; + return value; + }), + }); +}; + +const nextValue = Effect.gen(function* () { + const counter = yield* Counter; + return yield* counter.next; +}); describe("queryOptions", () => { - const testContext = () => { - const eq = createEffectQuery(Layer.empty); - return { eq }; - }; - - test("should work with defined initial data", async () => { - const { eq } = testContext(); - const defaultOptions = queryOptions({ - initialData: () => "test", - queryFn: () => "test", - queryKey: ["test"], - }); - const effectQueryOptions = eq.queryOptions({ - initialData: () => "test", - queryFn: () => Effect.succeed("test"), - queryKey: ["test"], - }); - const { result: defaultResult } = await renderHook( - () => useQuery(defaultOptions), - { - wrapper: HooksWrapper, - } + test("resolves with the value produced by the Effect", async () => { + const eq = createEffectQuery(counterLayer()); + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useQuery( + eq.queryOptions({ queryFn: () => nextValue, queryKey: ["n"] }) + ), + { wrapper } ); - const { result: effectQueryResult } = await renderHook( - () => useQuery(effectQueryOptions), - { - wrapper: HooksWrapper, - } + + await vi.waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe(1); + }); + + test("refetching runs the Effect again", async () => { + const eq = createEffectQuery(counterLayer()); + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useQuery( + eq.queryOptions({ queryFn: () => nextValue, queryKey: ["n"] }) + ), + { wrapper } ); - await afterQueryFinish( - () => { - expect(defaultResult.current.data).toEqual( - effectQueryResult.current.data - ); - }, - defaultResult, - effectQueryResult + await vi.waitFor(() => expect(result.current.data).toBe(1)); + await result.current.refetch(); + await vi.waitFor(() => expect(result.current.data).toBe(2)); + }); + + test("initialData is used without running the Effect", async () => { + const queryFn = vi.fn(() => Effect.succeed("fetched")); + const eq = createEffectQuery(Layer.empty); + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useQuery( + eq.queryOptions({ + initialData: "initial", + queryFn, + queryKey: ["initial"], + staleTime: Number.POSITIVE_INFINITY, + }) + ), + { wrapper } ); + + expect(result.current.status).toBe("success"); + expect(result.current.data).toBe("initial"); + expect(queryFn).not.toHaveBeenCalled(); }); - test("skip token - passes skipToken through and does not fetch", async () => { - const { eq } = testContext(); + test("select transforms the value of the Effect", async () => { + const eq = createEffectQuery(Layer.empty); + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useQuery( + eq.queryOptions({ + queryFn: () => Effect.succeed({ name: "Ripley" }), + queryKey: ["select"], + select: (user) => user.name.toUpperCase(), + }) + ), + { wrapper } + ); - const effectQueryOptions = eq.queryOptions({ - queryFn: skipToken, - queryKey: ["skip-token"], - }); + await vi.waitFor(() => expect(result.current.data).toBe("RIPLEY")); + }); - expect(effectQueryOptions.queryFn).toBe(skipToken); + test("placeholderData keeps the previous value while the next key loads", async () => { + const eq = createEffectQuery(Layer.empty); + const { wrapper } = createWrapper(); + const { result, rerender } = await renderHook( + ({ id }: { id: number } = { id: 1 }) => + useQuery( + eq.queryOptions({ + placeholderData: keepPreviousData, + queryFn: () => Effect.as(Effect.sleep("20 millis"), `user:${id}`), + queryKey: ["placeholder", id], + }) + ), + { initialProps: { id: 1 }, wrapper } + ); - const { result } = await renderHook(() => useQuery(effectQueryOptions), { - wrapper: HooksWrapper, - }); + await vi.waitFor(() => expect(result.current.data).toBe("user:1")); + await rerender({ id: 2 }); + expect(result.current.data).toBe("user:1"); + expect(result.current.isPlaceholderData).toBe(true); + await vi.waitFor(() => expect(result.current.data).toBe("user:2")); + }); + + test("skipToken is passed through and nothing is fetched", async () => { + const eq = createEffectQuery(Layer.empty); + const options = eq.queryOptions({ queryFn: skipToken, queryKey: ["skip"] }); + expect(options.queryFn).toBe(skipToken); + + const { wrapper } = createWrapper(); + const { result } = await renderHook(() => useQuery(options), { wrapper }); expect(result.current.status).toBe("pending"); expect(result.current.fetchStatus).toBe("idle"); }); - test("skip token - wraps the effect into a promise returning queryFn", () => { - const { eq } = testContext(); + test("works with useSuspenseQuery", async () => { + const eq = createEffectQuery(counterLayer()); + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useSuspenseQuery( + eq.queryOptions({ queryFn: () => nextValue, queryKey: ["suspense"] }) + ), + { wrapper } + ); + + await vi.waitFor(() => expect(result.current.data).toBe(1)); + }); - const effectQueryOptions = eq.queryOptions({ - queryFn: () => Effect.succeed("test"), - queryKey: ["test"], + test("works with queryClient.fetchQuery and ensureQueryData", async () => { + const eq = createEffectQuery(counterLayer()); + const { queryClient } = createWrapper(); + const options = eq.queryOptions({ + queryFn: () => nextValue, + queryKey: ["imperative"], }); - expect(typeof effectQueryOptions.queryFn).toBe("function"); - expect(effectQueryOptions.enabled).toBeUndefined(); + await expect(queryClient.fetchQuery(options)).resolves.toBe(1); + await expect(queryClient.ensureQueryData(options)).resolves.toBe(1); + expect(queryClient.getQueryData(options.queryKey)).toBe(1); }); - test("should work with skip token", async () => { - const { eq } = testContext(); - const enabled = Math.random() < 0.5; - const defaultOptions = queryOptions({ - queryFn: enabled ? async () => "test" : skipToken, - queryKey: ["test"], - }); - const effectQueryOptions = eq.queryOptions({ - queryFn: enabled ? () => Effect.succeed("test") : skipToken, - queryKey: ["test"], - }); - const { result: defaultResult } = await renderHook( - () => useQuery(defaultOptions), - { - wrapper: HooksWrapper, - } - ); - const { result: effectQueryResult } = await renderHook( - () => useQuery(effectQueryOptions), - { - wrapper: HooksWrapper, - } + test("unmounting while fetching interrupts the Effect", async () => { + const interrupted = vi.fn(); + const eq = createEffectQuery(Layer.empty); + const { wrapper } = createWrapper(); + const { result, unmount } = await renderHook( + () => + useQuery( + eq.queryOptions({ + queryFn: () => + Effect.never.pipe( + Effect.onInterrupt(() => Effect.sync(interrupted)) + ), + queryKey: ["unmount"], + }) + ), + { wrapper } ); - await afterQueryFinish( - () => { - expect(defaultResult.current.data).toEqual( - effectQueryResult.current.data - ); - }, - defaultResult, - effectQueryResult - ); + await vi.waitFor(() => expect(result.current.fetchStatus).toBe("fetching")); + await unmount(); + await vi.waitFor(() => expect(interrupted).toHaveBeenCalledOnce()); }); +}); - test("should work with undefined initial data", async () => { - const { eq } = testContext(); - const defaultOptions = queryOptions({ - queryFn: () => "test", - queryKey: ["test"], - }); - const effectQueryOptions = eq.queryOptions({ - queryFn: () => Effect.succeed("test"), - queryKey: ["test"], - }); - const { result: defaultResult } = await renderHook( - () => useQuery(defaultOptions), - { - wrapper: HooksWrapper, - } - ); - const { result: effectQueryResult } = await renderHook( - () => useQuery(effectQueryOptions), - { - wrapper: HooksWrapper, - } +describe("createEffectQueryFromManagedRuntime", () => { + test("runs queries on the given runtime", async () => { + const runtime = ManagedRuntime.make(counterLayer()); + const eq = createEffectQueryFromManagedRuntime(runtime); + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useQuery( + eq.queryOptions({ queryFn: () => nextValue, queryKey: ["mr"] }) + ), + { wrapper } ); - await afterQueryFinish( - () => { - expect(defaultResult.current.data).toEqual( - effectQueryResult.current.data - ); - }, - defaultResult, - effectQueryResult - ); + await vi.waitFor(() => expect(result.current.data).toBe(1)); + expect(eq.runtime).toBe(runtime); + // The runtime is shared: running on it directly sees the same service instance. + await expect(runtime.runPromise(nextValue)).resolves.toBe(2); + await eq.dispose(); }); - test("should work with suspenseQuery", async () => { - const { eq } = testContext(); - const defaultOptions = queryOptions({ - queryFn: () => "test", - queryKey: ["test"], - }); - const effectQueryOptions = eq.queryOptions({ - queryFn: () => Effect.succeed("test"), - queryKey: ["test"], - }); - const { result: defaultResult } = await renderHook( - () => useSuspenseQuery(defaultOptions), - { - wrapper: HooksWrapper, - } + test("queries fail with a defect once the runtime is disposed", async () => { + const eq = createEffectQueryFromManagedRuntime( + ManagedRuntime.make(counterLayer()) ); - const { result: effectQueryResult } = await renderHook( - () => useSuspenseQuery(effectQueryOptions), - { - wrapper: HooksWrapper, - } + await eq.dispose(); + const { wrapper } = createWrapper(); + const { result } = await renderHook( + () => + useQuery( + eq.queryOptions({ queryFn: () => nextValue, queryKey: ["disposed"] }) + ), + { wrapper } ); - await afterQueryFinish( - () => { - expect(defaultResult.current.data).toEqual( - effectQueryResult.current.data - ); - }, - defaultResult, - effectQueryResult - ); + await vi.waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeInstanceOf(EffectQueryDefect); }); }); diff --git a/packages/effect-query/test/react/runtime-handle.test-d.tsx b/packages/effect-query/test/react/runtime-handle.test-d.tsx new file mode 100644 index 0000000..bb48ff6 --- /dev/null +++ b/packages/effect-query/test/react/runtime-handle.test-d.tsx @@ -0,0 +1,70 @@ +import { Context, Effect, Layer, ManagedRuntime } from "effect"; +import { describe, expectTypeOf, test } from "vitest"; +import { + createEffectQuery, + createEffectQueryFromManagedRuntime, + type EffectQueryDefect, + type EffectQueryFailure, +} from "../../src"; + +class Db extends Context.Service }>()( + "test/types/RuntimeHandleDb" +) {} +class Missing extends Context.Service()( + "test/types/RuntimeHandleMissing" +) {} + +const DbLive = Layer.succeed(Db)({ get: Effect.succeed("row") }); + +const readDb = () => + Effect.gen(function* () { + const db = yield* Db; + return yield* db.get; + }); + +const readMissing = () => + Effect.gen(function* () { + const missing = yield* Missing; + return missing.value; + }); + +describe("runtime handle", () => { + test("createEffectQuery exposes the runtime built from its layer", () => { + const eq = createEffectQuery(DbLive); + expectTypeOf(eq.runtime).toEqualTypeOf< + ManagedRuntime.ManagedRuntime + >(); + expectTypeOf(eq.dispose).toEqualTypeOf<() => Promise>(); + }); + + test("createEffectQueryFromManagedRuntime exposes the given runtime", () => { + const eq = createEffectQueryFromManagedRuntime(ManagedRuntime.make(DbLive)); + expectTypeOf(eq.runtime).toEqualTypeOf< + ManagedRuntime.ManagedRuntime + >(); + expectTypeOf(eq.dispose).toEqualTypeOf<() => Promise>(); + }); +}); + +describe("createEffectQueryFromManagedRuntime", () => { + const eq = createEffectQueryFromManagedRuntime(ManagedRuntime.make(DbLive)); + + test("provides the services of the runtime to queries and mutations", () => { + eq.queryOptions({ queryFn: readDb, queryKey: ["db"] }); + eq.mutationOptions({ mutationFn: (_: string) => readDb() }); + }); + + test("rejects requirements the runtime does not provide", () => { + // @ts-expect-error Missing is not provided by the runtime + eq.queryOptions({ queryFn: readMissing, queryKey: ["missing"] }); + // @ts-expect-error Missing is not provided by the runtime + eq.mutationOptions({ mutationFn: (_: string) => readMissing() }); + }); + + test("infers the error type of an infallible Effect as a defect only", () => { + const options = eq.mutationOptions({ mutationFn: (_: string) => readDb() }); + type MutationError = Parameters>[0]; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().not.toExtend>(); + }); +}); diff --git a/packages/effect-query/test/solid/hydration.test.ts b/packages/effect-query/test/solid/hydration.test.ts new file mode 100644 index 0000000..ff3e65a --- /dev/null +++ b/packages/effect-query/test/solid/hydration.test.ts @@ -0,0 +1,145 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; +import { Data, Effect, Layer } from "effect"; +import { + createComponent, + createRoot, + ErrorBoundary, + type JSX, + sharedConfig, +} from "solid-js"; +import { afterEach, describe, expect, test } from "vitest"; +import { + createEffectQuery, + EffectQueryDefect, + EffectQueryFailure, + QueryErrorBoundary, + type QueryErrorBoundaryProps, + type QueryErrorSource, +} from "../../src/solid"; + +class NotFound extends Data.TaggedError("NotFound")<{ id: string }> {} + +const eq = createEffectQuery(Layer.empty); + +const userOptions = eq.queryOptions({ + queryFn: () => Effect.fail(new NotFound({ id: "1" })), + queryKey: ["hydrated-user"], + throwOnError: true, +}); + +const boundary = ( + props: QueryErrorBoundaryProps +) => createComponent(QueryErrorBoundary, props); + +const describeError = ( + error: EffectQueryFailure | EffectQueryDefect +) => + error.match({ + NotFound: (failure) => `failure:${failure.id}`, + OrElse: () => + `defect:${error instanceof EffectQueryDefect ? (error.defect as Error).message : "?"}`, + }); + +/** Resolves the accessors Solid components return when rendered without a DOM. */ +const resolve = (value: unknown): unknown => { + let current = value; + while (typeof current === "function") { + current = (current as () => unknown)(); + } + return current; +}; + +/** + * Renders `ui` the way Solid hydrates it: `sharedConfig.context` is set and `sharedConfig.load` + * returns what the server serialized for each boundary. + */ +const hydrate = ( + queryClient: QueryClient, + serverError: unknown, + ui: () => JSX.Element +): unknown => + resolve( + createRoot((dispose) => { + disposers.push(dispose); + sharedConfig.context = { count: 0, id: "", lazy: {} } as never; + sharedConfig.load = () => serverError as never; + try { + return createComponent(QueryClientProvider, { + get children() { + return ui(); + }, + client: queryClient, + }); + } finally { + sharedConfig.context = undefined as never; + sharedConfig.load = undefined; + } + }) + ); + +const disposers: (() => void)[] = []; +afterEach(() => { + for (const dispose of disposers.splice(0)) { + dispose(); + } +}); + +describe("QueryErrorBoundary hydration", () => { + test("renders the server error as a defect when the client has no error for the query", () => { + // What seroval hands back for the error the boundary caught on the server. + const deserialized = new Error("NotFound"); + // `load` hands every boundary the server error, so there is no outer boundary here: an error + // escalating from the inner one would make `hydrate` throw. + const html = hydrate(new QueryClient(), deserialized, () => + boundary({ + children: "children", + fallback: ({ error }) => describeError(error), + query: userOptions, + }) + ); + + expect(html).toBe("defect:NotFound"); + }); + + test("prefers the typed error the client's query holds", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + await queryClient.fetchQuery(userOptions).catch(() => undefined); + expect( + queryClient.getQueryState(userOptions.queryKey)?.error + ).toBeInstanceOf(EffectQueryFailure); + + const html = hydrate(queryClient, new Error("NotFound"), () => + boundary({ + children: "children", + fallback: ({ error }) => describeError(error), + query: userOptions, + }) + ); + + expect(html).toBe("failure:1"); + }); + + test("errors thrown by the children while hydrating still reach the next boundary", () => { + const Thrower = (): JSX.Element => { + throw new Error("unrelated"); + }; + const html = hydrate(new QueryClient(), undefined, () => + createComponent(ErrorBoundary, { + get children() { + return boundary({ + get children() { + return createComponent(Thrower, {}); + }, + fallback: () => "inner", + query: userOptions, + }); + }, + fallback: (error: Error) => `outer:${error.message}`, + }) + ); + + expect(html).toBe("outer:unrelated"); + }); +}); diff --git a/packages/effect-query/test/solid/query-error-boundary.test.ts b/packages/effect-query/test/solid/query-error-boundary.test.ts index 9160ed4..bfee8ec 100644 --- a/packages/effect-query/test/solid/query-error-boundary.test.ts +++ b/packages/effect-query/test/solid/query-error-boundary.test.ts @@ -240,3 +240,41 @@ describe("isQueryError", () => { expect(isQueryError(queryClient, options, new Error("nope"))).toBe(false); }); }); + +// TanStack Query's default hash cannot serialize a `BigInt`, such keys need a `queryKeyHashFn`. +const bigintHash = (key: readonly unknown[]) => + JSON.stringify(key, (_, value: unknown) => + typeof value === "bigint" ? `${value}n` : value + ); + +const bigUserOptions = eq.queryOptions({ + queryFn: () => Effect.fail(new NotFound({ id: "big" })), + queryKey: ["solid-big-user", 1n], + queryKeyHashFn: bigintHash, + throwOnError: true, +}); + +const BigUser = (): JSX.Element => { + const query = useQuery(() => bigUserOptions); + return (() => String(query.data)) as unknown as JSX.Element; +}; + +describe("QueryErrorBoundary with a custom queryKeyHashFn", () => { + test("guards queries whose keys the default hash cannot serialize", async () => { + const container = mount(() => + boundary({ + get children() { + return suspended(() => createComponent(BigUser, {})); + }, + fallback: ({ error }) => + error.match({ + NotFound: (notFound) => `big: ${notFound.id}`, + OrElse: () => "other", + }), + query: bigUserOptions, + }) + ); + + await vi.waitFor(() => expect(container.textContent).toBe("big: big")); + }); +}); diff --git a/packages/effect-query/test/solid/runtime-handle.test-d.ts b/packages/effect-query/test/solid/runtime-handle.test-d.ts new file mode 100644 index 0000000..ff35266 --- /dev/null +++ b/packages/effect-query/test/solid/runtime-handle.test-d.ts @@ -0,0 +1,70 @@ +import { Context, Effect, Layer, ManagedRuntime } from "effect"; +import { describe, expectTypeOf, test } from "vitest"; +import { + createEffectQuery, + createEffectQueryFromManagedRuntime, + type EffectQueryDefect, + type EffectQueryFailure, +} from "../../src/solid"; + +class Db extends Context.Service }>()( + "test/types/RuntimeHandleDb" +) {} +class Missing extends Context.Service()( + "test/types/RuntimeHandleMissing" +) {} + +const DbLive = Layer.succeed(Db)({ get: Effect.succeed("row") }); + +const readDb = () => + Effect.gen(function* () { + const db = yield* Db; + return yield* db.get; + }); + +const readMissing = () => + Effect.gen(function* () { + const missing = yield* Missing; + return missing.value; + }); + +describe("runtime handle", () => { + test("createEffectQuery exposes the runtime built from its layer", () => { + const eq = createEffectQuery(DbLive); + expectTypeOf(eq.runtime).toEqualTypeOf< + ManagedRuntime.ManagedRuntime + >(); + expectTypeOf(eq.dispose).toEqualTypeOf<() => Promise>(); + }); + + test("createEffectQueryFromManagedRuntime exposes the given runtime", () => { + const eq = createEffectQueryFromManagedRuntime(ManagedRuntime.make(DbLive)); + expectTypeOf(eq.runtime).toEqualTypeOf< + ManagedRuntime.ManagedRuntime + >(); + expectTypeOf(eq.dispose).toEqualTypeOf<() => Promise>(); + }); +}); + +describe("createEffectQueryFromManagedRuntime", () => { + const eq = createEffectQueryFromManagedRuntime(ManagedRuntime.make(DbLive)); + + test("provides the services of the runtime to queries and mutations", () => { + eq.queryOptions({ queryFn: readDb, queryKey: ["db"] }); + eq.mutationOptions({ mutationFn: (_: string) => readDb() }); + }); + + test("rejects requirements the runtime does not provide", () => { + // @ts-expect-error Missing is not provided by the runtime + eq.queryOptions({ queryFn: readMissing, queryKey: ["missing"] }); + // @ts-expect-error Missing is not provided by the runtime + eq.mutationOptions({ mutationFn: (_: string) => readMissing() }); + }); + + test("infers the error type of an infallible Effect as a defect only", () => { + const options = eq.mutationOptions({ mutationFn: (_: string) => readDb() }); + type MutationError = Parameters>[0]; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().not.toExtend>(); + }); +}); diff --git a/packages/effect-query/test/solid/runtime.test.ts b/packages/effect-query/test/solid/runtime.test.ts index a28020d..e3db4d1 100644 --- a/packages/effect-query/test/solid/runtime.test.ts +++ b/packages/effect-query/test/solid/runtime.test.ts @@ -59,6 +59,24 @@ const withRoot = ( describe("solid runtime", () => { const eq = createEffectQuery(GreeterLive); + test("getters of options created once stay reactive", async () => { + const [name, setName] = createSignal(undefined); + const options = eq.queryOptions({ + get enabled() { + return name() !== undefined; + }, + queryFn: () => greet(name() ?? ""), + get queryKey() { + return ["greet-getter", name()] as const; + }, + }); + const { result } = withRoot((client) => useQuery(() => options, client)); + + expect(result.fetchStatus).toBe("idle"); + setName("Lambert"); + await vi.waitFor(() => expect(result.data).toBe("Hello, Lambert!")); + }); + test("provides the layer to the query", async () => { const { result } = withRoot((client) => useQuery( diff --git a/packages/effect-query/test/svelte/runtime-handle.test-d.ts b/packages/effect-query/test/svelte/runtime-handle.test-d.ts new file mode 100644 index 0000000..08f68ec --- /dev/null +++ b/packages/effect-query/test/svelte/runtime-handle.test-d.ts @@ -0,0 +1,70 @@ +import { Context, Effect, Layer, ManagedRuntime } from "effect"; +import { describe, expectTypeOf, test } from "vitest"; +import { + createEffectQuery, + createEffectQueryFromManagedRuntime, + type EffectQueryDefect, + type EffectQueryFailure, +} from "../../src/svelte"; + +class Db extends Context.Service }>()( + "test/types/RuntimeHandleDb" +) {} +class Missing extends Context.Service()( + "test/types/RuntimeHandleMissing" +) {} + +const DbLive = Layer.succeed(Db)({ get: Effect.succeed("row") }); + +const readDb = () => + Effect.gen(function* () { + const db = yield* Db; + return yield* db.get; + }); + +const readMissing = () => + Effect.gen(function* () { + const missing = yield* Missing; + return missing.value; + }); + +describe("runtime handle", () => { + test("createEffectQuery exposes the runtime built from its layer", () => { + const eq = createEffectQuery(DbLive); + expectTypeOf(eq.runtime).toEqualTypeOf< + ManagedRuntime.ManagedRuntime + >(); + expectTypeOf(eq.dispose).toEqualTypeOf<() => Promise>(); + }); + + test("createEffectQueryFromManagedRuntime exposes the given runtime", () => { + const eq = createEffectQueryFromManagedRuntime(ManagedRuntime.make(DbLive)); + expectTypeOf(eq.runtime).toEqualTypeOf< + ManagedRuntime.ManagedRuntime + >(); + expectTypeOf(eq.dispose).toEqualTypeOf<() => Promise>(); + }); +}); + +describe("createEffectQueryFromManagedRuntime", () => { + const eq = createEffectQueryFromManagedRuntime(ManagedRuntime.make(DbLive)); + + test("provides the services of the runtime to queries and mutations", () => { + eq.queryOptions({ queryFn: readDb, queryKey: ["db"] }); + eq.mutationOptions({ mutationFn: (_: string) => readDb() }); + }); + + test("rejects requirements the runtime does not provide", () => { + // @ts-expect-error Missing is not provided by the runtime + eq.queryOptions({ queryFn: readMissing, queryKey: ["missing"] }); + // @ts-expect-error Missing is not provided by the runtime + eq.mutationOptions({ mutationFn: (_: string) => readMissing() }); + }); + + test("infers the error type of an infallible Effect as a defect only", () => { + const options = eq.mutationOptions({ mutationFn: (_: string) => readDb() }); + type MutationError = Parameters>[0]; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().not.toExtend>(); + }); +}); diff --git a/packages/effect-query/test/vue/query-error-boundary.test.ts b/packages/effect-query/test/vue/query-error-boundary.test.ts index 48bf42b..d9aed4a 100644 --- a/packages/effect-query/test/vue/query-error-boundary.test.ts +++ b/packages/effect-query/test/vue/query-error-boundary.test.ts @@ -299,3 +299,47 @@ describe("isQueryError", () => { ).toBe(true); }); }); + +// TanStack Query's default hash cannot serialize a `BigInt`, such keys need a `queryKeyHashFn`. +const bigintHash = (key: readonly unknown[]) => + JSON.stringify(key, (_, value: unknown) => + typeof value === "bigint" ? `${value}n` : value + ); + +const bigUserOptions = eq.queryOptions({ + queryFn: () => Effect.fail(new NotFound({ id: "big" })), + queryKey: ["vue-big-user", 1n], + queryKeyHashFn: bigintHash, +}); + +const BigUser = defineComponent({ + async setup() { + const query = useQuery({ ...bigUserOptions, throwOnError: true }); + await query.suspense(); + return () => h("p", String(query.data.value)); + }, +}); + +describe("QueryErrorBoundary with a custom queryKeyHashFn", () => { + test("guards queries whose keys the default hash cannot serialize", async () => { + const screen = await mount({ + render: () => + h( + QueryErrorBoundary, + { query: bigUserOptions }, + { + default: () => suspended(() => h(BigUser)), + fallback: ({ error }: { error: unknown }) => + h( + "p", + error instanceof EffectQueryFailure + ? `big: ${error.failure.id}` + : "other" + ), + } + ), + }); + + await expect.element(screen.getByText("big: big")).toBeInTheDocument(); + }); +}); diff --git a/packages/effect-query/test/vue/runtime-handle.test-d.ts b/packages/effect-query/test/vue/runtime-handle.test-d.ts new file mode 100644 index 0000000..b2426b8 --- /dev/null +++ b/packages/effect-query/test/vue/runtime-handle.test-d.ts @@ -0,0 +1,70 @@ +import { Context, Effect, Layer, ManagedRuntime } from "effect"; +import { describe, expectTypeOf, test } from "vitest"; +import { + createEffectQuery, + createEffectQueryFromManagedRuntime, + type EffectQueryDefect, + type EffectQueryFailure, +} from "../../src/vue"; + +class Db extends Context.Service }>()( + "test/types/RuntimeHandleDb" +) {} +class Missing extends Context.Service()( + "test/types/RuntimeHandleMissing" +) {} + +const DbLive = Layer.succeed(Db)({ get: Effect.succeed("row") }); + +const readDb = () => + Effect.gen(function* () { + const db = yield* Db; + return yield* db.get; + }); + +const readMissing = () => + Effect.gen(function* () { + const missing = yield* Missing; + return missing.value; + }); + +describe("runtime handle", () => { + test("createEffectQuery exposes the runtime built from its layer", () => { + const eq = createEffectQuery(DbLive); + expectTypeOf(eq.runtime).toEqualTypeOf< + ManagedRuntime.ManagedRuntime + >(); + expectTypeOf(eq.dispose).toEqualTypeOf<() => Promise>(); + }); + + test("createEffectQueryFromManagedRuntime exposes the given runtime", () => { + const eq = createEffectQueryFromManagedRuntime(ManagedRuntime.make(DbLive)); + expectTypeOf(eq.runtime).toEqualTypeOf< + ManagedRuntime.ManagedRuntime + >(); + expectTypeOf(eq.dispose).toEqualTypeOf<() => Promise>(); + }); +}); + +describe("createEffectQueryFromManagedRuntime", () => { + const eq = createEffectQueryFromManagedRuntime(ManagedRuntime.make(DbLive)); + + test("provides the services of the runtime to queries and mutations", () => { + eq.queryOptions({ queryFn: readDb, queryKey: ["db"] }); + eq.mutationOptions({ mutationFn: (_: string) => readDb() }); + }); + + test("rejects requirements the runtime does not provide", () => { + // @ts-expect-error Missing is not provided by the runtime + eq.queryOptions({ queryFn: readMissing, queryKey: ["missing"] }); + // @ts-expect-error Missing is not provided by the runtime + eq.mutationOptions({ mutationFn: (_: string) => readMissing() }); + }); + + test("infers the error type of an infallible Effect as a defect only", () => { + const options = eq.mutationOptions({ mutationFn: (_: string) => readDb() }); + type MutationError = Parameters>[0]; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().not.toExtend>(); + }); +}); diff --git a/packages/effect-query/vitest.config.mts b/packages/effect-query/vitest.config.mts index e376fdb..b20e3a8 100644 --- a/packages/effect-query/vitest.config.mts +++ b/packages/effect-query/vitest.config.mts @@ -18,6 +18,13 @@ const browser = () => ({ export default defineConfig({ test: { projects: [ + { + test: { + environment: "node", + include: ["test/core/**/*.test.ts"], + name: "core", + }, + }, { plugins: [react()], test: {