Add Effect v4 support, Fast Refresh tooling, and revamped docs (#56)
Lint / lint (push) Successful in 57s
Publish / publish (push) Failing after 57s

## Summary

- Add `effect-fc-next`, a React 19 integration targeting Effect v4 beta.
- Introduce component lifecycles, scoped resources, queries, mutations, forms, lenses, views, and refreshable components.
- Add `@effect-view/vite-plugin` for Vite Fast Refresh support.
- Add an Effect v4 example application covering the new APIs.
- Expand test coverage for both the existing and next-generation packages.
- Replace the starter Docusaurus content with complete Effect View documentation while preserving the Effect v3 docs as a versioned snapshot.
- Update the landing page, navigation, package scripts, and build output.
- Extend CI with linting, tests, package builds, Docker builds, and container publishing.

## Validation

- `bun lint:tsc`
- `bun lint:biome`
- `bun test`
- `bun run build`
- `bun pack`
- Docker image build

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: Thilawyn/effect-fc#56
This commit was merged in pull request #56.
This commit is contained in:
2026-07-26 02:32:59 +02:00
parent 721ab7d736
commit 4478598f15
138 changed files with 13360 additions and 1970 deletions
+167
View File
@@ -0,0 +1,167 @@
import { Effect, type Scope, Stream } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import { describe, expect, it } from "vitest"
import * as Query from "./Query.js"
import * as QueryClient from "./QueryClient.js"
import * as View from "./View.js"
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.layer()),
)))
const staticKey = <K>(key: K): View.View<K> => View.make({
get: Effect.succeed(key),
changes: Stream.make(key),
})
const expectSuccessValue = <A, E>(
state: Query.FinalQueryState<unknown, A, E>,
): A => {
expect(AsyncResult.isSuccess(state.result)).toBe(true)
if (!AsyncResult.isSuccess(state.result))
throw new Error(`Expected Success result, received ${state.result._tag}`)
return state.result.value
}
describe("Query", () => {
it("fetch caches successful results until they are invalidated or stale", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(1)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:1")
})
it("refresh reruns the latest query key", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "0 millis",
})
const first = yield* query.fetch([1])
yield* Effect.sleep("1 millis")
const refreshed = yield* query.refresh
return [first, refreshed] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
yield* query.invalidateCacheEntry([1])
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("invalidateCache clears cached entries for the query function", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
yield* query.invalidateCache
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("service starts the key view automatically and records its latest final state", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
const effect = Effect.gen(function*() {
const query = yield* Query.service({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
Effect.andThen(View.get(query.latestFinalState)),
Effect.flatMap(Effect.fromOption),
Effect.eventually,
Effect.timeout("1 second"),
)
return {
state: yield* View.get(query.state),
latestFinalState,
}
})
const result = await runQueryTest(effect)
expect(calls).toBe(1)
expect(result.state.key).toEqual([1])
expect(expectSuccessValue(result.latestFinalState)).toBe("value:1:1")
})
})