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
}>
+
+
+
+
+ );
+ };
+ 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: {