2 Commits

Author SHA1 Message Date
renovate-bot 112e91d199 Update actions/checkout action to v7
Lint / lint (push) Failing after 37s
Test build / test-build (pull_request) Successful in 2m13s
2026-07-30 12:06:44 +00:00
Julien Valverdé e412943e64 Add Query.withScheduledRefresh
Lint / lint (push) Successful in 1m53s
2026-07-30 13:09:24 +02:00
16 changed files with 120 additions and 110 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Lint TypeScript - name: Lint TypeScript
+2 -2
View File
@@ -14,14 +14,14 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to Container Registry - name: Login to Container Registry
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: docker.valverde.cloud registry: docker.valverde.cloud
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Clone repo - name: Clone repo
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
+11 -11
View File
@@ -85,18 +85,18 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.102", "effect-lens": "^2.0.1-beta.101",
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-browser": "4.0.0-beta.101",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.102", "effect": "4.0.0-beta.101",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "4.0.0-beta.102", "effect": "4.0.0-beta.101",
"react": "^19.2.0", "react": "^19.2.0",
}, },
}, },
@@ -104,9 +104,9 @@
"name": "@effect-view/example", "name": "@effect-view/example",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-browser": "4.0.0-beta.101",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.102", "effect": "4.0.0-beta.101",
"effect-view": "workspace:*", "effect-view": "workspace:*",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
}, },
@@ -3052,9 +3052,9 @@
"@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], "@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="], "@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.101", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-05//60oEzMyQyNjk8Ll1mML6gU2RvT9TTaomq5cGmNQu6Gzb6jlWfzn2/ZGzFtehhB9iOpqtjy0QkT5tDh9ElA=="],
"@effect-view/example/effect": ["effect@4.0.0-beta.102", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w=="], "@effect-view/example/effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
"@effect-view/vite-plugin/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "@effect-view/vite-plugin/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -3122,11 +3122,11 @@
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="], "effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.101", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-05//60oEzMyQyNjk8Ll1mML6gU2RvT9TTaomq5cGmNQu6Gzb6jlWfzn2/ZGzFtehhB9iOpqtjy0QkT5tDh9ElA=="],
"effect-view/effect": ["effect@4.0.0-beta.102", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w=="], "effect-view/effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
"effect-view/effect-lens": ["effect-lens@2.0.1-beta.102", "", { "peerDependencies": { "effect": "4.0.0-beta.102" } }, "sha512-YQSPI/r42Do/24X+RurTd4BLV2EdxG4LcgySpR4Pt7W2Z+wCmoBlSAV+B7rs2lTpqOny3kKTLnn9tbpfQVzSAA=="], "effect-view/effect-lens": ["effect-lens@2.0.1-beta.101", "", { "peerDependencies": { "effect": "4.0.0-beta.101" } }, "sha512-ME5JruBkF3AG34rhb9MlHb1uwEbW70hr6lFITf8tcHBTgVF5QXFCe8nWATraYZpxBHGqM2bOnRvoadmwh2yyvQ=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+16 -14
View File
@@ -54,9 +54,11 @@ valid decoded value should go:
## Create forms once ## Create forms once
`MutationForm.make` and `LensForm.make` construct forms. Pipe them through the `MutationForm.service` and `LensForm.service` are Effect constructors, not
module's `run` operator to start validation or synchronization in the current hooks. Create each root form once and keep it stable. For a component-owned
scope. Create each root form once and keep it stable. form, call the constructor inside `Component.useOnMount`; for a form shared by
multiple components, create it in an Effect service. Update the existing form's
Lenses rather than reconstructing the form during render.
## MutationForm: validate, then submit ## MutationForm: validate, then submit
@@ -71,7 +73,7 @@ import { Component, MutationForm, View } from "effect-view"
const CreateProfileView = Component.make("CreateProfile")(function* () { const CreateProfileView = Component.make("CreateProfile")(function* () {
const form = yield* Component.useOnMount(() => const form = yield* Component.useOnMount(() =>
MutationForm.make({ MutationForm.service({
schema: ProfileSchema, schema: ProfileSchema,
initialEncodedValue: { initialEncodedValue: {
displayName: "", displayName: "",
@@ -82,7 +84,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () {
Effect.log( Effect.log(
`Creating ${profile.displayName}, age ${profile.age}`, `Creating ${profile.displayName}, age ${profile.age}`,
), ),
}).pipe(MutationForm.thenRun), }),
) )
const [canCommit, isCommitting] = yield* View.useAll([ const [canCommit, isCommitting] = yield* View.useAll([
@@ -108,9 +110,9 @@ mutation receives the schema's decoded profile, so `profile.age` is a number.
Schema transformations happen before the mutation and schema issues prevent an Schema transformations happen before the mutation and schema issues prevent an
invalid draft from being submitted. invalid draft from being submitted.
`MutationForm.thenRun` starts initial validation. In the example above, `MutationForm.service` also starts initial validation in the current scope. In
`Component.useOnMount` keeps that validation and the form's mutation work tied the example above, `Component.useOnMount` keeps that validation and the form's
to the component that owns them. mutation work tied to the component that owns them.
## LensForm: validate, then synchronize ## LensForm: validate, then synchronize
@@ -134,10 +136,10 @@ const EditProfileView = Component.make("EditProfile")(function* () {
}), }),
) )
const form = yield* LensForm.make({ const form = yield* LensForm.service({
schema: ProfileSchema, schema: ProfileSchema,
target: profile, target: profile,
}).pipe(LensForm.thenRun) })
return [form, profile] as const return [form, profile] as const
}), }),
@@ -166,8 +168,8 @@ reaches the target. If another part of the application updates the target,
`LensForm` encodes that value back into the draft. `LensForm` encodes that value back into the draft.
Pass `initialEncodedValue` only when the first draft should differ from the Pass `initialEncodedValue` only when the first draft should differ from the
encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding encoded target. Otherwise `LensForm.service` obtains the initial draft by
the target through the schema. encoding the target through the schema.
## Focus into subforms ## Focus into subforms
@@ -425,14 +427,14 @@ const AppointmentSchema = Schema.Struct({
const AppointmentView = Component.make("Appointment")(function* () { const AppointmentView = Component.make("Appointment")(function* () {
const [form, startsAtField] = yield* Component.useOnMount(() => const [form, startsAtField] = yield* Component.useOnMount(() =>
Effect.gen(function* () { Effect.gen(function* () {
const form = yield* MutationForm.make({ const form = yield* MutationForm.service({
schema: AppointmentSchema, schema: AppointmentSchema,
initialEncodedValue: { startsAt: "" }, initialEncodedValue: { startsAt: "" },
f: ([appointment]) => f: ([appointment]) =>
Effect.log( Effect.log(
`Saving ${DateTime.formatIso(appointment.startsAt)}`, `Saving ${DateTime.formatIso(appointment.startsAt)}`,
), ),
}).pipe(MutationForm.thenRun) })
return [form, Form.focusObjectOn(form, "startsAt")] as const return [form, Form.focusObjectOn(form, "startsAt")] as const
}), }),
+10 -12
View File
@@ -57,11 +57,11 @@ client defaults. Window-focus refresh also requires the optional
## Create a reactive query ## Create a reactive query
A query is driven by a `View` rather than by a value read during one React A query is driven by a `View` rather than by a value read during one React
render. Whenever that key changes, a running Query checks the cache and starts render. Whenever that key changes, `Query.service` checks the cache and starts
the query effect when necessary. the query effect when necessary.
`Query.make` constructs the Query and `Query.thenRun` starts it in the current `Query.service` is an Effect constructor, not a hook. Create each query instance
scope. Create each query instance once and keep it stable. In a component, the usual place is once and keep it stable. In a component, the usual place is
`Component.useOnMount`; a query shared by multiple components can instead be `Component.useOnMount`; a query shared by multiple components can instead be
owned by an Effect service. Change the existing query's reactive key rather owned by an Effect service. Change the existing query's reactive key rather
than reconstructing the query during render. than reconstructing the query during render.
@@ -84,7 +84,7 @@ const PostView = Component.make("Post")(function* () {
yield* SubscriptionRef.make(["post", 1 as number] as const), yield* SubscriptionRef.make(["post", 1 as number] as const),
) )
const query = yield* Query.make({ const query = yield* Query.service({
key, key,
staleTime: "1 minute", staleTime: "1 minute",
f: ([, id]) => f: ([, id]) =>
@@ -95,7 +95,7 @@ const PostView = Component.make("Post")(function* () {
Effect.andThen((response) => response.json), Effect.andThen((response) => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)), Effect.andThen(Schema.decodeUnknownEffect(Post)),
), ),
}).pipe(Query.thenRun) })
return [Lens.focusTupleAt(key, 1), query] as const return [Lens.focusTupleAt(key, 1), query] as const
}), }),
@@ -124,7 +124,7 @@ Effect equality by default, so structurally equal Effect data types work well
as query keys. Supply `keyEquivalence` when the key needs different equality as query keys. Supply `keyEquivalence` when the key needs different equality
semantics. semantics.
`Query.thenRun` starts watching its key in the current scope. The `Query.service` starts watching its key in the current scope. The
`Component.useOnMount` call above keeps both the query instance and its query `Component.useOnMount` call above keeps both the query instance and its query
function identity stable for the component's lifetime. function identity stable for the component's lifetime.
@@ -255,8 +255,7 @@ Refresh every five minutes, starting after five minutes:
```ts ```ts
import { Schedule } from "effect" import { Schedule } from "effect"
const query = yield* Query.make(options).pipe( yield* query.pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes")), Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
) )
``` ```
@@ -264,8 +263,7 @@ const query = yield* Query.make(options).pipe(
Limit the number of refreshes: Limit the number of refreshes:
```ts ```ts
const query = yield* Query.make(options).pipe( yield* query.pipe(
Query.thenRun,
Query.withScheduledRefresh( Query.withScheduledRefresh(
Schedule.spaced("5 minutes").pipe( Schedule.spaced("5 minutes").pipe(
Schedule.upTo({ times: 3 }), Schedule.upTo({ times: 3 }),
@@ -291,12 +289,12 @@ By default, the client enables refresh on browser window focus. Set it globally
or override it for one query: or override it for one query:
```tsx ```tsx
const query = yield* Query.make({ const query = yield* Query.service({
key, key,
f: loadPost, f: loadPost,
staleTime: "10 seconds", staleTime: "10 seconds",
refreshOnWindowFocus: false, refreshOnWindowFocus: false,
}).pipe(Query.thenRun) })
``` ```
Window-focus refresh depends on the optional `@effect/platform-browser` Window-focus refresh depends on the optional `@effect/platform-browser`
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "effect-view", "name": "effect-view",
"description": "Write React function components with Effect", "description": "Write React function components with Effect",
"version": "0.1.1", "version": "0.1.0",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -97,19 +97,19 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-browser": "4.0.0-beta.101",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.102", "effect": "4.0.0-beta.101",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "4.0.0-beta.102", "effect": "4.0.0-beta.101",
"react": "^19.2.0" "react": "^19.2.0"
}, },
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.102" "effect-lens": "^2.0.1-beta.101"
} }
} }
+10 -5
View File
@@ -189,13 +189,18 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
) )
}) })
export const thenRun = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never, E = never, R = never>( export declare namespace service {
self: Effect.Effect<LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, E, R>, export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never>
extends make.Options<A, I, RD, RE, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
): Effect.Effect< ): Effect.Effect<
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
E, Schema.SchemaError | TER,
Scope.Scope | R Scope.Scope | RD | RE | TRR | TRW
> => Effect.tap( > => Effect.tap(
self, make(options),
form => Effect.forkScoped(form.run), form => Effect.forkScoped(form.run),
) )
+10 -5
View File
@@ -196,13 +196,18 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
) )
}) })
export const thenRun = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never, E = never, R = never>( export declare namespace service {
self: Effect.Effect<MutationForm<A, I, RD, RE, MA, ME, MR>, E, R>, export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never>
extends make.Options<A, I, RD, RE, MA, ME, MR> {}
}
export const service = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
options: service.Options<A, I, RD, RE, MA, ME, MR>
): Effect.Effect< ): Effect.Effect<
MutationForm<A, I, RD, RE, MA, ME, MR>, MutationForm<A, I, RD, RE, MA, ME, MR>,
E, never,
Scope.Scope | R Scope.Scope | RD | RE | MR
> => Effect.tap( > => Effect.tap(
self, make(options),
form => Effect.forkScoped(form.run), form => Effect.forkScoped(form.run),
) )
+11 -11
View File
@@ -92,14 +92,14 @@ describe("Query", () => {
return calls return calls
}), }),
staleTime: "0 millis", staleTime: "0 millis",
}).pipe( })
Query.thenRun,
Query.withScheduledRefresh( yield* query.fetch([1])
Schedule.spaced("1 second").pipe( const returnedQuery = yield* query.pipe(Query.withScheduledRefresh(
Schedule.upTo({ times: 1 }), Schedule.spaced("1 second").pipe(
), Schedule.upTo({ times: 1 }),
), ),
) ))
yield* TestClock.adjust("999 millis") yield* TestClock.adjust("999 millis")
const beforeInterval = calls const beforeInterval = calls
@@ -108,7 +108,7 @@ describe("Query", () => {
const afterFirstInterval = calls const afterFirstInterval = calls
return { return {
isQuery: Query.isQuery(query), returnsQuery: returnedQuery === query,
beforeInterval, beforeInterval,
afterFirstInterval, afterFirstInterval,
} }
@@ -117,7 +117,7 @@ describe("Query", () => {
)) ))
expect(result).toEqual({ expect(result).toEqual({
isQuery: true, returnsQuery: true,
beforeInterval: 1, beforeInterval: 1,
afterFirstInterval: 2, afterFirstInterval: 2,
}) })
@@ -180,14 +180,14 @@ describe("Query", () => {
const key = staticKey<readonly [number]>([1]) const key = staticKey<readonly [number]>([1])
const effect = Effect.gen(function*() { const effect = Effect.gen(function*() {
const query = yield* Query.make({ const query = yield* Query.service({
key, key,
f: ([id]: readonly [number]) => Effect.sync(() => { f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1 calls += 1
return `value:${id}:${calls}` return `value:${id}:${calls}`
}), }),
staleTime: "1 minute", staleTime: "1 minute",
}).pipe(Query.thenRun) })
const latestFinalState = yield* Effect.sleep("1 millis").pipe( const latestFinalState = yield* Effect.sleep("1 millis").pipe(
Effect.andThen(View.get(query.latestFinalState)), Effect.andThen(View.get(query.latestFinalState)),
+21 -19
View File
@@ -410,10 +410,14 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
) )
}) })
export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>( export const service = <K, A, E = never, R = never>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>, options: make.Options<K, A, E, R>
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | R2> => Effect.tap( ): Effect.Effect<
self, Query<K, A, E, R>,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run), query => Effect.forkScoped(query.run),
) )
@@ -422,16 +426,14 @@ export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
* *
* @example Refresh every five minutes, starting after five minutes * @example Refresh every five minutes, starting after five minutes
* ```ts * ```ts
* yield* Query.make(options).pipe( * yield* query.pipe(
* Query.thenRun,
* Query.withScheduledRefresh(Schedule.spaced("5 minutes")), * Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
* ) * )
* ``` * ```
* *
* @example Refresh at most three times * @example Refresh at most three times
* ```ts * ```ts
* yield* Query.make(options).pipe( * yield* query.pipe(
* Query.thenRun,
* Query.withScheduledRefresh( * Query.withScheduledRefresh(
* Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })), * Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })),
* ), * ),
@@ -441,19 +443,19 @@ export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
export const withScheduledRefresh: { export const withScheduledRefresh: {
<Output, Error, Env>( <Output, Error, Env>(
schedule: Schedule.Schedule<Output, unknown, Error, Env>, schedule: Schedule.Schedule<Output, unknown, Error, Env>,
): <K, A, E, R, E2, R2>( ): <K, A, E, R>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>, self: Query<K, A, E, R>,
) => Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2> ) => Effect.Effect<Query<K, A, E, R>, never, Scope.Scope | Env>
<K, A, E, R, E2, R2, Output, Error, Env>( <K, A, E, R, Output, Error, Env>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>, self: Query<K, A, E, R>,
schedule: Schedule.Schedule<Output, unknown, Error, Env>, schedule: Schedule.Schedule<Output, unknown, Error, Env>,
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2> ): Effect.Effect<Query<K, A, E, R>, never, Scope.Scope | Env>
} = Function.dual(2, <K, A, E, R, E2, R2, Output, Error, Env>( } = Function.dual(2, <K, A, E, R, Output, Error, Env>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>, self: Query<K, A, E, R>,
schedule: Schedule.Schedule<Output, unknown, Error, Env>, schedule: Schedule.Schedule<Output, unknown, Error, Env>,
) => Effect.tap( ) => Effect.schedule(self.refresh, schedule).pipe(
self, Effect.forkScoped,
query => Effect.forkScoped(Effect.schedule(query.refresh, schedule)), Effect.as(self),
)) ))
+9 -5
View File
@@ -115,14 +115,18 @@ export const make = Effect.fnUntraced(function* (
) )
}) })
export const thenRun = <E = never, R = never>( export declare namespace service {
self: Effect.Effect<QueryClientService, E, R>, export interface Options extends make.Options {}
): Effect.Effect<QueryClientService, E, Scope.Scope | R> => Effect.tap( }
self,
export const service = (
options?: service.Options
): Effect.Effect<QueryClientService, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
make(options),
client => Effect.forkScoped(client.run), client => Effect.forkScoped(client.run),
) )
export const layer = (options?: make.Options) => Layer.effect(QueryClient, thenRun(make(options))) export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey") export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
+3 -3
View File
@@ -27,15 +27,15 @@
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-browser": "4.0.0-beta.101",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.102", "effect": "4.0.0-beta.101",
"effect-view": "workspace:*", "effect-view": "workspace:*",
"react-icons": "^5.6.0" "react-icons": "^5.6.0"
}, },
"overrides": { "overrides": {
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"effect": "4.0.0-beta.102", "effect": "4.0.0-beta.101",
"react": "^19.2.6" "react": "^19.2.6"
} }
} }
+2 -4
View File
@@ -26,13 +26,11 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
})) }))
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() { const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* MutationForm.make({ const form = yield* MutationForm.service({
schema: RegisterSchema, schema: RegisterSchema,
initialEncodedValue: { email: "", password: "" }, initialEncodedValue: { email: "", password: "" },
f: ([value]) => Effect.log(`Registered ${value.email}`), f: ([value]) => Effect.log(`Registered ${value.email}`),
}).pipe( })
MutationForm.thenRun,
)
const emailField = Form.focusObjectOn(form, "email") const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password") const passwordField = Form.focusObjectOn(form, "password")
+2 -4
View File
@@ -65,16 +65,14 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*(
Stream.runForEach(Lens.changes(appState.lens), Console.log) Stream.runForEach(Lens.changes(appState.lens), Console.log)
) )
const form = yield* LensForm.make({ const form = yield* LensForm.service({
schema: UserProfileSchema, schema: UserProfileSchema,
target: Lens.focusObjectOn(appState.lens, "currentUser"), target: Lens.focusObjectOn(appState.lens, "currentUser"),
initialEncodedValue: { initialEncodedValue: {
email: "", email: "",
password: "", password: "",
}, },
}).pipe( })
LensForm.thenRun,
)
const emailField = Form.focusObjectOn(form, "email") const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password") const passwordField = Form.focusObjectOn(form, "password")
+6 -8
View File
@@ -36,18 +36,16 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const)) const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
const idLens = Lens.focusTupleAt(keyLens, 1) const idLens = Lens.focusTupleAt(keyLens, 1)
const query = yield* Query.make({ const query = yield* Query.service({
key: keyLens, key: keyLens,
f: ([, id]) => HttpClient.HttpClient.pipe( f: ([, id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")), Effect.tap(Effect.sleep("1 second")),
Effect.flatMap(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.flatMap(response => response.json), Effect.andThen(response => response.json),
Effect.flatMap(Schema.decodeUnknownEffect(Post)), Effect.andThen(Schema.decodeUnknownEffect(Post)),
), ),
staleTime: "10 seconds", staleTime: "10 seconds",
}).pipe( })
Query.thenRun,
)
const mutation = yield* Mutation.make({ const mutation = yield* Mutation.make({
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe( f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(