From 59f9358b9acb58b9a504413e5d02faa819afbb95 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 13 Oct 2025 01:00:50 +0200 Subject: [PATCH 001/181] Update dependency @effect/language-service to ^0.44.0 (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [@effect/language-service](https://github.com/Effect-TS/language-service) | [`^0.42.0` -> `^0.44.0`](https://renovatebot.com/diffs/npm/@effect%2flanguage-service/0.42.0/0.44.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@effect%2flanguage-service/0.44.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@effect%2flanguage-service/0.42.0/0.44.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
Effect-TS/language-service (@​effect/language-service) ### [`v0.44.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0440) [Compare Source](https://github.com/Effect-TS/language-service/compare/v0.43.2...v0.44.0) ##### Minor Changes - [#​415](https://github.com/Effect-TS/language-service/pull/415) [`42c66a1`](https://github.com/Effect-TS/language-service/commit/42c66a12658d712671b482fdcce0c5b608171d4f) Thanks [@​mattiamanzati](https://github.com/mattiamanzati)! - Add `diagnosticsName` option to include rule names in diagnostic messages. When enabled (default: true), diagnostic messages will display the rule name at the end, e.g., "Effect must be yielded or assigned to a variable. effect(floatingEffect)" ### [`v0.43.2`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0432) [Compare Source](https://github.com/Effect-TS/language-service/compare/v0.43.1...v0.43.2) ##### Patch Changes - [#​410](https://github.com/Effect-TS/language-service/pull/410) [`0b40c04`](https://github.com/Effect-TS/language-service/commit/0b40c04625cadc0a8dfc3b194daafea1f751a3b9) Thanks [@​mattiamanzati](https://github.com/mattiamanzati)! - Defer typescript loading in CLI ### [`v0.43.1`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0431) [Compare Source](https://github.com/Effect-TS/language-service/compare/v0.43.0...v0.43.1) ##### Patch Changes - [#​408](https://github.com/Effect-TS/language-service/pull/408) [`9ccd800`](https://github.com/Effect-TS/language-service/commit/9ccd8007b338e0524e17d3061acb722ad5c0e87b) Thanks [@​mattiamanzati](https://github.com/mattiamanzati)! - Fix handling of leading/trailing slashes ### [`v0.43.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0430) [Compare Source](https://github.com/Effect-TS/language-service/compare/v0.42.0...v0.43.0) ##### Minor Changes - [#​407](https://github.com/Effect-TS/language-service/pull/407) [`6590590`](https://github.com/Effect-TS/language-service/commit/6590590c0decd83f0baa4fd47655f0f67b6c5db9) Thanks [@​mattiamanzati](https://github.com/mattiamanzati)! - Add deterministicKeys diagnostic to enforce consistent key patterns for Services and Errors This new diagnostic helps maintain consistent and unique keys for Effect Services and Tagged Errors by validating them against configurable patterns. The diagnostic is disabled by default and can be enabled via the `deterministicKeys` diagnosticSeverity setting. Two patterns are supported: - `default`: Constructs keys from package name + file path + class identifier (e.g., `@effect/package/FileName/ClassIdentifier`) - `package-identifier`: Uses package name + identifier for flat project structures Example configuration: ```jsonc { "diagnosticSeverity": { "deterministicKeys": "error" }, "keyPatterns": [ { "target": "service", "pattern": "default", "skipLeadingPath": ["src/"] } ] } ``` The diagnostic also provides auto-fix code actions to update keys to match the configured patterns. ##### Patch Changes - [#​405](https://github.com/Effect-TS/language-service/pull/405) [`f43b3ab`](https://github.com/Effect-TS/language-service/commit/f43b3ab32cad347fb2eb0af740771e35a6c7ff66) Thanks [@​mattiamanzati](https://github.com/mattiamanzati)! - Fix wrapWithEffectGen refactor not working on class heritage clauses The wrapWithEffectGen refactor now correctly skips expressions in heritage clauses (e.g., `extends` clauses in class declarations) to avoid wrapping them inappropriately.
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). Reviewed-on: https://git.valverde.cloud/Thilawyn/effect-fc/pulls/12 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 5a089e1..61aad1c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "@effect-fc/monorepo", "devDependencies": { "@biomejs/biome": "^2.2.5", - "@effect/language-service": "^0.42.0", + "@effect/language-service": "^0.44.0", "@types/bun": "^1.2.23", "npm-check-updates": "^19.0.0", "npm-sort": "^0.0.4", @@ -135,7 +135,7 @@ "@effect-fc/example": ["@effect-fc/example@workspace:packages/example"], - "@effect/language-service": ["@effect/language-service@0.42.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-a5naAdmFxrp6T6IsKNTbsoPJXgn2/WXcjzHHrvq7O/MCCWWiJepSVeJiD8rhb8YsWhiNXnvV5/MzOtljwWHY7w=="], + "@effect/language-service": ["@effect/language-service@0.44.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-ya9cDN0CvmZl2jAhGOBEvp15e+t/9LDdwkH1+vO8wnz/5BdsAZLVo3O3SYO5rtHQiL3rJBwPkn3Z7X6pZ8E8ww=="], "@effect/platform": ["@effect/platform@0.92.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.18.1" } }, "sha512-XXWCBVwyhaKZISN7aM1fv/3fWDGyxr84ObywnUrL8aHvJLoIeskWFAP/fqw3c5MFCrJ3ZV97RWLbv6JiBQugdg=="], diff --git a/package.json b/package.json index 6373aa1..34c8e42 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.2.5", - "@effect/language-service": "^0.42.0", + "@effect/language-service": "^0.44.0", "@types/bun": "^1.2.23", "npm-check-updates": "^19.0.0", "npm-sort": "^0.0.4", -- 2.49.1 From 756b652861fd16b90acb8f090bd46f5566b00c63 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 14 Oct 2025 16:50:05 +0200 Subject: [PATCH 002/181] Update actions/setup-node action to v6 (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/setup-node](https://github.com/actions/setup-node) | action | major | `v5` -> `v6` | --- ### Release Notes
actions/setup-node (actions/setup-node) ### [`v6`](https://github.com/actions/setup-node/compare/v5...v6) [Compare Source](https://github.com/actions/setup-node/compare/v5...v6)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). Reviewed-on: https://git.valverde.cloud/Thilawyn/effect-fc/pulls/13 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .gitea/workflows/test-build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml index 3ff60d7..c394801 100644 --- a/.gitea/workflows/test-build.yaml +++ b/.gitea/workflows/test-build.yaml @@ -10,7 +10,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: "22" - name: Clone repo -- 2.49.1 From a1ec5c47811141139ca40fc4df57da0257d3ba5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 16 Oct 2025 02:00:10 +0200 Subject: [PATCH 003/181] Handle ParseError on form submit --- packages/effect-fc/src/Form.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 26a1e6b..8d032ba 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -156,13 +156,14 @@ export const submit = ( Effect.andThen(identity), Effect.tap(Ref.set(self.submitStateRef, AsyncData.loading())), Effect.andThen(flow( - self.submit, + self.submit as (value: NoInfer) => Effect.Effect, + Effect.tapErrorTag("ParseError", e => Ref.set(self.errorRef, Option.some(e as ParseResult.ParseError))), Effect.exit, Effect.map(Exit.match({ onSuccess: a => AsyncData.success(a), - onFailure: e => AsyncData.failure(e), + onFailure: e => AsyncData.failure(e as Cause.Cause), })), - Effect.tap(v => Ref.set(self.submitStateRef, v)) + Effect.tap(v => Ref.set(self.submitStateRef, v)), )), ), -- 2.49.1 From 8d55a67e755252b57a68dc8876409210b293c3bf Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 16 Oct 2025 21:34:20 +0200 Subject: [PATCH 004/181] Update dependency @effect/language-service to ^0.45.0 (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [@effect/language-service](https://github.com/Effect-TS/language-service) | [`^0.44.0` -> `^0.45.0`](https://renovatebot.com/diffs/npm/@effect%2flanguage-service/0.44.1/0.45.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@effect%2flanguage-service/0.45.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@effect%2flanguage-service/0.44.1/0.45.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
Effect-TS/language-service (@​effect/language-service) ### [`v0.45.1`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0451) [Compare Source](https://github.com/Effect-TS/language-service/compare/v0.45.0...v0.45.1) ##### Patch Changes - [#​423](https://github.com/Effect-TS/language-service/pull/423) [`70d8734`](https://github.com/Effect-TS/language-service/commit/70d8734558c4ba3abfd69fafce785b7f58a70a52) Thanks [@​mattiamanzati](https://github.com/mattiamanzati)! - Add code fix to rewrite Schema class constructor overrides as static 'new' methods When detecting constructor overrides in Schema classes, the diagnostic now provides a new code fix option that automatically rewrites the constructor as a static 'new' method. This preserves the custom initialization logic while maintaining Schema's decoding behavior. Example: ```typescript // Before (with constructor override) class MyClass extends Schema.Class("MyClass")({ a: Schema.Number }) { b: number; constructor() { super({ a: 42 }); this.b = 56; } } // After (using static 'new' method) class MyClass extends Schema.Class("MyClass")({ a: Schema.Number }) { b: number; public static new() { const _this = new this({ a: 42 }); _this.b = 56; return _this; } } ``` - [#​421](https://github.com/Effect-TS/language-service/pull/421) [`8c455ed`](https://github.com/Effect-TS/language-service/commit/8c455ed7a459665d26c30f1e5d90338e48794815) Thanks [@​mattiamanzati](https://github.com/mattiamanzati)! - Update dependencies to their latest versions including Effect 3.18.4, TypeScript 5.9.3, and various ESLint and build tooling packages ### [`v0.45.0`](https://github.com/Effect-TS/language-service/blob/HEAD/CHANGELOG.md#0450) [Compare Source](https://github.com/Effect-TS/language-service/compare/v0.44.1...v0.45.0) ##### Minor Changes - [#​419](https://github.com/Effect-TS/language-service/pull/419) [`7cd7216`](https://github.com/Effect-TS/language-service/commit/7cd7216abc8e3057098acf1889c7494d17a869d6) Thanks [@​mattiamanzati](https://github.com/mattiamanzati)! - Add support for custom APIs in deterministicKeys diagnostic using the `@effect-identifier` JSDoc tag. You can now enforce deterministic keys in custom APIs that follow an `extends MyApi("identifier")` pattern by: - Adding `extendedKeyDetection: true` to plugin options to enable detection - Marking the identifier parameter with `/** @​effect-identifier */` JSDoc tag Example: ```ts export function Repository(/** @​effect-identifier */ identifier: string) { return Context.Tag("Repository/" + identifier); } export class UserRepo extends Repository("user-repo")< UserRepo, { /** ... */ } >() {} ```
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). Reviewed-on: https://git.valverde.cloud/Thilawyn/effect-fc/pulls/14 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 61aad1c..6079b3a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "@effect-fc/monorepo", "devDependencies": { "@biomejs/biome": "^2.2.5", - "@effect/language-service": "^0.44.0", + "@effect/language-service": "^0.45.0", "@types/bun": "^1.2.23", "npm-check-updates": "^19.0.0", "npm-sort": "^0.0.4", @@ -135,7 +135,7 @@ "@effect-fc/example": ["@effect-fc/example@workspace:packages/example"], - "@effect/language-service": ["@effect/language-service@0.44.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-ya9cDN0CvmZl2jAhGOBEvp15e+t/9LDdwkH1+vO8wnz/5BdsAZLVo3O3SYO5rtHQiL3rJBwPkn3Z7X6pZ8E8ww=="], + "@effect/language-service": ["@effect/language-service@0.45.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-SEZ9TaVCpRKYumTQJPApg3os9O94bN2lCYQLgZbyK/xD+NSfYPPJZQ+6T5LkpcNgW8BRk1ACI7S1W2/noxm7Qg=="], "@effect/platform": ["@effect/platform@0.92.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.18.1" } }, "sha512-XXWCBVwyhaKZISN7aM1fv/3fWDGyxr84ObywnUrL8aHvJLoIeskWFAP/fqw3c5MFCrJ3ZV97RWLbv6JiBQugdg=="], diff --git a/package.json b/package.json index 34c8e42..76911e6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.2.5", - "@effect/language-service": "^0.44.0", + "@effect/language-service": "^0.45.0", "@types/bun": "^1.2.23", "npm-check-updates": "^19.0.0", "npm-sort": "^0.0.4", -- 2.49.1 From 6bdf2a4d87242615b65171ca8f94437e05d4d574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 19 Oct 2025 02:51:43 +0200 Subject: [PATCH 005/181] submit -> submitFn --- packages/effect-fc/src/Form.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 8d032ba..11b2a62 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -16,7 +16,7 @@ extends Pipeable.Pipeable { readonly [FormTypeId]: FormTypeId readonly schema: Schema.Schema - readonly submit: (value: NoInfer
) => Effect.Effect + readonly submitFn: (value: NoInfer) => Effect.Effect readonly debounce: Option.Option readonly valueRef: SubscriptionRef.SubscriptionRef> @@ -34,7 +34,7 @@ extends Pipeable.Class() implements Form { constructor( readonly schema: Schema.Schema, - readonly submit: (value: NoInfer) => Effect.Effect, + readonly submitFn: (value: NoInfer) => Effect.Effect, readonly debounce: Option.Option, readonly valueRef: SubscriptionRef.SubscriptionRef>, @@ -55,7 +55,7 @@ export namespace make { export interface Options { readonly schema: Schema.Schema readonly initialEncodedValue: NoInfer - readonly submit: (value: NoInfer) => Effect.Effect, + readonly submitFn: (value: NoInfer) => Effect.Effect, readonly debounce?: Duration.DurationInput, } } @@ -74,7 +74,7 @@ export const make: { return new FormImpl( options.schema, - options.submit, + options.submitFn, Option.fromNullable(options.debounce), valueRef, @@ -156,7 +156,7 @@ export const submit = ( Effect.andThen(identity), Effect.tap(Ref.set(self.submitStateRef, AsyncData.loading())), Effect.andThen(flow( - self.submit as (value: NoInfer) => Effect.Effect, + self.submitFn as (value: NoInfer) => Effect.Effect, Effect.tapErrorTag("ParseError", e => Ref.set(self.errorRef, Option.some(e as ParseResult.ParseError))), Effect.exit, Effect.map(Exit.match({ -- 2.49.1 From 1af839f036f9604b9a6b83719451e40b7d359061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 19 Oct 2025 07:16:39 +0200 Subject: [PATCH 006/181] Fix example --- packages/example/src/routes/form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index c00ff11..0e391dd 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -39,7 +39,7 @@ class RegisterForm extends Effect.Service()("RegisterForm", { ), initialEncodedValue: { email: "", password: "", birth: Option.none() }, - submit: v => Effect.sleep("500 millis").pipe( + submitFn: v => Effect.sleep("500 millis").pipe( Effect.andThen(Console.log(v)), Effect.andThen(Effect.sync(() => alert("Done!"))), ), -- 2.49.1 From 336ea67ea28484711d6a114705a11ba008b267b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 19 Oct 2025 07:46:36 +0200 Subject: [PATCH 007/181] Add Subscribable.flatMapSubscriptionRef --- packages/effect-fc/src/Form.ts | 20 +++++++------------- packages/effect-fc/src/Subscribable.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 11b2a62..9aeefc7 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -199,19 +199,13 @@ export const field = Effect.andThen( - ParseResult.ArrayFormatter.formatError(v), - Array.filter(issue => PropertyPath.equivalence(issue.path, path)), - ), - onNone: () => Effect.succeed([]), - }), - filter => SubscribableInternal.make({ - get: Effect.flatMap(self.errorRef.get, filter), - get changes() { return Stream.flatMap(self.errorRef.changes, filter) }, - }), - ), + SubscribableInternal.flatMapSubscriptionRef(self.errorRef, Option.match({ + onSome: flow( + ParseResult.ArrayFormatter.formatError, + Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))), + ), + onNone: () => Effect.succeed([]), + })), pipe( Option.isSome, diff --git a/packages/effect-fc/src/Subscribable.ts b/packages/effect-fc/src/Subscribable.ts index 3325fdc..416c6ea 100644 --- a/packages/effect-fc/src/Subscribable.ts +++ b/packages/effect-fc/src/Subscribable.ts @@ -1,4 +1,4 @@ -import { type Effect, Effectable, Readable, type Stream, Subscribable } from "effect" +import { Effect, Effectable, Readable, Stream, Subscribable, type SubscriptionRef } from "effect" class SubscribableImpl @@ -22,3 +22,11 @@ export const make = (values: { readonly get: Effect.Effect readonly changes: Stream.Stream }): Subscribable.Subscribable => new SubscribableImpl(values.get, values.changes) + +export const flatMapSubscriptionRef = ( + ref: SubscriptionRef.SubscriptionRef, + flatMap: (value: NoInfer) => Effect.Effect, +): Subscribable.Subscribable => make({ + get: Effect.flatMap(ref, flatMap), + get changes() { return Stream.flatMap(ref.changes, flatMap) }, +}) -- 2.49.1 From 90db94e9059d61cea7e344c84d7b71767e04efae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 20 Oct 2025 04:35:11 +0200 Subject: [PATCH 008/181] Refactor Subscribable --- packages/effect-fc/src/Form.ts | 70 +++++++------------------- packages/effect-fc/src/Subscribable.ts | 43 ++++++---------- 2 files changed, 31 insertions(+), 82 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 9aeefc7..2d00fb5 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,10 +1,10 @@ import * as AsyncData from "@typed/async-data" -import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, pipe, Ref, Schema, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect" +import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream, SubscriptionRef } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as React from "react" import * as Hooks from "./Hooks/index.js" import * as PropertyPath from "./PropertyPath.js" -import * as SubscribableInternal from "./Subscribable.js" +import * as Subscribable from "./Subscribable.js" import * as SubscriptionSubRef from "./SubscriptionSubRef.js" @@ -83,28 +83,14 @@ export const make: { validationFiberRef, submitStateRef, - pipe( - ([value, error, validationFiber, submitState]: readonly [ - Option.Option, - Option.Option, - Option.Option>, - AsyncData.AsyncData, - ]) => Option.isSome(value) && Option.isNone(error) && Option.isNone(validationFiber) && !AsyncData.isLoading(submitState), - - filter => SubscribableInternal.make({ - get: Effect.map(Effect.all([valueRef, errorRef, validationFiberRef, submitStateRef]), filter), - get changes() { - return Stream.map( - Stream.zipLatestAll( - valueRef.changes, - errorRef.changes, - validationFiberRef.changes, - submitStateRef.changes, - ), - filter, - ) - }, - }), + Subscribable.map( + Subscribable.zipLatestAll(valueRef, errorRef, validationFiberRef, submitStateRef), + ([value, error, validationFiber, submitState]) => ( + Option.isSome(value) && + Option.isNone(error) && + Option.isNone(validationFiber) && + !AsyncData.isLoading(submitState) + ), ), ) }) @@ -186,42 +172,20 @@ export const field = , path: P, ): FormField, PropertyPath.ValueFromPath> => new FormFieldImpl( - pipe( - Option.match({ - onSome: (v: A) => Option.map(PropertyPath.get(v, path), Option.some), - onNone: () => Option.some(Option.none()), - }), - filter => SubscribableInternal.make({ - get: Effect.flatMap(self.valueRef, filter), - get changes() { return Stream.flatMap(self.valueRef.changes, filter) }, - }), - ), - + Subscribable.mapEffect(self.valueRef, Option.match({ + onSome: v => Option.map(PropertyPath.get(v, path), Option.some), + onNone: () => Option.some(Option.none()), + })), SubscriptionSubRef.makeFromPath(self.encodedValueRef, path), - - SubscribableInternal.flatMapSubscriptionRef(self.errorRef, Option.match({ + Subscribable.mapEffect(self.errorRef, Option.match({ onSome: flow( ParseResult.ArrayFormatter.formatError, Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))), ), onNone: () => Effect.succeed([]), })), - - pipe( - Option.isSome, - filter => SubscribableInternal.make({ - get: Effect.map(self.validationFiberRef.get, filter), - get changes() { return Stream.map(self.validationFiberRef.changes, filter) }, - }), - ), - - pipe( - AsyncData.isLoading, - filter => SubscribableInternal.make({ - get: Effect.map(self.submitStateRef, filter), - get changes() { return Stream.map(self.submitStateRef.changes, filter) }, - }), - ), + Subscribable.map(self.validationFiberRef, Option.isSome), + Subscribable.map(self.submitStateRef, AsyncData.isLoading) ) diff --git a/packages/effect-fc/src/Subscribable.ts b/packages/effect-fc/src/Subscribable.ts index 416c6ea..9ebba36 100644 --- a/packages/effect-fc/src/Subscribable.ts +++ b/packages/effect-fc/src/Subscribable.ts @@ -1,32 +1,17 @@ -import { Effect, Effectable, Readable, Stream, Subscribable, type SubscriptionRef } from "effect" +import { Effect, Stream, Subscribable } from "effect" -class SubscribableImpl -extends Effectable.Class implements Subscribable.Subscribable { - readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId - readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId +export const zipLatestAll = >>( + ...subscribables: T +): Subscribable.Subscribable< + [T[number]] extends [never] + ? never + : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never }, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? _E : never, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? _R : never +> => Subscribable.make({ + get: Effect.all(subscribables.map(v => v.get)), + changes: Stream.zipLatestAll(...subscribables.map(v => v.changes)), +}) as any - constructor( - readonly get: Effect.Effect, - readonly changes: Stream.Stream, - ) { - super() - } - - commit() { - return this.get - } -} - -export const make = (values: { - readonly get: Effect.Effect - readonly changes: Stream.Stream -}): Subscribable.Subscribable => new SubscribableImpl(values.get, values.changes) - -export const flatMapSubscriptionRef = ( - ref: SubscriptionRef.SubscriptionRef, - flatMap: (value: NoInfer) => Effect.Effect, -): Subscribable.Subscribable => make({ - get: Effect.flatMap(ref, flatMap), - get changes() { return Stream.flatMap(ref.changes, flatMap) }, -}) +export * from "effect/Subscribable" -- 2.49.1 From cf4ba5805f1bb1ba200d50158b078af067c1c036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 20 Oct 2025 05:36:45 +0200 Subject: [PATCH 009/181] Refactor Form --- packages/effect-fc/src/Component.ts | 2 +- packages/effect-fc/src/Form.ts | 10 +++++----- packages/example/src/routes/form.tsx | 26 ++++++++++++++------------ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 297cd38..dd1524f 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -55,7 +55,7 @@ const ComponentProto = Object.freeze({ this: Component ) { const self = this - // biome-ignore lint/style/noNonNullAssertion: context initialization + // biome-ignore lint/style/noNonNullAssertion: React ref initialization const runtimeRef = React.useRef>>(null!) runtimeRef.current = yield* Effect.runtime>() diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 2d00fb5..e829a11 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -16,7 +16,7 @@ extends Pipeable.Pipeable { readonly [FormTypeId]: FormTypeId readonly schema: Schema.Schema - readonly submitFn: (value: NoInfer) => Effect.Effect + readonly onSubmit: (value: NoInfer) => Effect.Effect readonly debounce: Option.Option readonly valueRef: SubscriptionRef.SubscriptionRef> @@ -34,7 +34,7 @@ extends Pipeable.Class() implements Form { constructor( readonly schema: Schema.Schema, - readonly submitFn: (value: NoInfer) => Effect.Effect, + readonly onSubmit: (value: NoInfer) => Effect.Effect, readonly debounce: Option.Option, readonly valueRef: SubscriptionRef.SubscriptionRef>, @@ -55,7 +55,7 @@ export namespace make { export interface Options { readonly schema: Schema.Schema readonly initialEncodedValue: NoInfer - readonly submitFn: (value: NoInfer) => Effect.Effect, + readonly onSubmit: (value: NoInfer) => Effect.Effect, readonly debounce?: Duration.DurationInput, } } @@ -74,7 +74,7 @@ export const make: { return new FormImpl( options.schema, - options.submitFn, + options.onSubmit, Option.fromNullable(options.debounce), valueRef, @@ -142,7 +142,7 @@ export const submit = ( Effect.andThen(identity), Effect.tap(Ref.set(self.submitStateRef, AsyncData.loading())), Effect.andThen(flow( - self.submitFn as (value: NoInfer) => Effect.Effect, + self.onSubmit as (value: NoInfer) => Effect.Effect, Effect.tapErrorTag("ParseError", e => Ref.set(self.errorRef, Option.some(e as ParseResult.ParseError))), Effect.exit, Effect.map(Exit.match({ diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index 0e391dd..722e2d8 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -39,7 +39,7 @@ class RegisterForm extends Effect.Service()("RegisterForm", { ), initialEncodedValue: { email: "", password: "", birth: Option.none() }, - submitFn: v => Effect.sleep("500 millis").pipe( + onSubmit: v => Effect.sleep("500 millis").pipe( Effect.andThen(Console.log(v)), Effect.andThen(Effect.sync(() => alert("Done!"))), ), @@ -47,7 +47,7 @@ class RegisterForm extends Effect.Service()("RegisterForm", { }) }) {} -class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() { +class RegisterFormView extends Component.makeUntraced("RegisterFormView")(function*() { const form = yield* RegisterForm const submit = yield* Form.useSubmit(form) const [canSubmit] = yield* Hooks.useSubscribables(form.canSubmitSubscribable) @@ -84,16 +84,18 @@ class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() { ) }) {} +const RegisterPage = Component.makeUntraced("RegisterPage")(function*() { + const RegisterFormViewFC = yield* Effect.provide( + RegisterFormView, + yield* Hooks.useContext(RegisterForm.Default, { finalizerExecutionMode: "fork" }), + ) + + return +}).pipe( + Component.withRuntime(runtime.context) +) + export const Route = createFileRoute("/form")({ - component: Component.makeUntraced("RegisterRoute")(function*() { - const RegisterRouteFC = yield* Effect.provide( - RegisterPage, - yield* Hooks.useContext(RegisterForm.Default, { finalizerExecutionMode: "fork" }), - ) - - return - }).pipe( - Component.withRuntime(runtime.context) - ) + component: RegisterPage }) -- 2.49.1 From 64583601dc39d943344dd2f7f305f011e186b71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 20 Oct 2025 05:55:58 +0200 Subject: [PATCH 010/181] Fix --- packages/example/src/lib/form/TextFieldFormInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/example/src/lib/form/TextFieldFormInput.tsx b/packages/example/src/lib/form/TextFieldFormInput.tsx index 85db7fa..e0973fb 100644 --- a/packages/example/src/lib/form/TextFieldFormInput.tsx +++ b/packages/example/src/lib/form/TextFieldFormInput.tsx @@ -1,5 +1,5 @@ import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" -import { Array, Option } from "effect" +import { Array, Option, Struct } from "effect" import { Component, Form, Hooks } from "effect-fc" @@ -41,7 +41,7 @@ export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInp value={input.value} onChange={e => input.setValue(e.target.value)} disabled={(input.optional && !input.enabled) || isSubmitting} - {...props} + {...Struct.omit(props, "optional", "defaultValue")} > {input.optional && -- 2.49.1 From 15f6d695f8caecb38b9a0e582659e90f3e28af15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 20 Oct 2025 05:57:00 +0200 Subject: [PATCH 011/181] Fix --- packages/example/src/lib/form/TextFieldFormInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/example/src/lib/form/TextFieldFormInput.tsx b/packages/example/src/lib/form/TextFieldFormInput.tsx index e0973fb..567afdb 100644 --- a/packages/example/src/lib/form/TextFieldFormInput.tsx +++ b/packages/example/src/lib/form/TextFieldFormInput.tsx @@ -10,7 +10,7 @@ extends TextField.RootProps, Form.useInput.Options { } interface OptionalProps -extends Omit, Form.useOptionalInput.Options { +extends Omit, Form.useOptionalInput.Options { readonly optional: true readonly field: Form.FormField> } -- 2.49.1 From 003d2f19a20dfc710b69d0a439acc750fa574427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 20 Oct 2025 06:33:06 +0200 Subject: [PATCH 012/181] Version bump --- packages/effect-fc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect-fc/package.json b/packages/effect-fc/package.json index e5f9d33..d87a362 100644 --- a/packages/effect-fc/package.json +++ b/packages/effect-fc/package.json @@ -1,7 +1,7 @@ { "name": "effect-fc", "description": "Write React function components with Effect", - "version": "0.1.4", + "version": "0.1.5", "type": "module", "files": [ "./README.md", -- 2.49.1 From adc8835304bb7333120a24ade95c5e3b57644946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 20 Oct 2025 07:09:49 +0200 Subject: [PATCH 013/181] Add useOnMount --- packages/effect-fc/src/Component.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index dd1524f..c216237 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -407,3 +407,15 @@ export const withRuntime: { props, ) }) + +export const useOnMount: { + ( + f: () => Effect.Effect + ): Effect.Effect +} = Effect.fnUntraced(function* ( + f: () => Effect.Effect +) { + const runtime = yield* Effect.runtime() + // biome-ignore lint/correctness/useExhaustiveDependencies: only computed on mount + return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(f())), []) +}) -- 2.49.1 From 1f14e8be6bdbd03e88354c9ccca4f78633dc5655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 20 Oct 2025 21:24:27 +0200 Subject: [PATCH 014/181] Add useOnChange --- packages/effect-fc/src/Component.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index c216237..9f0e1e5 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -415,7 +415,21 @@ export const useOnMount: { } = Effect.fnUntraced(function* ( f: () => Effect.Effect ) { - const runtime = yield* Effect.runtime() + const runtime = yield* Effect.runtime() // biome-ignore lint/correctness/useExhaustiveDependencies: only computed on mount return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(f())), []) }) + +export const useOnChange: { + ( + f: () => Effect.Effect, + deps: React.DependencyList, + ): Effect.Effect +} = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps: React.DependencyList, +) { + const runtime = yield* Effect.runtime() + // biome-ignore lint/correctness/useExhaustiveDependencies: "f" is non-reactive + return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(f())), deps) +}) -- 2.49.1 From 3695128923fdeb110cfe9a6e2ec6c0b6ccd8fb91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 21 Oct 2025 06:16:54 +0200 Subject: [PATCH 015/181] Refactor Hooks --- packages/effect-fc/src/Component.ts | 163 +++++++++++++++++- packages/effect-fc/src/Form.ts | 141 +++++++-------- packages/effect-fc/src/Subscribable.ts | 44 ++++- packages/effect-fc/src/SubscriptionRef.ts | 43 +++++ packages/effect-fc/src/index.ts | 1 + packages/example/src/domain/Todo.ts | 2 +- .../example/src/lib/input/TextAreaInput.tsx | 41 ----- .../example/src/lib/input/TextFieldInput.tsx | 69 -------- packages/example/src/routes/dev/input.tsx | 41 ----- packages/example/src/todo/Todo.tsx | 26 ++- packages/example/src/todo/Todos.tsx | 6 +- 11 files changed, 324 insertions(+), 253 deletions(-) create mode 100644 packages/effect-fc/src/SubscriptionRef.ts delete mode 100644 packages/example/src/lib/input/TextAreaInput.tsx delete mode 100644 packages/example/src/lib/input/TextFieldInput.tsx delete mode 100644 packages/example/src/routes/dev/input.tsx diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 9f0e1e5..4bb01f4 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -1,8 +1,7 @@ /** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ -import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, Tracer, type Types, type Utils } from "effect" +import { Context, Effect, Effectable, ExecutionStrategy, Exit, Function, Layer, ManagedRuntime, Predicate, Ref, Runtime, Scope, Tracer, type Types, type Utils } from "effect" import * as React from "react" -import * as Hooks from "./Hooks/index.js" import { Memoized } from "./index.js" @@ -46,6 +45,11 @@ export namespace Component { } } +export interface ScopeOptions { + readonly finalizerExecutionMode?: "sync" | "fork" + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy +} + const ComponentProto = Object.freeze({ ...Effectable.CommitPrototype, @@ -60,7 +64,7 @@ const ComponentProto = Object.freeze({ runtimeRef.current = yield* Effect.runtime>() return React.useRef(function ScopeProvider(props: P) { - const scope = Runtime.runSync(runtimeRef.current)(Hooks.useScope( + const scope = Runtime.runSync(runtimeRef.current)(useScope( Array.from( Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values() ), @@ -408,6 +412,55 @@ export const withRuntime: { ) }) + +export const useScope: { + ( + deps: React.DependencyList, + options?: ScopeOptions, + ): Effect.Effect +} = Effect.fnUntraced(function*(deps, options) { + const runtime = yield* Effect.runtime() + + // biome-ignore lint/correctness/useExhaustiveDependencies: no reactivity needed + const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(Effect.all([ + Ref.make(true), + Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential), + ])), []) + const [scope, setScope] = React.useState(initialScope) + + React.useEffect(() => Runtime.runSync(runtime)( + Effect.if(isInitialRun, { + onTrue: () => Effect.as( + Ref.set(isInitialRun, false), + () => closeScope(scope, runtime, options), + ), + + onFalse: () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential).pipe( + Effect.tap(scope => Effect.sync(() => setScope(scope))), + Effect.map(scope => () => closeScope(scope, runtime, options)), + ), + }) + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + ), deps) + + return scope +}) + +const closeScope = ( + scope: Scope.CloseableScope, + runtime: Runtime.Runtime, + options?: ScopeOptions, +) => { + switch (options?.finalizerExecutionMode ?? "sync") { + case "sync": + Runtime.runSync(runtime)(Scope.close(scope, Exit.void)) + break + case "fork": + Runtime.runFork(runtime)(Scope.close(scope, Exit.void)) + break + } +} + export const useOnMount: { ( f: () => Effect.Effect @@ -430,6 +483,108 @@ export const useOnChange: { deps: React.DependencyList, ) { const runtime = yield* Effect.runtime() - // biome-ignore lint/correctness/useExhaustiveDependencies: "f" is non-reactive + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(f())), deps) }) + +export const useReactEffect: { + ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: ScopeOptions, + ): Effect.Effect> +} = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: ScopeOptions, +) { + const runtime = yield* Effect.runtime>() + + React.useEffect(() => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)), + Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(f(), Scope.Scope, scope))), + Effect.map(({ scope }) => + () => closeScope(scope, runtime, options) + ), + Runtime.runSync(runtime), + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + ), deps) +}) + +export const useReactLayoutEffect: { + ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: ScopeOptions, + ): Effect.Effect> +} = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: ScopeOptions, +) { + const runtime = yield* Effect.runtime>() + + React.useLayoutEffect(() => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)), + Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(f(), Scope.Scope, scope))), + Effect.map(({ scope }) => + () => closeScope(scope, runtime, options) + ), + Runtime.runSync(runtime), + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + ), deps) +}) + +export const useCallbackSync: { + ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, + ): Effect.Effect<(...args: Args) => A, never, R> +} = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +) { + // biome-ignore lint/style/noNonNullAssertion: context initialization + const runtimeRef = React.useRef>(null!) + runtimeRef.current = yield* Effect.runtime() + + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(f(...args)), deps) +}) + +export const useCallbackPromise: { + ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, + ): Effect.Effect<(...args: Args) => Promise, never, R> +} = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +) { + // biome-ignore lint/style/noNonNullAssertion: context initialization + const runtimeRef = React.useRef>(null!) + runtimeRef.current = yield* Effect.runtime() + + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + return React.useCallback((...args: Args) => Runtime.runPromise(runtimeRef.current)(f(...args)), deps) +}) + +export const useContext: { + ( + layer: Layer.Layer, + options?: ScopeOptions, + ): Effect.Effect, E, RIn> +} = Effect.fnUntraced(function* ( + layer: Layer.Layer, + options?: ScopeOptions, +) { + const scope = yield* useScope([layer], options) + + return yield* useOnChange(() => Effect.context().pipe( + Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))), + Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)), + Effect.andThen(runtime => runtime.runtimeEffect), + Effect.andThen(runtime => runtime.context), + Effect.provideService(Scope.Scope, scope), + ), [scope]) +}) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index e829a11..23731cd 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,10 +1,11 @@ import * as AsyncData from "@typed/async-data" -import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream, SubscriptionRef } from "effect" +import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as React from "react" -import * as Hooks from "./Hooks/index.js" +import * as Component from "./Component.js" import * as PropertyPath from "./PropertyPath.js" import * as Subscribable from "./Subscribable.js" +import * as SubscriptionRef from "./SubscriptionRef.js" import * as SubscriptionSubRef from "./SubscriptionSubRef.js" @@ -163,7 +164,7 @@ export namespace service { export const service = ( options: service.Options -): Effect.Effect, never, R | Scope.Scope> => Effect.tap( +): Effect.Effect, never, Scope.Scope | R> => Effect.tap( make(options), form => Effect.forkScoped(run(form)), ) @@ -220,24 +221,6 @@ extends Pipeable.Class() implements FormField { export const isFormField = (u: unknown): u is FormField => Predicate.hasProperty(u, FormFieldTypeId) -export namespace useForm { - export interface Options - extends make.Options {} -} - -export const useForm: { - ( - options: make.Options, - deps: React.DependencyList, - ): Effect.Effect, never, R> -} = Effect.fnUntraced(function* ( - options: make.Options, - deps: React.DependencyList, -) { - const form = yield* Hooks.useMemo(() => make(options), [options.debounce, ...deps]) - yield* Hooks.useFork(() => run(form), [form]) - return form -}) export const useSubmit = ( self: Form @@ -245,7 +228,7 @@ export const useSubmit = ( () => Promise>>, never, SR -> => Hooks.useCallbackPromise(() => submit(self), [self]) +> => Component.useCallbackPromise(() => submit(self), [self]) export const useField = >>( self: Form, @@ -271,33 +254,34 @@ export const useInput: { ( field: FormField, options?: useInput.Options, - ): Effect.Effect, NoSuchElementException> + ): Effect.Effect, NoSuchElementException, Scope.Scope> } = Effect.fnUntraced(function* ( field: FormField, options?: useInput.Options, ) { - const internalValueRef = yield* Hooks.useMemo(() => Effect.andThen(field.encodedValueRef, SubscriptionRef.make), [field]) - const [value, setValue] = yield* Hooks.useRefState(internalValueRef) - - yield* Hooks.useFork(() => Effect.all([ - Stream.runForEach( - Stream.drop(field.encodedValueRef, 1), - upstreamEncodedValue => Effect.whenEffect( - Ref.set(internalValueRef, upstreamEncodedValue), - Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), + const internalValueRef = yield* Component.useOnChange(() => Effect.tap( + Effect.andThen(field.encodedValueRef, SubscriptionRef.make), + internalValueRef => Effect.forkScoped(Effect.all([ + Stream.runForEach( + Stream.drop(field.encodedValueRef, 1), + upstreamEncodedValue => Effect.whenEffect( + Ref.set(internalValueRef, upstreamEncodedValue), + Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), + ), ), - ), - Stream.runForEach( - internalValueRef.changes.pipe( - Stream.drop(1), - Stream.changesWith(Equal.equivalence()), - options?.debounce ? Stream.debounce(options.debounce) : identity, + Stream.runForEach( + internalValueRef.changes.pipe( + Stream.drop(1), + Stream.changesWith(Equal.equivalence()), + options?.debounce ? Stream.debounce(options.debounce) : identity, + ), + internalValue => Ref.set(field.encodedValueRef, internalValue), ), - internalValue => Ref.set(field.encodedValueRef, internalValue), - ), - ], { concurrency: "unbounded" }), [field, internalValueRef, options?.debounce]) + ], { concurrency: "unbounded" })), + ), [field, options?.debounce]) + const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) return { value, setValue } }) @@ -316,55 +300,56 @@ export const useOptionalInput: { ( field: FormField>, options: useOptionalInput.Options, - ): Effect.Effect, NoSuchElementException> + ): Effect.Effect, NoSuchElementException, Scope.Scope> } = Effect.fnUntraced(function* ( field: FormField>, options: useOptionalInput.Options, ) { - const [enabledRef, internalValueRef] = yield* Hooks.useMemo(() => Effect.andThen( - field.encodedValueRef, - Option.match({ - onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]), - onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]), - }), - ), [field]) + const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap( + Effect.andThen( + field.encodedValueRef, + Option.match({ + onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]), + onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]), + }), + ), - const [enabled, setEnabled] = yield* Hooks.useRefState(enabledRef) - const [value, setValue] = yield* Hooks.useRefState(internalValueRef) + ([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([ + Stream.runForEach( + Stream.drop(field.encodedValueRef, 1), - yield* Hooks.useFork(() => Effect.all([ - Stream.runForEach( - Stream.drop(field.encodedValueRef, 1), + upstreamEncodedValue => Effect.whenEffect( + Option.match(upstreamEncodedValue, { + onSome: v => Effect.andThen( + Ref.set(enabledRef, true), + Ref.set(internalValueRef, v), + ), + onNone: () => Effect.andThen( + Ref.set(enabledRef, false), + Ref.set(internalValueRef, options.defaultValue), + ), + }), - upstreamEncodedValue => Effect.whenEffect( - Option.match(upstreamEncodedValue, { - onSome: v => Effect.andThen( - Ref.set(enabledRef, true), - Ref.set(internalValueRef, v), + Effect.andThen( + Effect.all([enabledRef, internalValueRef]), + ([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()), ), - onNone: () => Effect.andThen( - Ref.set(enabledRef, false), - Ref.set(internalValueRef, options.defaultValue), - ), - }), - - Effect.andThen( - Effect.all([enabledRef, internalValueRef]), - ([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()), ), ), - ), - Stream.runForEach( - enabledRef.changes.pipe( - Stream.zipLatest(internalValueRef.changes), - Stream.drop(1), - Stream.changesWith(Equal.equivalence()), - options?.debounce ? Stream.debounce(options.debounce) : identity, + Stream.runForEach( + enabledRef.changes.pipe( + Stream.zipLatest(internalValueRef.changes), + Stream.drop(1), + Stream.changesWith(Equal.equivalence()), + options?.debounce ? Stream.debounce(options.debounce) : identity, + ), + ([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()), ), - ([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()), - ), - ], { concurrency: "unbounded" }), [field, enabledRef, internalValueRef, options.debounce]) + ], { concurrency: "unbounded" })), + ), [field, options.debounce]) + const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef) + const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) return { enabled, setEnabled, value, setValue } }) diff --git a/packages/effect-fc/src/Subscribable.ts b/packages/effect-fc/src/Subscribable.ts index 9ebba36..e1d2d7f 100644 --- a/packages/effect-fc/src/Subscribable.ts +++ b/packages/effect-fc/src/Subscribable.ts @@ -1,17 +1,47 @@ -import { Effect, Stream, Subscribable } from "effect" +import { Effect, Equivalence, pipe, type Scope, Stream, Subscribable } from "effect" +import * as React from "react" +import * as Component from "./Component.js" -export const zipLatestAll = >>( - ...subscribables: T +export const zipLatestAll = []>( + ...elements: T ): Subscribable.Subscribable< [T[number]] extends [never] ? never : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never }, - [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? _E : never, - [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? _R : never + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? E : never, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? R : never > => Subscribable.make({ - get: Effect.all(subscribables.map(v => v.get)), - changes: Stream.zipLatestAll(...subscribables.map(v => v.changes)), + get: Effect.all(elements.map(v => v.get)), + changes: Stream.zipLatestAll(...elements.map(v => v.changes)), }) as any +export const useSubscribables: { + []>( + ...elements: T + ): Effect.Effect< + [T[number]] extends [never] + ? never + : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never }, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? E : never, + ([T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? R : never) | Scope.Scope + > +} = Effect.fnUntraced(function* []>( + ...elements: T +) { + const [reactStateValue, setReactStateValue] = React.useState( + yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get))) + ) + + yield* Component.useOnChange(() => Effect.forkScoped(pipe( + elements.map(ref => Stream.changesWith(ref.changes, Equivalence.strict())), + streams => Stream.zipLatestAll(...streams), + Stream.runForEach(v => + Effect.sync(() => setReactStateValue(v)) + ), + )), elements) + + return reactStateValue as any +}) + export * from "effect/Subscribable" diff --git a/packages/effect-fc/src/SubscriptionRef.ts b/packages/effect-fc/src/SubscriptionRef.ts new file mode 100644 index 0000000..c1afcc2 --- /dev/null +++ b/packages/effect-fc/src/SubscriptionRef.ts @@ -0,0 +1,43 @@ +import { Effect, Equivalence, type Scope, Stream, SubscriptionRef } from "effect" +import * as React from "react" +import * as Component from "./Component.js" +import * as SetStateAction from "./SetStateAction.js" + + +export const useSubscriptionRefState: { + ( + ref: SubscriptionRef.SubscriptionRef + ): Effect.Effect>], never, Scope.Scope> +} = Effect.fnUntraced(function* (ref: SubscriptionRef.SubscriptionRef) { + const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref)) + + yield* Component.useOnChange(() => Effect.forkScoped(Stream.runForEach( + Stream.changesWith(ref.changes, Equivalence.strict()), + v => Effect.sync(() => setReactStateValue(v)), + )), [ref]) + + const setValue = yield* Component.useCallbackSync((setStateAction: React.SetStateAction) => + Effect.andThen( + SubscriptionRef.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)), + v => setReactStateValue(v), + ), + [ref]) + + return [reactStateValue, setValue] +}) + +export const useSubscriptionRefFromState: { + (state: readonly [A, React.Dispatch>]): Effect.Effect, never, Scope.Scope> +} = Effect.fnUntraced(function*([value, setValue]) { + const ref = yield* Component.useOnMount(() => SubscriptionRef.make(value)) + + yield* Component.useOnChange(() => Effect.forkScoped(Stream.runForEach( + Stream.changesWith(ref.changes, Equivalence.strict()), + v => Effect.sync(() => setValue(v)), + )), [setValue]) + yield* Component.useReactEffect(() => SubscriptionRef.set(ref, value), [value]) + + return ref +}) + +export * from "effect/SubscriptionRef" diff --git a/packages/effect-fc/src/index.ts b/packages/effect-fc/src/index.ts index b2227e8..abdca26 100644 --- a/packages/effect-fc/src/index.ts +++ b/packages/effect-fc/src/index.ts @@ -7,4 +7,5 @@ export * as PropertyPath from "./PropertyPath.js" export * as ReactRuntime from "./ReactRuntime.js" export * as SetStateAction from "./SetStateAction.js" export * as Subscribable from "./Subscribable.js" +export * as SubscriptionRef from "./SubscriptionRef.js" export * as SubscriptionSubRef from "./SubscriptionSubRef.js" diff --git a/packages/example/src/domain/Todo.ts b/packages/example/src/domain/Todo.ts index f4fe24f..500faab 100644 --- a/packages/example/src/domain/Todo.ts +++ b/packages/example/src/domain/Todo.ts @@ -1,5 +1,5 @@ -import { assertEncodedJsonifiable } from "@/lib/schema" import { Schema } from "effect" +import { assertEncodedJsonifiable } from "@/lib/schema" export class Todo extends Schema.Class("Todo")({ diff --git a/packages/example/src/lib/input/TextAreaInput.tsx b/packages/example/src/lib/input/TextAreaInput.tsx deleted file mode 100644 index 03d4359..0000000 --- a/packages/example/src/lib/input/TextAreaInput.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** biome-ignore-all lint/correctness/useHookAtTopLevel: effect-fc HOC */ -import { Callout, Flex, TextArea, type TextAreaProps } from "@radix-ui/themes" -import { Array, type Equivalence, Option, ParseResult, type Schema, Struct } from "effect" -import { Component } from "effect-fc" -import { useInput } from "effect-fc/Hooks" -import * as React from "react" - - -export type TextAreaInputProps = Omit, "schema" | "equivalence"> & Omit - -export const TextAreaInput = (options: { - readonly schema: Schema.Schema - readonly equivalence?: Equivalence.Equivalence -}): Component.Component< - TextAreaInputProps, - React.JSX.Element, - ParseResult.ParseError, - R -> => Component.makeUntraced("TextFieldInput")(function*(props) { - const input = yield* useInput({ ...options, ...props }) - const issue = React.useMemo(() => input.error.pipe( - Option.map(ParseResult.ArrayFormatter.formatErrorSync), - Option.flatMap(Array.head), - ), [input.error]) - - return ( - -