From 91172dd392fcc9bb97b0b5c16a836a64d045ef27 Mon Sep 17 00:00:00 2001 From: Matheus Seabra Date: Sat, 5 Sep 2026 01:00:11 -0300 Subject: [PATCH 1/3] feat: add runtime-bound React query and mutation hooks --- package.json | 4 +- packages/react/LICENSE | 21 + packages/react/package.json | 35 ++ packages/react/src/index.ts | 13 + packages/react/src/mutation.ts | 45 ++ packages/react/src/query.ts | 55 +++ packages/react/test/mutation.test.tsx | 81 ++++ packages/react/test/query.test.tsx | 141 ++++++ packages/react/test/requirements.test.tsx | 25 ++ packages/react/test/support.ts | 27 ++ packages/react/test/types.ts | 22 + packages/react/tsconfig.build.json | 11 + packages/react/tsconfig.json | 5 + packages/react/vitest.config.ts | 13 + pnpm-lock.yaml | 523 +++++++++++++++++++++- pnpm-workspace.yaml | 2 + vitest.config.ts | 1 + 17 files changed, 1019 insertions(+), 5 deletions(-) create mode 100644 packages/react/LICENSE create mode 100644 packages/react/package.json create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/mutation.ts create mode 100644 packages/react/src/query.ts create mode 100644 packages/react/test/mutation.test.tsx create mode 100644 packages/react/test/query.test.tsx create mode 100644 packages/react/test/requirements.test.tsx create mode 100644 packages/react/test/support.ts create mode 100644 packages/react/test/types.ts create mode 100644 packages/react/tsconfig.build.json create mode 100644 packages/react/tsconfig.json create mode 100644 packages/react/vitest.config.ts create mode 100644 pnpm-workspace.yaml diff --git a/package.json b/package.json index 8b10168..054a03c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wthw7/effect-query", - "version": "0.1.0-alpha.0", + "version": "0.3.0-beta.0", "description": "Typed, interruptible query caching for Effect", "license": "MIT", "author": "Matheus Seabra", @@ -40,7 +40,7 @@ "test": "vitest run --coverage", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build", + "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm --filter @wthw7/effect-query-react check", "prepublishOnly": "pnpm check" }, "dependencies": { diff --git a/packages/react/LICENSE b/packages/react/LICENSE new file mode 100644 index 0000000..d71947b --- /dev/null +++ b/packages/react/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Matheus Seabra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..0d9742e --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wthw7/effect-query-react", + "version": "0.3.0-beta.0", + "description": "React hooks for Effect Query", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/matheuseabra/effect-query.git", + "directory": "packages/react" + }, + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "lint": "oxlint src test", + "test": "vitest run --coverage", + "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build" + }, + "dependencies": { "@wthw7/effect-query": "workspace:^" }, + "peerDependencies": { "effect": "^3.22.1", "react": "^18.3.1 || ^19.0.0" }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^26.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 0000000..8dbf264 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,13 @@ +import type { QueryService } from "@wthw7/effect-query" +import type { Runtime } from "effect" +import { makeUseMutation } from "./mutation.js" +import { makeUseQuery } from "./query.js" + +/** Create once per application/cache lifetime, outside React render. */ +export const createQueryHooks = (runtime: Runtime.Runtime) => ({ + useQuery: makeUseQuery(runtime), + useMutation: makeUseMutation(runtime) +}) + +export type { QueryResult } from "./query.js" +export type { MutationResult } from "./mutation.js" diff --git a/packages/react/src/mutation.ts b/packages/react/src/mutation.ts new file mode 100644 index 0000000..091579c --- /dev/null +++ b/packages/react/src/mutation.ts @@ -0,0 +1,45 @@ +import { Mutation, type MutationDefinition, type QueryService } from "@wthw7/effect-query" +import { Effect, Exit, Fiber, Runtime } from "effect" +import { useCallback, useEffect, useMemo, useState } from "react" + +export interface MutationResult { + readonly mutate: (...args: Args) => Promise> + readonly isPending: boolean +} + +export const makeUseMutation = (runtime: Runtime.Runtime) => + function useMutation( + definition: MutationDefinition + ): MutationResult { + const lifecycle = useMemo(() => ({ + active: false, + fibers: new Set>() + }), [definition]) + const [state, setState] = useState({ lifecycle, count: 0 }) + + useEffect(() => { + lifecycle.active = true + return () => { + lifecycle.active = false + for (const fiber of lifecycle.fibers) { + Runtime.runFork(runtime)(Fiber.interrupt(fiber)) + } + } + }, [lifecycle]) + + const mutate = useCallback((...args: Args): Promise> => { + if (!lifecycle.active) return Effect.runPromiseExit(Effect.interrupt) + const fiber = Runtime.runFork(runtime)(Effect.suspend(() => Mutation.execute(definition, ...args))) + lifecycle.fibers.add(fiber) + setState({ lifecycle, count: lifecycle.fibers.size }) + return new Promise((resolve) => { + fiber.addObserver((exit) => { + lifecycle.fibers.delete(fiber) + if (lifecycle.active) setState({ lifecycle, count: lifecycle.fibers.size }) + resolve(exit) + }) + }) + }, [definition, lifecycle]) + + return { mutate, isPending: state.lifecycle === lifecycle && state.count > 0 } + } diff --git a/packages/react/src/query.ts b/packages/react/src/query.ts new file mode 100644 index 0000000..5239708 --- /dev/null +++ b/packages/react/src/query.ts @@ -0,0 +1,55 @@ +import { KeyHashError, Query, type QueryDefinition, type QueryKey, type QueryService } from "@wthw7/effect-query" +import { Cause, Effect, Exit, Fiber, Runtime } from "effect" +import { useEffect, useMemo, useRef, useState } from "react" + +export interface QueryResult { + readonly data: A | undefined + readonly error: Cause.Cause | undefined + readonly isPending: boolean +} + +const pending = { data: undefined, error: undefined, isPending: true } as const + +export const makeUseQuery = (runtime: Runtime.Runtime) => + function useQuery( + definition: QueryDefinition, + ...args: Args + ): QueryResult { + // Key identity, rather than fresh argument objects, controls the request. + const identity = Effect.runSyncExit(Effect.try({ + try: () => (definition.hash ?? JSON.stringify)(definition.key(...args)), + catch: (reason) => new KeyHashError({ key: args, reason }) + })) + const hash = Exit.isSuccess(identity) ? identity.value : undefined + const request = useMemo(() => ({ + effect: Exit.isFailure(identity) + ? Effect.failCause(identity.cause) + : Effect.suspend(() => Query.fetch(definition, ...args)) + }), [definition, hash]) + const previous = useRef | undefined>(undefined) + const [state, setState] = useState<{ request: typeof request; result: QueryResult }>( + { request, result: pending } + ) + + useEffect(() => { + let active = true + setState({ request, result: pending }) + // Wait for interrupted cleanup before reusing a key (including StrictMode). + const ready = previous.current === undefined ? Effect.void : Fiber.await(previous.current) + const fiber = Runtime.runFork(runtime)(Effect.zipRight(ready, request.effect)) + previous.current = fiber + fiber.addObserver((exit) => { + if (!active) return + const result = Exit.isSuccess(exit) + ? { data: exit.value, error: undefined, isPending: false } + : { data: undefined, error: exit.cause, isPending: false } + setState({ request, result }) + }) + return () => { + active = false + Runtime.runFork(runtime)(Fiber.interrupt(fiber)) + } + }, [request]) + + return state.request === request ? state.result : pending + } diff --git a/packages/react/test/mutation.test.tsx b/packages/react/test/mutation.test.tsx new file mode 100644 index 0000000..de97484 --- /dev/null +++ b/packages/react/test/mutation.test.tsx @@ -0,0 +1,81 @@ +import { Mutation, Query } from "@wthw7/effect-query" +import { act, renderHook } from "@testing-library/react" +import { Cause, Effect, Exit, Option } from "effect" +import { describe, expect, it, vi } from "vitest" +import { advance, setup } from "./support.js" + +describe("useMutation", () => { + const { hooks, run } = setup() + + it("tracks pending work through the core onSuccess handler", async () => { + const { useMutation } = await hooks() + const mutation = Mutation.make({ + execute: (value: number) => Effect.delay(Effect.succeed(value), 20), + onSuccess: (value) => Query.setData(["saved"], value) + }) + const view = renderHook(() => useMutation(mutation)) + expect(view.result.current.isPending).toBe(false) + let outcome!: Promise> + act(() => { outcome = view.result.current.mutate(42) }) + expect(view.result.current.isPending).toBe(true) + await advance(20) + expect(await outcome).toEqual(Exit.succeed(42)) + expect(view.result.current.isPending).toBe(false) + expect(await run(Query.getData(["saved"]))).toEqual(Option.some(42)) + }) + + it("returns typed execution and onSuccess failures without unhandled rejection", async () => { + const { useMutation } = await hooks() + const mutation = Mutation.make({ execute: () => Effect.fail("offline") }) + const view = renderHook(() => useMutation(mutation)) + let outcome!: Promise> + await act(async () => { outcome = view.result.current.mutate(); await outcome }) + expect(await outcome).toEqual(Exit.fail("offline")) + expect(view.result.current.isPending).toBe(false) + const other = Mutation.make({ execute: () => Effect.succeed(1), onSuccess: () => Effect.fail("save") }) + const handler = renderHook(() => useMutation(other)) + await act(async () => { outcome = handler.result.current.mutate(); await outcome }) + expect(await outcome).toEqual(Exit.fail("save")) + }) + + it("stays pending until all concurrent mutations finish", async () => { + const { useMutation } = await hooks() + const mutation = Mutation.make({ execute: (delay: number) => Effect.delay(Effect.succeed(delay), delay) }) + const view = renderHook(() => useMutation(mutation)) + act(() => { void view.result.current.mutate(10); void view.result.current.mutate(30) }) + await advance(10) + expect(view.result.current.isPending).toBe(true) + await advance(20) + expect(view.result.current.isPending).toBe(false) + }) + + it("interrupts on unmount and refuses work through a retained callback", async () => { + const { useMutation } = await hooks() + const cancelled = vi.fn() + const mutation = Mutation.make({ execute: () => Effect.never.pipe(Effect.onInterrupt(() => Effect.sync(cancelled))) }) + const view = renderHook(() => useMutation(mutation)) + const mutate = view.result.current.mutate + let outcome!: Promise> + act(() => { outcome = mutate() }) + await advance() + view.unmount() + await advance() + expect(cancelled).toHaveBeenCalledOnce() + const exit = await outcome + expect(Exit.isFailure(exit) && Cause.isInterrupted(exit.cause)).toBe(true) + const retained = await mutate() + expect(Exit.isFailure(retained) && Cause.isInterrupted(retained.cause)).toBe(true) + }) + + it("cancels the previous definition and resets pending when the definition changes", async () => { + const { useMutation } = await hooks() + const old = Mutation.make({ execute: (): Effect.Effect => Effect.never }) + const next = Mutation.make({ execute: () => Effect.succeed(2) }) + const view = renderHook(({ definition }) => useMutation(definition), { initialProps: { definition: old } }) + act(() => { void view.result.current.mutate() }) + await advance() + view.rerender({ definition: next }) + expect(view.result.current.isPending).toBe(false) + await act(async () => { expect(await view.result.current.mutate()).toEqual(Exit.succeed(2)) }) + }) +}) diff --git a/packages/react/test/query.test.tsx b/packages/react/test/query.test.tsx new file mode 100644 index 0000000..5820460 --- /dev/null +++ b/packages/react/test/query.test.tsx @@ -0,0 +1,141 @@ +import { KeyHashError, Query } from "@wthw7/effect-query" +import { renderHook } from "@testing-library/react" +import { Cause, Effect, Option } from "effect" +import { StrictMode } from "react" +import { describe, expect, it, vi } from "vitest" +import { advance, setup } from "./support.js" + +describe("useQuery", () => { + const { hooks } = setup() + + it("fetches on mount and reuses fresh data across mounts", async () => { + const { useQuery } = await hooks() + const execute = vi.fn((id: string) => id) + const query = Query.make({ key: (id: string) => [id], execute: (id) => Effect.delay(Effect.sync(() => execute(id)), 20), staleTime: 1000 }) + const first = renderHook(() => useQuery(query, "one")) + expect(first.result.current).toEqual({ data: undefined, error: undefined, isPending: true }) + await advance(20) + expect(first.result.current).toEqual({ data: "one", error: undefined, isPending: false }) + first.unmount() + const second = renderHook(() => useQuery(query, "one")) + await advance() + expect(second.result.current.data).toBe("one") + expect(execute).toHaveBeenCalledOnce() + }) + + it("cancels the old key and never displays its late result", async () => { + const { useQuery } = await hooks() + const cancelled = vi.fn() + const query = Query.make({ + key: (id: string) => [id], + execute: (id) => Effect.delay(Effect.succeed(id), 20).pipe( + Effect.onInterrupt(() => Effect.sync(() => { cancelled(id) })) + ) + }) + const view = renderHook(({ id }) => useQuery(query, id), { initialProps: { id: "old" } }) + await advance(5) + view.rerender({ id: "new" }) + expect(view.result.current.data).toBeUndefined() + expect(view.result.current.isPending).toBe(true) + await advance(20) + expect(cancelled).toHaveBeenCalledWith("old") + expect(view.result.current.data).toBe("new") + }) + + it("interrupts an in-flight request on unmount", async () => { + const { useQuery } = await hooks() + const cancelled = vi.fn() + const query = Query.make({ + key: () => ["unmount"], + execute: () => Effect.never.pipe(Effect.onInterrupt(() => Effect.sync(cancelled))) + }) + const view = renderHook(() => useQuery(query)) + await advance() + view.unmount() + await advance() + expect(cancelled).toHaveBeenCalledOnce() + }) + + it("exposes typed failures and defects as Effect causes", async () => { + const { useQuery } = await hooks() + const query = Query.make({ key: () => ["failure"], execute: () => Effect.fail("offline" as const) }) + const failed = renderHook(() => useQuery(query)) + await advance() + expect(failed.result.current.isPending).toBe(false) + expect(Cause.failureOption(failed.result.current.error!)).toEqual(Option.some("offline")) + const defect = Query.make({ key: () => ["defect"], execute: () => Effect.die("broken") }) + const died = renderHook(() => useQuery(defect)) + await advance() + expect(Cause.dieOption(died.result.current.error!)).toEqual(Option.some("broken")) + }) + + it("reports invalid keys without throwing during render", async () => { + const { useQuery } = await hooks() + const query = Query.make({ key: () => [1n], execute: () => Effect.succeed(1) }) + const view = renderHook(() => useQuery(query)) + await advance() + expect(Option.getOrThrow(Cause.failureOption(view.result.current.error!))).toBeInstanceOf(KeyHashError) + }) + + it("uses custom structural identity without refetching for new argument objects", async () => { + const { useQuery } = await hooks() + const execute = vi.fn(() => 1) + const query = Query.make({ + key: (arg: { id: bigint }) => [arg.id], + hash: ([id]) => String(id), + execute: () => Effect.sync(execute) + }) + const view = renderHook(() => useQuery(query, { id: 1n })) + await advance() + view.rerender() + await advance() + expect(view.result.current.data).toBe(1) + expect(execute).toHaveBeenCalledOnce() + }) + + it("completes after StrictMode cleanup and setup replay", async () => { + const { useQuery } = await hooks() + const query = Query.make({ key: () => ["strict"], execute: () => Effect.delay(Effect.succeed(1), 20) }) + const view = renderHook(() => useQuery(query), { wrapper: StrictMode }) + await advance(20) + expect(view.result.current).toEqual({ data: 1, error: undefined, isPending: false }) + }) + + it("deduplicates observers and only cancels a joining observer's wait", async () => { + const { useQuery } = await hooks() + const execute = vi.fn(() => 1) + const query = Query.make({ key: () => ["shared"], execute: () => Effect.delay(Effect.sync(execute), 20) }) + const winner = renderHook(() => useQuery(query)) + await advance() + const joiner = renderHook(() => useQuery(query)) + await advance() + joiner.unmount() + await advance(20) + expect(winner.result.current.data).toBe(1) + expect(execute).toHaveBeenCalledOnce() + }) + + it("exposes interruption to joiners when the winning observer unmounts", async () => { + const { useQuery } = await hooks() + const query = Query.make({ key: () => ["winner-cancel"], execute: () => Effect.never }) + const winner = renderHook(() => useQuery(query)) + await advance() + const joiner = renderHook(() => useQuery(query)) + await advance() + winner.unmount() + await advance() + expect(joiner.result.current.isPending).toBe(false) + expect(Cause.isInterrupted(joiner.result.current.error!)).toBe(true) + }) + + it("recovers after an invalid key changes to a valid key", async () => { + const { useQuery } = await hooks() + const query = Query.make({ key: (id: bigint | string) => [id], execute: () => Effect.succeed(1) }) + const view = renderHook(({ id }) => useQuery(query, id), { initialProps: { id: 1n as bigint | string } }) + await advance() + expect(view.result.current.error).toBeDefined() + view.rerender({ id: "valid" }) + await advance() + expect(view.result.current).toEqual({ data: 1, error: undefined, isPending: false }) + }) +}) diff --git a/packages/react/test/requirements.test.tsx b/packages/react/test/requirements.test.tsx new file mode 100644 index 0000000..c865888 --- /dev/null +++ b/packages/react/test/requirements.test.tsx @@ -0,0 +1,25 @@ +import { Mutation, Query, queryLayer } from "@wthw7/effect-query" +import { act, cleanup, renderHook } from "@testing-library/react" +import { Context, Effect, Exit, Layer, ManagedRuntime } from "effect" +import { expect, it } from "vitest" +import { createQueryHooks } from "../src/index.js" + +class Api extends Context.Tag("Api")() {} + +it("provides application services to both query and mutation fibers", async () => { + const managed = ManagedRuntime.make(Layer.merge(queryLayer(), Layer.succeed(Api, { value: "injected" }))) + try { + const { useQuery, useMutation } = createQueryHooks(await managed.runtime()) + const execute = () => Effect.map(Api, (api) => api.value) + const query = Query.make({ key: () => ["api"], execute }) + const mutation = Mutation.make({ execute }) + const view = renderHook(() => ({ query: useQuery(query), mutation: useMutation(mutation) })) + await act(async () => { + expect(await view.result.current.mutation.mutate()).toEqual(Exit.succeed("injected")) + }) + expect(view.result.current.query.data).toBe("injected") + } finally { + cleanup() + await managed.dispose() + } +}) diff --git a/packages/react/test/support.ts b/packages/react/test/support.ts new file mode 100644 index 0000000..9b98b3e --- /dev/null +++ b/packages/react/test/support.ts @@ -0,0 +1,27 @@ +import { queryLayer, type QueryService } from "@wthw7/effect-query" +import { act, cleanup } from "@testing-library/react" +import { Effect, ManagedRuntime } from "effect" +import { afterEach, beforeEach, vi } from "vitest" +import { createQueryHooks } from "../src/index.js" + +export const setup = () => { + let managed: ManagedRuntime.ManagedRuntime + beforeEach(() => { + vi.useFakeTimers() + managed = ManagedRuntime.make(queryLayer()) + }) + afterEach(async () => { + cleanup() + await advance() + await managed.dispose() + vi.useRealTimers() + }) + return { + run: (effect: Effect.Effect) => managed.runPromise(effect), + hooks: async () => createQueryHooks(await managed.runtime()) + } +} + +export const advance = async (millis = 0) => { + await act(async () => { await vi.advanceTimersByTimeAsync(millis) }) +} diff --git a/packages/react/test/types.ts b/packages/react/test/types.ts new file mode 100644 index 0000000..65e4996 --- /dev/null +++ b/packages/react/test/types.ts @@ -0,0 +1,22 @@ +import { Mutation, Query, type QueryService } from "@wthw7/effect-query" +import { Context, Effect, type Runtime } from "effect" +import { createQueryHooks } from "../src/index.js" + +class Missing extends Context.Tag("Missing")() {} + +// Compile-only examples: unprovided requirements and incorrect args must fail. +export function useTypeExamples(runtime: Runtime.Runtime) { + const { useQuery, useMutation } = createQueryHooks(runtime) + const query = Query.make({ key: (id: string) => [id], execute: () => Missing }) + // @ts-expect-error Missing is not provided by this runtime. + useQuery(query, "id") + const mutation = Mutation.make({ execute: () => Missing }) + // @ts-expect-error Missing is not provided by this runtime. + useMutation(mutation) + const valid = Query.make({ key: (id: string) => [id], execute: () => Effect.succeed(1) }) + // @ts-expect-error Query arguments preserve the definition's tuple. + useQuery(valid, 1) + const typed = useMutation(Mutation.make({ execute: (value: number) => Effect.succeed(value) })) + // @ts-expect-error Mutation arguments preserve the definition's tuple. + void typed.mutate("wrong") +} diff --git a/packages/react/tsconfig.build.json b/packages/react/tsconfig.build.json new file mode 100644 index 0000000..e4a6b60 --- /dev/null +++ b/packages/react/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000..ab25553 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "jsx": "react-jsx" }, + "include": ["src", "test"] +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 0000000..bc225ba --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "jsdom", + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + thresholds: { lines: 100, functions: 100, statements: 100, branches: 90 } + } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cf5fa4..3edab91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,10 +29,45 @@ importers: version: 7.0.2 vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(tsx@4.23.13)) + version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@26.1.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(tsx@4.23.13)) + + packages/react: + dependencies: + '@wthw7/effect-query': + specifier: workspace:^ + version: link:../.. + effect: + specifier: ^3.22.1 + version: 3.22.1 + devDependencies: + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.0.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.7(@types/react@19.2.18) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.2.8 + react-dom: + specifier: ^19.0.0 + version: 19.2.8(react@19.2.8) packages: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -46,6 +81,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} @@ -54,6 +93,34 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -447,6 +514,28 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -459,6 +548,14 @@ packages: '@types/node@26.4.1': resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -610,6 +707,21 @@ packages: '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -621,13 +733,47 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + effect@3.22.1: resolution: {integrity: sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -661,9 +807,40 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -738,17 +915,30 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@1.2.3: resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nwsapi@2.2.27: + resolution: {integrity: sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -766,6 +956,9 @@ packages: vite-plus: optional: true + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -777,14 +970,47 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + rolldown@1.2.7: resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -798,6 +1024,9 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinybench@6.1.4: resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} engines: {node: '>=20.0.0'} @@ -814,6 +1043,21 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tsx@4.23.13: resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} engines: {node: '>=18.0.0'} @@ -911,13 +1155,67 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + snapshots: + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -926,6 +1224,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -933,6 +1233,26 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -1128,6 +1448,29 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1141,6 +1484,14 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1211,7 +1562,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(tsx@4.23.13)) + vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@26.1.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(tsx@4.23.13)) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -1230,6 +1581,16 @@ snapshots: '@vitest/spy@5.0.0': {} + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -1240,13 +1601,37 @@ snapshots: chai@6.2.2: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + effect@3.22.1: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + entities@6.0.1: {} + es-module-lexer@2.3.2: {} esbuild@0.28.2: @@ -1295,8 +1680,61 @@ snapshots: fsevents@2.3.3: optional: true + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + is-potential-custom-element-name@1.0.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.27 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + lightningcss-android-arm64@1.33.0: optional: true @@ -1346,6 +1784,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lru-cache@10.4.3: {} + + lz-string@1.5.0: {} + magic-string@1.2.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -1356,8 +1798,12 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.1 + ms@2.1.3: {} + nanoid@3.3.18: {} + nwsapi@2.2.27: {} + obug@2.1.4: {} oxlint@1.81.0: @@ -1382,6 +1828,10 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.81.0 '@oxlint/binding-win32-x64-msvc': 1.81.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -1392,8 +1842,25 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + pure-rand@6.1.0: {} + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react@19.2.8: {} + rolldown@1.2.7: dependencies: '@oxc-project/types': 0.148.0 @@ -1415,6 +1882,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1423,6 +1900,8 @@ snapshots: std-env@4.2.0: {} + symbol-tree@3.2.4: {} + tinybench@6.1.4: {} tinyexec@1.3.0: {} @@ -1434,6 +1913,20 @@ snapshots: tinyrainbow@3.1.1: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tsx@4.23.13: dependencies: esbuild: 0.28.2 @@ -1478,7 +1971,7 @@ snapshots: fsevents: 2.3.3 tsx: 4.23.13 - vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(tsx@4.23.13)): + vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@26.1.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(tsx@4.23.13)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(tsx@4.23.13)) @@ -1497,10 +1990,34 @@ snapshots: optionalDependencies: '@types/node': 26.4.1 '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + jsdom: 26.1.0 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + ws@8.21.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..924b55f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/vitest.config.ts b/vitest.config.ts index 0c491aa..3fcf6ab 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config" export default defineConfig({ test: { + include: ["test/**/*.test.ts"], coverage: { provider: "v8", reporter: ["text", "lcov"], From 793f54c1a72dac02085f3f9cc021fcd6ca1685f6 Mon Sep 17 00:00:00 2001 From: Matheus Seabra Date: Sat, 5 Sep 2026 01:00:11 -0300 Subject: [PATCH 2/3] docs: document React adapter semantics and prepare beta release --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 17 ++++- CHANGELOG.md | 12 ++++ README.md | 4 +- docs/architecture.md | 32 ++++++++- docs/roadmap.md | 15 +++-- docs/spec.md | 26 +++++++- packages/react/README.md | 121 ++++++++++++++++++++++++++++++++++ 8 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 packages/react/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b74bc12..a311130 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,6 @@ jobs: if: always() with: name: coverage - path: coverage/ + path: | + coverage/ + packages/react/coverage/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0196ec..da2424b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,23 @@ jobs: registry-url: "https://registry.npmjs.org" - run: pnpm install --frozen-lockfile - run: pnpm check + - name: Verify release versions match the tag + run: | + node --input-type=module -e ' + import fs from "node:fs"; + const core = JSON.parse(fs.readFileSync("package.json", "utf8")); + const react = JSON.parse(fs.readFileSync("packages/react/package.json", "utf8")); + if (core.version !== react.version || process.env.GITHUB_REF_NAME !== `v${core.version}`) { + throw new Error("Release tag and package versions must match"); + } + ' + # pnpm resolves workspace dependencies to registry versions when packing. + - run: pnpm pack --out effect-query.tgz + - run: pnpm pack --out ../../effect-query-react.tgz + working-directory: packages/react # npm CLI (not pnpm) performs the OIDC token exchange for publishing. # Prereleases publish under their own dist-tag so `latest` stays clean. - run: | TAG=$(node -p "/-/.test(require('./package.json').version) ? require('./package.json').version.split('-')[1].split('.')[0] : 'latest'") - npm publish --provenance --tag "$TAG" --no-git-checks + npm publish ./effect-query.tgz --provenance --tag "$TAG" + npm publish ./effect-query-react.tgz --provenance --tag "$TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index c5077e7..3787e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0-beta.0] - Unreleased + +### Added + +- Optional `@wthw7/effect-query-react` package: runtime-bound `useQuery` and + `useMutation`, fiber cancellation on cleanup, typed Cause/Exit outcomes, and + concurrent mutation tracking. Hooks expose fetch snapshots, not cache subscriptions. +- Separate React behavior and compile-time tests, included in `pnpm check` and CI. +- Workspace packaging and release support for both packages; core remains React-free. +- Phase 1 core additions since alpha: custom hashers and `KeyHashError`, prefetch, + cancel, and focus/reconnect notifications. + ## [0.1.0-alpha.0] - 2026-09-04 First public alpha, published as `@wthw7/effect-query`. diff --git a/README.md b/README.md index a4f4130..c35d59c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # effect-query -[![version](https://img.shields.io/badge/version-0.1.0--alpha.0-blue)](https://www.npmjs.com/package/@wthw7/effect-query) +[![version](https://img.shields.io/badge/version-0.3.0--beta.0-blue)](https://www.npmjs.com/package/@wthw7/effect-query) [![CI](https://github.com/matheuseabra/effect-query/actions/workflows/ci.yml/badge.svg)](https://github.com/matheuseabra/effect-query/actions/workflows/ci.yml) [![license](https://img.shields.io/github/license/matheuseabra/effect-query.svg)](./LICENSE) @@ -24,6 +24,8 @@ parallel runtime. ## Features - UI-agnostic core — works in any Effect program; UI bindings stay out of core +- Optional [React adapter](./packages/react/README.md) with runtime-bound query + and mutation hooks, typed outcomes, and unmount cancellation - Structural readonly-tuple keys shared by reads, writes, and invalidation - Fresh/stale caching with stale-while-revalidate and background refresh - Single-flight in-flight deduplication across concurrent fetches diff --git a/docs/architecture.md b/docs/architecture.md index ebc30d7..c622f55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ Failures are never cached as data. A failed revalidation leaves the previous suc - `notifyFocus` / `notifyReconnect` revalidate opted-in entries in background daemons; queries needing services beyond `QueryService` fail silently there without touching cached data. -- React bindings, persistence, pagination, SSR hydration, and devtools remain +- Persistence, pagination, SSR hydration, and devtools remain out of scope (see `docs/roadmap.md`). ## Interruption semantics per operation @@ -56,4 +56,34 @@ Failures are never cached as data. A failed revalidation leaves the previous suc ## Verification surface +### React adapter + +`packages/react` is a separate workspace package with a one-way dependency on +core and React/Effect peer dependencies. `createQueryHooks` captures a supplied +`Runtime` once; it does not own runtime disposal. This keeps +injected application requirements checked at the hook boundary without erasing +them through a React context. The application disposes its managed runtime after +unmounting the React tree. + +Each query hook forks a core fetch after commit. A hashed key plus the stable +definition determines request identity. Cleanup marks the observer inactive and +interrupts its fiber. A new request awaits the previous fiber's finalizers before +starting, so React StrictMode replay cannot join the request it just cancelled. +Results carry their request identity to prevent displaying old-key data between +render and effect setup. Causes preserve both typed errors and defects. + +Mutation hooks track concurrent fibers per definition and settle promises with +typed `Exit` values. Cleanup disables that definition's callbacks and interrupts +its outstanding fibers; late completions do not update inactive React state. + +The adapter delegates cache behavior and request ownership to core. In particular, +cancelling the winning hook interrupts surviving joiners, and detached stale +revalidation outlives hook cleanup. There is no reactive cache subscription in +this phase: hooks display fetch snapshots, so background cache changes do not +automatically rerender consumers. Core cache semantics and coverage gates are +unchanged. React behavior tests run separately in jsdom, with fake timers for +mount, key change, cleanup, StrictMode, errors, and concurrent mutations. + +### Core + The tests exercise the public seam rather than private maps: fresh caching, in-flight deduplication, key isolation, custom hashers and typed hash failures, retries and exhaustion, manual cache operations, stale-while-revalidate, cache-time garbage collection, failure visibility, winner/joiner interruption, cancellation and slot reuse, prefetch warming, focus/reconnect triggers, and mutation success and failure handling. `pnpm test` enforces 100% line/function coverage thresholds (see `vitest.config.ts`). diff --git a/docs/roadmap.md b/docs/roadmap.md index a217625..de76bc7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,19 +55,24 @@ publish `0.2.0-beta.0` and verify the tarball as in Phase 0. Goal: prove the core is truly UI-agnostic with a first adapter. -- [ ] New package (e.g. `effect-query-react`) depending on core, never the +- [x] New package (`@wthw7/effect-query-react`) depending on core, never the reverse. React stays out of core `package.json`. -- [ ] `useQuery(definition, ...args)` returning `{ data, error, isPending }` +- [x] `useQuery(definition, ...args)` returning `{ data, error, isPending }` driven by Effect fibers, with unmount cancellation. -- [ ] `useMutation(definition)` returning `{ mutate, isPending }`. -- [ ] Adapter test suite with fake timers covering mount, key change, +- [x] `useMutation(definition)` returning `{ mutate, isPending }`. +- [x] Adapter test suite with fake timers covering mount, key change, unmount-cancel, and error states. -- [ ] README section with framework example; core README links to it. +- [x] README section with framework example; core README links to it. Quality gate: adapter tests green in CI, core coverage thresholds untouched, example app (or test harness) demonstrates mount → fetch → unmount-cancel, publish `0.3.0-beta.0` for both packages. +Implementation is ready for review. Both manifests and the release workflow are +prepared for `0.3.0-beta.0`; publishing and registry verification remain pending. +The new adapter needs npm Trusted Publishing configuration before release. See +its [README](../packages/react/README.md) for setup and fetch-snapshot limitations. + ## Phase 3 — Advanced cache Goal: close the remaining v0.1 out-of-scope items, one at a time, each diff --git a/docs/spec.md b/docs/spec.md index 9334846..7f9cc23 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -95,16 +95,36 @@ const updateUser = Mutation.make({ - **Deduping**: Identical in-flight keys share the same fiber - **Stale-while-revalidate**: Returns cached data immediately, refetches in background when stale - **Typed everything**: Success, error, and requirements flow through Effect -- **No React binding required**: Core is pure Effect; a React adapter is future work +- **No React binding required**: Core is pure Effect; React hooks live in a separate optional package -## React Adapter (future) +## React Adapter ```ts +const { useQuery, useMutation } = createQueryHooks(await managed.runtime()) const { data, error, isPending } = useQuery(userQuery, "123") const { mutate } = useMutation(updateUser) ``` -The adapter is not included in v0.1. A future package may build on Effect fibers and React concurrent features while keeping the core package React-free. +`@wthw7/effect-query-react` binds hooks to an application-owned Effect runtime +with `createQueryHooks`. The runtime must provide `QueryService` and the +definitions' requirements. React remains absent from the core package manifest. + +`useQuery` starts a fetch fiber after commit and interrupts it on key/definition +changes or unmount. Identity is the stable definition plus its hashed key. It +returns `data: A | undefined`, `error: Cause | undefined`, and +`isPending: boolean`; a changed identity clears the displayed result immediately. +Core winner/joiner interruption and detached background revalidation semantics +remain unchanged. Results are fetch snapshots: mounted hooks do not subscribe +to cache writes, invalidation, or background revalidation completion. + +`useMutation` returns `mutate(...args): Promise>` and `isPending`. +Pending covers all concurrent calls through completion of `onSuccess`. Unmount +or definition changes interrupt outstanding mutation fibers. Retained callbacks +cannot start work after cleanup. Outcomes preserve typed errors and defects +without unhandled promise rejections. + +See the [adapter README](../packages/react/README.md) for a complete example, +runtime ownership, cancellation details, and current limitations. ## Out of Scope (v0.1) diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000..3f7ef6d --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,121 @@ +# @wthw7/effect-query-react + +React 18/19 hooks for [Effect Query](../../README.md). The adapter depends on +the core package; the core has no React dependency. + +## Install + +```sh +pnpm add @wthw7/effect-query-react@beta @wthw7/effect-query@beta effect react react-dom +``` + +Version `0.3.0-beta.0` is prepared for release; the install command works once +both packages have been published. + +## Example + +Create the runtime and hooks once during application startup. Merge application +service layers with `queryLayer()` before obtaining the runtime; the hooks only +accept definitions whose requirements that runtime provides. + +```tsx +import { Mutation, Query, queryLayer } from "@wthw7/effect-query" +import { createQueryHooks } from "@wthw7/effect-query-react" +import { Cause, Effect, Exit, ManagedRuntime } from "effect" +import { createRoot } from "react-dom/client" + +const managed = ManagedRuntime.make(queryLayer()) +const { useQuery, useMutation } = createQueryHooks(await managed.runtime()) + +const userQuery = Query.make({ + key: (id: string) => ["user", id] as const, + execute: (id) => Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(`/api/users/${id}`, { signal }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return await response.text() + }, + catch: (error) => String(error) + }), + staleTime: "30 seconds" +}) + +const saveName = Mutation.make({ + execute: (name: string) => Effect.tryPromise({ + try: (signal) => fetch("/api/name", { method: "PUT", body: name, signal }).then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return name + }), + catch: (error) => String(error) + }), + onSuccess: () => Query.invalidate(["user", "123"]) +}) + +function User() { + const { data, error, isPending } = useQuery(userQuery, "123") + const { mutate, isPending: isSaving } = useMutation(saveName) + if (isPending) return

Loading…

+ if (error) return
{Cause.pretty(error)}
+ return +} + +const root = createRoot(document.getElementById("root")!) +root.render() + +// At application shutdown (not after each render): +// root.unmount() +// await managed.dispose() +``` + +## Contract + +- `createQueryHooks(runtime)` returns `useQuery` and `useMutation`; no global + runtime or provider is installed. Create separate hook sets for separate caches. +- `useQuery(definition, ...args)` returns `{ data, error, isPending }`. + `error` is `Cause | undefined`, preserving typed failures, + defects, and interruption. Inspect typed failures with `Cause.failureOption`. +- Keep definitions stable (module scope or `useMemo`). Request identity is the + definition plus the hashed key. Fresh argument objects with the same hash do + not restart a request. Include every input affecting the result in the key; + custom hashers and key functions must be pure and deterministic. +- Mount, definition changes, and key changes start a `Query.fetch` fiber. Key + changes immediately show pending state without displaying the previous key's + data. Cleanup interrupts the hook's fiber and ignores its late completion. +- The core's winner/joiner semantics apply: removing the winning observer cancels + the shared request, and surviving joiners receive an interruption cause. + Removing a joiner cancels only its own wait. This adapter does not introduce + reference-counted request ownership. +- Results are snapshots of `Query.fetch`, not cache subscriptions. A stale cache + hit returns stale data while core revalidation runs independently. Background + completion, `setData`, and invalidation do not update mounted hook results; + remount or change the query key/definition to read again. Background core + daemons are not owned or cancelled by hook cleanup. +- `useMutation(definition)` returns `{ mutate, isPending }`. `mutate(...args)` + returns `Promise>`, which resolves for typed failures, defects, + and interruption as well as success. Pending remains true until all concurrent + invocations (including `onSuccess`) complete. Cleanup interrupts outstanding + mutation fibers; retained callbacks resolve with interruption without starting + work. Interruption cannot undo a write already received by a server. +- SSR/hydration and automatic browser focus/reconnect listeners are outside this + adapter's scope. Effects start after commit; server rendering remains pending. + +## Development + +From the repository root, run `pnpm install` then `pnpm check`. The root builds +core before typechecking and testing the adapter. The fake-timer harness in +`test/query.test.tsx` proves mount → fetch → unmount cancellation, key changes, +StrictMode replay, and shared-request behavior. Mutation tests cover concurrency, +typed outcomes, cleanup, and core success handlers. Each package independently +enforces 100% line/function coverage. + +## Release + +Both packages are versioned at `0.3.0-beta.0`. Before pushing the release tag, +configure npm Trusted Publishing for `@wthw7/effect-query-react` using this +repository's `.github/workflows/release.yml` workflow (bootstrap the package if +npm requires it). The release workflow packs with pnpm so `workspace:^` becomes +a registry version, then publishes core followed by the adapter through npm OIDC. +Opening a PR does not publish either package. From 7ef53615a5ffe563d693416359ae9397f7e01f5e Mon Sep 17 00:00:00 2001 From: Matheus Seabra Date: Sat, 5 Sep 2026 01:12:28 -0300 Subject: [PATCH 3/3] build: orchestrate workspace checks with Turborepo --- .github/workflows/ci.yml | 8 +++-- .github/workflows/release.yml | 2 -- .gitignore | 3 +- AGENTS.md | 7 ++++ CHANGELOG.md | 2 ++ README.md | 19 +++++++++-- docs/architecture.md | 10 ++++++ package.json | 4 ++- packages/react/README.md | 7 ++-- pnpm-lock.yaml | 64 +++++++++++++++++++++++++++++++++++ turbo.json | 38 +++++++++++++++++++++ 11 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 turbo.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a311130..1341e02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,13 +12,17 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile + - uses: actions/cache@v4 + with: + path: .turbo/cache + key: ${{ runner.os }}-node22-turbo-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-node22-turbo-${{ hashFiles('pnpm-lock.yaml') }}- - run: pnpm check - uses: actions/upload-artifact@v4 if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da2424b..3c4da48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: # Node 24 ships npm 11.5.1+, required for OIDC trusted publishing. diff --git a/.gitignore b/.gitignore index 5a19e8a..ea5856d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules dist -coverage \ No newline at end of file +coverage +.turbo diff --git a/AGENTS.md b/AGENTS.md index b7135d3..57df176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,16 @@ Preserve the boundaries described in the spec: typed errors and requirements sho - `pnpm install` — install dependencies. - `pnpm check` — typecheck, lint, test (with enforced coverage thresholds), and build. +- `pnpm check --force` — rerun all Turborepo tasks without reading cached results. +- `pnpm exec turbo run build` — build core and the React adapter in dependency order. - `pnpm test:watch` — rerun tests on change (no coverage). - `pnpm build` — emit `dist/` via `tsconfig.build.json`. +pnpm manages the workspace; Turborepo coordinates `pnpm check` across core at +the root and `packages/react`. Core commands are registered root tasks in +`turbo.json`; adapter build/typecheck/test depend on the core build. Keep task +inputs and cached outputs accurate when adding files or configuration. + During documentation-only changes, use `git diff --check` to catch whitespace errors and review the rendered Markdown. ## Coding Style & Naming Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 3787e88..8f18a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 concurrent mutation tracking. Hooks expose fetch snapshots, not cache subscriptions. - Separate React behavior and compile-time tests, included in `pnpm check` and CI. - Workspace packaging and release support for both packages; core remains React-free. +- Turborepo task orchestration and local/CI caching for builds, coverage tests, + typechecking, and linting, with pnpm retained as the workspace package manager. - Phase 1 core additions since alpha: custom hashers and `KeyHashError`, prefetch, cancel, and focus/reconnect notifications. diff --git a/README.md b/README.md index c35d59c..aa793f8 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,23 @@ pnpm install pnpm check ``` -`pnpm check` runs typecheck, lint, tests with enforced coverage thresholds, -and build. Please keep 100% line coverage, name tests after observable +`pnpm check` uses Turborepo to run typecheck, lint, coverage tests, and build +for core and the React adapter. pnpm manages workspace dependencies; Turbo +orders tasks and caches successful results in `.turbo/cache`. The adapter's +build, typecheck, and tests wait for core's build. Shared TypeScript and lint +configuration changes invalidate the cache, and build/coverage artifacts are +restored on cache hits. CI retains the task cache with GitHub Actions caching; +no remote-cache account is required. + +Core stays at the repository root, with its commands registered as Turbo root +tasks in `turbo.json`; the adapter lives in `packages/react`. `pnpm build`, +`pnpm test`, `pnpm lint`, and `pnpm typecheck` at the root still target core. +Use `pnpm exec turbo run build` to build both packages, or +`pnpm exec turbo run test --filter=@wthw7/effect-query-react` for adapter tests +with core built first. `pnpm check --force` reruns all tasks without cache hits. +Watch mode remains available through `pnpm test:watch` for core. + +Please keep 100% line coverage, name tests after observable behavior, and use concise imperative commit subjects (`feat:`, `fix:`, `test:`, `docs:`). For larger changes, open an issue first so the design can be discussed before code. diff --git a/docs/architecture.md b/docs/architecture.md index c622f55..11c7d39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,6 +58,16 @@ Failures are never cached as data. A failed revalidation leaves the previous suc ### React adapter +pnpm links the workspace packages, while Turborepo orchestrates `pnpm check`. +Core remains the root package: its build, lint, test, and typecheck commands are +registered explicitly as `//#...` root tasks. Adapter build, typecheck, and test +tasks depend on `//#build`, because core's exported runtime and declarations +come from `dist/`. Root tasks have scoped inputs so adapter-only changes do not +invalidate core results. Shared TypeScript/lint configs participate in global +cache hashes; successful builds and coverage reports are cached as outputs. +The explicit `.turbo/cache` location keeps worktree caches isolated, and CI +persists that directory between runs. No publishing command is cached. + `packages/react` is a separate workspace package with a one-way dependency on core and React/Effect peer dependencies. `createQueryHooks` captures a supplied `Runtime` once; it does not own runtime disposal. This keeps diff --git a/package.json b/package.json index 054a03c..e60f679 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "node": ">=18" }, "type": "module", + "packageManager": "pnpm@10.30.1", "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -40,7 +41,7 @@ "test": "vitest run --coverage", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm --filter @wthw7/effect-query-react check", + "check": "turbo run typecheck lint test build", "prepublishOnly": "pnpm check" }, "dependencies": { @@ -51,6 +52,7 @@ "@vitest/coverage-v8": "^5.0.0", "oxlint": "^1.81.0", "tsx": "^4.23.13", + "turbo": "^2.10.12", "typescript": "^7.0.2", "vitest": "^5.0.0" } diff --git a/packages/react/README.md b/packages/react/README.md index 3f7ef6d..1fd681b 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -104,8 +104,11 @@ root.render() ## Development -From the repository root, run `pnpm install` then `pnpm check`. The root builds -core before typechecking and testing the adapter. The fake-timer harness in +From the repository root, run `pnpm install` then `pnpm check`. Turborepo runs +both packages' checks, builds core before the adapter's dependent tasks, and +caches build and coverage artifacts. For the adapter suite alone, run +`pnpm exec turbo run test --filter=@wthw7/effect-query-react` from the root; +this also builds core when needed. The fake-timer harness in `test/query.test.tsx` proves mount → fetch → unmount cancellation, key changes, StrictMode replay, and shared-request behavior. Mutation tests cover concurrency, typed outcomes, cleanup, and core success handlers. Each package independently diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3edab91..1c50aad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: tsx: specifier: ^4.23.13 version: 4.23.13 + turbo: + specifier: ^2.10.12 + version: 2.10.12 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -533,6 +536,36 @@ packages: '@types/react-dom': optional: true + '@turbo/darwin-64@2.10.12': + resolution: {integrity: sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.10.12': + resolution: {integrity: sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.10.12': + resolution: {integrity: sha512-lr7KIotukvjZwEXiFSYAeOH3BWzjFVBbSzTbv0fuGFsNukYyH0+g1hB5ecqnJkgkYU+KHEMG1edOhnjiKON1wQ==} + cpu: [x64] + os: [android, linux] + + '@turbo/linux-arm64@2.10.12': + resolution: {integrity: sha512-f0pZDTtvzB5SuNwuXBaKbZHUCMCukgc8nMlHEuvLmj91Fzec+MEbr3cAvGNor5htEDqZnO6Lxt9N/GPI/77oGA==} + cpu: [arm64] + os: [android, linux] + + '@turbo/windows-64@2.10.12': + resolution: {integrity: sha512-SDOueJRjS/QcykWf2KCRtTLmIl5YMKsLbXkXQGhDwcTXvKXZiS5ih5lBl/gkwZIpYFjqA/rAlfMzlAFcVHNe0g==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.10.12': + resolution: {integrity: sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA==} + cpu: [arm64] + os: [win32] + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1063,6 +1096,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + turbo@2.10.12: + resolution: {integrity: sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw==} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -1469,6 +1506,24 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@turbo/darwin-64@2.10.12': + optional: true + + '@turbo/darwin-arm64@2.10.12': + optional: true + + '@turbo/linux-64@2.10.12': + optional: true + + '@turbo/linux-arm64@2.10.12': + optional: true + + '@turbo/windows-64@2.10.12': + optional: true + + '@turbo/windows-arm64@2.10.12': + optional: true + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': @@ -1933,6 +1988,15 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + turbo@2.10.12: + optionalDependencies: + '@turbo/darwin-64': 2.10.12 + '@turbo/darwin-arm64': 2.10.12 + '@turbo/linux-64': 2.10.12 + '@turbo/linux-arm64': 2.10.12 + '@turbo/windows-64': 2.10.12 + '@turbo/windows-arm64': 2.10.12 + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..7c82908 --- /dev/null +++ b/turbo.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "cacheDir": ".turbo/cache", + "globalDependencies": ["tsconfig.json", ".oxlintrc.json"], + "tasks": { + "build": { + "dependsOn": ["//#build"], + "outputs": ["dist/**"] + }, + "typecheck": { + "dependsOn": ["//#build"], + "outputs": [] + }, + "lint": { + "outputs": [] + }, + "test": { + "dependsOn": ["//#build"], + "outputs": ["coverage/**"] + }, + "//#build": { + "inputs": ["src/**", "tsconfig.json", "tsconfig.build.json"], + "outputs": ["dist/**"] + }, + "//#typecheck": { + "inputs": ["src/**", "test/**", "tsconfig.json"], + "outputs": [] + }, + "//#lint": { + "inputs": ["src/**", "test/**"], + "outputs": [] + }, + "//#test": { + "inputs": ["src/**", "test/**", "vitest.config.ts", "tsconfig.json", "README.md"], + "outputs": ["coverage/**"] + } + } +}